diff --git a/docs/pages/components/alert.md b/docs/pages/components/alert.md
index 5ea30cbb..67f99498 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..f25a5fda 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,56 @@ 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() {
+ if (this.duration < Infinity) {
+ 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 = '100%';
+ const end = '0';
+ this.countdownAnimation = countdownElement.animate([{ width: start }, { width: end }], {
+ duration: this.duration,
+ easing: 'linear'
+ });
}
}
@@ -84,10 +130,6 @@ export default class SlAlert extends ShoelaceElement {
this.hide();
}
- private handleMouseMove() {
- this.restartAutoHide();
- }
-
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
@@ -109,6 +151,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 +194,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 +232,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 +242,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 +266,21 @@ 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..20a1729a 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,47 @@ 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);
+ display: flex;
+ }
+
+ .alert__countdown--ltr {
+ justify-content: flex-end;
+ }
+
+ .alert__countdown .alert__countdown-elapsed {
+ height: 100%;
+ 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;
+ }
`;