From c4856756b9f99464ee4120036ca4d7cdbf5c849d Mon Sep 17 00:00:00 2001 From: rcatoio Date: Wed, 28 Feb 2024 12:12:30 +0100 Subject: [PATCH] feat: add a countdown on sl-alert --- docs/pages/components/alert.md | 63 ++++++++++++++++++++++ src/components/alert/alert.component.ts | 71 ++++++++++++++++++++++--- src/components/alert/alert.styles.ts | 46 ++++++++++++++++ 3 files changed, 174 insertions(+), 6 deletions(-) diff --git a/docs/pages/components/alert.md b/docs/pages/components/alert.md index 5ea30cbb..59934b84 100644 --- a/docs/pages/components/alert.md +++ b/docs/pages/components/alert.md @@ -247,6 +247,69 @@ const App = () => { }; ``` +### Countdown + +Set the `countdown` attribute to display a loading bar that indicates the alert remaining time. This is useful for alerts with relatively long duration. + +```html:preview +
+ Show Alert + + + + You're not stuck, the alert will close after a pretty long duration. + +
+ + + + +``` + +```jsx:react +import { useState } from 'react'; +import SlAlert from '@shoelace-style/shoelace/dist/react/alert'; +import SlButton from '@shoelace-style/shoelace/dist/react/button'; +import SlIcon from '@shoelace-style/shoelace/dist/react/icon'; + +const css = ` + .alert-countdown sl-alert { + margin-top: var(--sl-spacing-medium); + } +`; + +const App = () => { + const [open, setOpen] = useState(false); + + return ( + <> +
+ setOpen(true)}> + Show Alert + + + setOpen(false)}> + + You're not stuck, the alert will close after a pretty long duration. + +
+ + + + ); +}; +``` + ### Toast Notifications To display an alert as a toast notification, or "toast", create the alert and call its `toast()` method. This will move the alert out of its position in the DOM and into [the toast stack](#the-toast-stack) where it will be shown. Once dismissed, it will be removed from the DOM completely. To reuse a toast, store a reference to it and call `toast()` again later on. diff --git a/src/components/alert/alert.component.ts b/src/components/alert/alert.component.ts index 13d8e52f..18673535 100644 --- a/src/components/alert/alert.component.ts +++ b/src/components/alert/alert.component.ts @@ -4,7 +4,7 @@ import { getAnimation, setDefaultAnimation } from '../../utilities/animation-reg import { HasSlotController } from '../../internal/slot.js'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize.js'; -import { property, query } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; import { waitForEvent } from '../../internal/event.js'; import { watch } from '../../internal/watch.js'; import componentStyles from '../../styles/component.styles.js'; @@ -45,11 +45,15 @@ export default class SlAlert extends ShoelaceElement { static dependencies = { 'sl-icon-button': SlIconButton }; private autoHideTimeout: number; + private remainingTimeInterval: number; + private countdownAnimation?: Animation; private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix'); private readonly localize = new LocalizeController(this); @query('[part~="base"]') base: HTMLElement; + @query('.alert__countdown-elapsed') countdownElement: HTMLElement; + /** * Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can * use the `show()` and `hide()` methods and this attribute will reflect the alert's open state. @@ -69,14 +73,57 @@ export default class SlAlert extends ShoelaceElement { */ @property({ type: Number }) duration = Infinity; + /** + * Enables a countdown that indicates the remaining time the alert will be displayed. + * Typically used to indicate the remaining time before a whole app refresh. + */ + @property({ type: String, reflect: true }) countdown?: 'RtL' | 'LtR'; + + @state() private remainingTime = this.duration; + firstUpdated() { this.base.hidden = !this.open; } private restartAutoHide() { + this.handleCountdownChange(); clearTimeout(this.autoHideTimeout); + clearInterval(this.remainingTimeInterval); if (this.open && this.duration < Infinity) { this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration); + this.remainingTime = this.duration; + this.remainingTimeInterval = window.setInterval(() => { + this.remainingTime -= 100; + }, 100); + } + } + + private pauseAutoHide() { + this.countdownAnimation?.pause(); + clearTimeout(this.autoHideTimeout); + clearInterval(this.remainingTimeInterval); + } + + private resumeAutoHide() { + this.autoHideTimeout = window.setTimeout(() => this.hide(), this.remainingTime); + this.remainingTimeInterval = window.setInterval(() => { + this.remainingTime -= 100; + }, 100); + this.countdownAnimation?.play(); + } + + private handleCountdownChange() { + if(this.open && this.duration < Infinity && this.countdown) { + const { countdownElement } = this; + const start = this.countdown === 'LtR' ? '0' : '100%'; + const end = this.countdown === 'LtR' ? '100%' : '0'; + this.countdownAnimation = countdownElement.animate([ + { width: start }, + { width: end } + ], { + duration: this.duration, + easing: 'linear' + }); } } @@ -84,10 +131,6 @@ export default class SlAlert extends ShoelaceElement { this.hide(); } - private handleMouseMove() { - this.restartAutoHide(); - } - @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { @@ -109,6 +152,7 @@ export default class SlAlert extends ShoelaceElement { this.emit('sl-hide'); clearTimeout(this.autoHideTimeout); + clearInterval(this.remainingTimeInterval); await stopAnimations(this.base); const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() }); @@ -151,6 +195,7 @@ export default class SlAlert extends ShoelaceElement { */ async toast() { return new Promise(resolve => { + this.handleCountdownChange(); if (toastStack.parentElement === null) { document.body.append(toastStack); } @@ -188,6 +233,7 @@ export default class SlAlert extends ShoelaceElement { alert: true, 'alert--open': this.open, 'alert--closable': this.closable, + 'alert--has-countdown': !!this.countdown, 'alert--has-icon': this.hasSlotController.test('icon'), 'alert--primary': this.variant === 'primary', 'alert--success': this.variant === 'success', @@ -197,7 +243,8 @@ export default class SlAlert extends ShoelaceElement { })} role="alert" aria-hidden=${this.open ? 'false' : 'true'} - @mousemove=${this.handleMouseMove} + @mouseenter=${this.pauseAutoHide} + @mouseleave=${this.resumeAutoHide} >
@@ -220,6 +267,18 @@ export default class SlAlert extends ShoelaceElement { > ` : ''} + +
${this.remainingTime}
+ + ${this.countdown ? html` +
+
+
+
+ `: ''}
`; } diff --git a/src/components/alert/alert.styles.ts b/src/components/alert/alert.styles.ts index 884230c5..a79d23d2 100644 --- a/src/components/alert/alert.styles.ts +++ b/src/components/alert/alert.styles.ts @@ -22,6 +22,7 @@ export default css` line-height: 1.6; color: var(--sl-color-neutral-700); margin: inherit; + overflow: hidden; } .alert:not(.alert--has-icon) .alert__icon, @@ -37,6 +38,10 @@ export default css` padding-inline-start: var(--sl-spacing-large); } + .alert--has-countdown { + border-bottom: none; + } + .alert--primary { border-top-color: var(--sl-color-primary-600); } @@ -91,4 +96,45 @@ export default css` font-size: var(--sl-font-size-medium); padding-inline-end: var(--sl-spacing-medium); } + + .alert__countdown { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: calc(var(--sl-panel-border-width) * 3); + background-color: var(--sl-panel-border-color); + } + + .alert__countdown .alert__countdown-elapsed { + height: 100%; + } + + .alert__countdown .alert__countdown-elapsed--rtl { + width: 0; + } + + .alert--primary .alert__countdown-elapsed { + background-color: var(--sl-color-primary-600); + } + + .alert--success .alert__countdown-elapsed { + background-color: var(--sl-color-success-600); + } + + .alert--neutral .alert__countdown-elapsed { + background-color: var(--sl-color-neutral-600); + } + + .alert--warning .alert__countdown-elapsed { + background-color: var(--sl-color-warning-600); + } + + .alert--danger .alert__countdown-elapsed { + background-color: var(--sl-color-danger-600); + } + + .alert__timer { + display: none; + } `;