diff --git a/docs/components/alert.md b/docs/components/alert.md index d89679bb..b5076bda 100644 --- a/docs/components/alert.md +++ b/docs/components/alert.md @@ -2,9 +2,7 @@ [component-header:sl-alert] -Alerts are used to display important messages. - -Alerts are designed to be shown dynamically, so you need to include the `open` attribute to display them. +Alerts are used to display important messages either inline or as toast notifications. ```html preview <sl-alert open> @@ -39,23 +37,23 @@ Set the `type` attribute to change the alert's type. <sl-alert type="info" open> <sl-icon slot="icon" name="gear"></sl-icon> <strong>Your settings have been updated</strong><br> - Some settings will take affect the next time you log in. + Settings will take affect on next login. </sl-alert> <br> <sl-alert type="warning" open> <sl-icon slot="icon" name="exclamation-triangle"></sl-icon> - <strong>This will end your session</strong><br> - You will be logged out until you log in again. + <strong>Your session has ended</strong><br> + Please login again to continue. </sl-alert> <br> <sl-alert type="danger" open> <sl-icon slot="icon" name="exclamation-octagon"></sl-icon> - <strong>Delete this file?</strong><br> - This is permanent, which means forever! + <strong>Your account has been deleted</strong><br> + We're very sorry to see you go! </sl-alert> ``` @@ -87,4 +85,142 @@ Icons are optional. Simply omit the `icon` slot if you don't want them. </sl-alert> ``` +### Duration + +Set the `duration` prop to automatically hide an alert after a period of time. This is useful for alerts that don't require acknowledgement. + +```html preview +<div class="alert-duration"> + <sl-button type="primary">Show Alert</sl-button> + + <sl-alert type="primary" duration="3000" closable style="margin-top: var(--sl-spacing-medium);"> + <sl-icon slot="icon" name="info-circle"></sl-icon> + This alert will automatically hide itself after three seconds, unless you interact with it. + </sl-alert> +</div> + +<script> + const container = document.querySelector('.alert-duration'); + const button = container.querySelector('sl-button'); + const alert = container.querySelector('sl-alert'); + + button.addEventListener('click', () => alert.show()); +</script> +``` + +### 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. + +You should always use the `closable` prop so users can dismiss the notification. It's also common to set a reasonable `duration` when the notification doesn't require acknowledgement. + +```html preview +<div class="alert-toast"> + <sl-button type="primary">Primary</sl-button> + <sl-button type="success">Success</sl-button> + <sl-button type="info">Info</sl-button> + <sl-button type="warning">Warning</sl-button> + <sl-button type="danger">Danger</sl-button> + + <sl-alert type="primary" duration="3000" closable> + <sl-icon slot="icon" name="info-circle"></sl-icon> + <strong>This is super informative</strong><br> + You can tell by how pretty the alert is. + </sl-alert> + + <sl-alert type="success" duration="3000" closable> + <sl-icon slot="icon" name="check2-circle"></sl-icon> + <strong>Your changes have been saved</strong><br> + You can safely exit the app now. + </sl-alert> + + <sl-alert type="info" duration="3000" closable> + <sl-icon slot="icon" name="gear"></sl-icon> + <strong>Your settings have been updated</strong><br> + Settings will take affect on next login. + </sl-alert> + + <sl-alert type="warning" duration="3000" closable> + <sl-icon slot="icon" name="exclamation-triangle"></sl-icon> + <strong>Your session has ended</strong><br> + Please login again to continue. + </sl-alert> + + <sl-alert type="danger" duration="3000" closable> + <sl-icon slot="icon" name="exclamation-octagon"></sl-icon> + <strong>Your account has been deleted</strong><br> + We're very sorry to see you go! + </sl-alert> +</div> + +<script> + const container = document.querySelector('.alert-toast'); + + ['primary', 'success', 'info', 'warning', 'danger'].map(type => { + const button = container.querySelector(`sl-button[type="${type}"]`); + const alert = container.querySelector(`sl-alert[type="${type}"]`); + + button.addEventListener('click', () => alert.toast()); + }); +</script> +``` + +### Creating Toasts Imperatively + +For convenience, you can create a utility that emits toast notifications with a function call rather than composing them in your HTML. To do this, generate the alert with JavaScript, append it to the body, and call the `toast()` method as shown in the example below. + +```html preview +<div class="alert-toast-wrapper"> + <sl-button type="primary">Create Toast</sl-button> +</div> + +<script> + const container = document.querySelector('.alert-toast-wrapper'); + const button = container.querySelector('sl-button'); + let count = 0; + + // Always escape HTML for text arguments! + function escapeHtml(html) { + const div = document.createElement('div'); + div.textContent = html; + return div.innerHTML; + } + + // Custom function to emit toast notifications + function notify(message, type = 'primary', icon = 'info-circle', duration = 3000) { + const alert = Object.assign(document.createElement('sl-alert'), { + type: type, + closable: true, + duration: duration, + innerHTML: ` + <sl-icon name="${icon}" slot="icon"></sl-icon> + ${escapeHtml(message)} + ` + }); + + document.body.append(alert); + return alert.toast(); + } + + button.addEventListener('click', () => { + notify(`This is custom toast #${++count}`); + }); +</script> +``` + +### The Toast Stack + +The toast stack is a fixed position singleton element created and managed internally by the alert component. It will be added and removed from the DOM as needed when toasts are shown. When more than one toast is visible, they will stack vertically in the toast stack. + +By default, the toast stack is positioned at the top-right of the viewport. You can change its position by targeting `.sl-toast-stack` in your stylesheet. To make toasts appear at the top-left of the viewport, for example, use the following styles. + +```css +.sl-toast-stack { + left: 0; + right: auto; +} +``` + +?> By design, it is not possible to show toasts in more than one stack simultaneously. Such behavior is confusing and makes for a poor user experience. + [component-metadata:sl-alert] diff --git a/docs/tokens/z-index.md b/docs/tokens/z-index.md index 41d6f346..c596a5d7 100644 --- a/docs/tokens/z-index.md +++ b/docs/tokens/z-index.md @@ -7,4 +7,5 @@ Z-indexes are used to stack components in a logical manner. | `--sl-z-index-drawer` | 700 | | `--sl-z-index-dialog` | 800 | | `--sl-z-index-dropdown` | 900 | +| `--sl-z-index-alert-group` | 950 | | `--sl-z-index-tooltip` | 1000 | diff --git a/src/components.d.ts b/src/components.d.ts index c108421b..123426b3 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -11,6 +11,10 @@ export namespace Components { * Set to true to make the alert closable. */ "closable": boolean; + /** + * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with the alert before it closes (e.g. moves the mouse over it), the timer will restart. + */ + "duration": number; /** * Hides the alert */ @@ -23,6 +27,10 @@ export namespace Components { * Shows the alert. */ "show": () => Promise<void>; + /** + * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by calling this method again. The returned promise will resolve after the alert is hidden. + */ + "toast": () => Promise<unknown>; /** * The type of alert. */ @@ -1418,6 +1426,10 @@ declare namespace LocalJSX { * Set to true to make the alert closable. */ "closable"?: boolean; + /** + * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with the alert before it closes (e.g. moves the mouse over it), the timer will restart. + */ + "duration"?: number; /** * Emitted after the alert closes and all transitions are complete. */ diff --git a/src/components/alert/alert.light-dom.scss b/src/components/alert/alert.light-dom.scss new file mode 100644 index 00000000..93921af4 --- /dev/null +++ b/src/components/alert/alert.light-dom.scss @@ -0,0 +1,15 @@ +.sl-toast-stack { + position: fixed; + top: 0; + right: 0; + z-index: var(--sl-z-index-toast); + width: 28rem; + max-width: 100%; + max-height: 100%; + overflow: auto; + + sl-alert { + --box-shadow: var(--sl-shadow-large); + margin: var(--sl-spacing-medium); + } +} diff --git a/src/components/alert/alert.scss b/src/components/alert/alert.scss index 4911db01..d060ef76 100644 --- a/src/components/alert/alert.scss +++ b/src/components/alert/alert.scss @@ -1,6 +1,11 @@ @import 'component'; +/** + * @prop --box-shadow: The alert's box shadow. + */ :host { + --box-shadow: none; + display: block; &[hidden] { @@ -16,17 +21,20 @@ border: solid 1px var(--sl-color-gray-90); border-top-width: 3px; border-radius: var(--sl-border-radius-medium); + box-shadow: var(--box-shadow); font-family: var(--sl-font-sans); font-size: var(--sl-font-size-small); font-weight: var(--sl-font-weight-normal); line-height: 1.6; color: var(--sl-color-gray-30); opacity: 0; - transition: var(--sl-transition-medium) opacity ease; + transform: scale(0.9); + transition: var(--sl-transition-medium) opacity ease, var(--sl-transition-medium) transform ease; } .alert--open { opacity: 1; + transform: scale(1); } .alert__icon { @@ -91,5 +99,5 @@ display: flex; align-items: center; font-size: var(--sl-font-size-large); - padding: 0 var(--sl-spacing-medium); + padding-right: var(--sl-spacing-medium); } diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index 9fcf4b1a..201357b7 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -1,5 +1,7 @@ import { Component, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } from '@stencil/core'; +const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); + /** * @since 2.0 * @status stable @@ -20,6 +22,7 @@ import { Component, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } }) export class Alert { alert: HTMLElement; + autoHideTimeout: any; isShowing = false; @Element() host: HTMLSlAlertElement; @@ -28,16 +31,27 @@ export class Alert { @Prop({ mutable: true, reflect: true }) open = false; /** Set to true to make the alert closable. */ - @Prop() closable = false; + @Prop({ reflect: true }) closable = false; /** The type of alert. */ - @Prop() type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary'; + @Prop({ reflect: true }) type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary'; + + /** + * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with the + * alert before it closes (e.g. moves the mouse over it), the timer will restart. + */ + @Prop() duration = Infinity; @Watch('open') handleOpenChange() { this.open ? this.show() : this.hide(); } + @Watch('duration') + handleDurationChange() { + this.restartAutoHide(); + } + /** Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened. */ @Event() slShow: EventEmitter; @@ -52,6 +66,7 @@ export class Alert { connectedCallback() { this.handleCloseClick = this.handleCloseClick.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); this.handleTransitionEnd = this.handleTransitionEnd.bind(this); } @@ -80,6 +95,10 @@ export class Alert { this.host.clientWidth; // force a reflow this.isShowing = true; this.open = true; + + if (this.duration < Infinity) { + this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); + } } /** Hides the alert */ @@ -96,14 +115,50 @@ export class Alert { return; } + clearTimeout(this.autoHideTimeout); this.isShowing = false; this.open = false; } + /** + * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when + * dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by + * calling this method again. The returned promise will resolve after the alert is hidden. + */ + @Method() + async toast() { + return new Promise(resolve => { + if (!toastStack.parentElement) { + document.body.append(toastStack); + } + + toastStack.append(this.host); + this.show(); + + this.host.addEventListener( + 'slAfterHide', + () => { + this.host.remove(); + resolve(); + + // Remove the toast stack from the DOM when there are no more alerts + if (toastStack.querySelector('sl-alert') === null) { + toastStack.remove(); + } + }, + { once: true } + ); + }); + } + handleCloseClick() { this.hide(); } + handleMouseMove() { + this.restartAutoHide(); + } + handleTransitionEnd(event: TransitionEvent) { const target = event.target as HTMLElement; @@ -114,6 +169,13 @@ export class Alert { } } + restartAutoHide() { + clearTimeout(this.autoHideTimeout); + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); + } + } + render() { return ( <Host hidden> @@ -124,8 +186,6 @@ export class Alert { alert: true, 'alert--open': this.open, 'alert--closable': this.closable, - - // States 'alert--primary': this.type === 'primary', 'alert--success': this.type === 'success', 'alert--info': this.type === 'info', @@ -133,7 +193,10 @@ export class Alert { 'alert--danger': this.type === 'danger' }} role="alert" + aria-live="assertive" + aria-atomic="true" aria-hidden={!this.open} + onMouseMove={this.handleMouseMove} onTransitionEnd={this.handleTransitionEnd} > <span part="icon" class="alert__icon"> @@ -145,7 +208,9 @@ export class Alert { </span> {this.closable && ( - <sl-icon-button part="close-button" class="alert__close" name="x" onClick={this.handleCloseClick} /> + <span class="alert__close"> + <sl-icon-button part="close-button" name="x" onClick={this.handleCloseClick} /> + </span> )} </div> </Host> diff --git a/src/styles/shoelace.scss b/src/styles/shoelace.scss index 1e0fa5c4..b00b8e9a 100644 --- a/src/styles/shoelace.scss +++ b/src/styles/shoelace.scss @@ -249,6 +249,7 @@ --sl-z-index-drawer: 700; --sl-z-index-dialog: 800; --sl-z-index-dropdown: 900; + --sl-z-index-toast: 950; --sl-z-index-tooltip: 1000; } @@ -268,4 +269,5 @@ // Component light DOM styles - only follow this pattern when absolutely necessary! //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +@import '../components/alert/alert.light-dom'; @import '../components/button-group/button-group.light-dom';