diff --git a/docs/components/alert.md b/docs/components/alert.md index d89679bb..662dc4a2 100644 --- a/docs/components/alert.md +++ b/docs/components/alert.md @@ -4,7 +4,71 @@ 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 designed to be shown dynamically, so you must include the `open` attribute to display them. + + + + + +```html preview +<div class="toast-example"> + <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" placement="top-end" 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" placement="top-end" 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" placement="top-end" duration="3000" closable> + <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. + </sl-alert> + + <sl-alert type="warning" placement="top-end" 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" placement="bottom" 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('.toast-example'); + + ['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.show()); + }); +</script> +``` + + + + + + + + + ```html preview <sl-alert open> @@ -46,16 +110,16 @@ Set the `type` attribute to change the alert's type. <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> ``` 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..940bf845 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. + */ + "duration": number; /** * Hides the alert */ @@ -19,6 +23,10 @@ export namespace Components { * Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */ "open": boolean; + /** + * Determines how the alert will be shown. If this is anything other than `inline`, the alert will be shown in a stack as a "toast" notification. When the alert is shown as a notification, it will be hoisted to a stack and removed from the DOM when hidden. (You can reuse alerts that have been removed by storing a reference to the element.) + */ + "placement": 'inline' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end'; /** * Shows the 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. + */ + "duration"?: number; /** * Emitted after the alert closes and all transitions are complete. */ @@ -1438,6 +1450,10 @@ declare namespace LocalJSX { * Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */ "open"?: boolean; + /** + * Determines how the alert will be shown. If this is anything other than `inline`, the alert will be shown in a stack as a "toast" notification. When the alert is shown as a notification, it will be hoisted to a stack and removed from the DOM when hidden. (You can reuse alerts that have been removed by storing a reference to the element.) + */ + "placement"?: 'inline' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end'; /** * The type of alert. */ diff --git a/src/components/alert/alert.light-dom.scss b/src/components/alert/alert.light-dom.scss new file mode 100644 index 00000000..ec1238c1 --- /dev/null +++ b/src/components/alert/alert.light-dom.scss @@ -0,0 +1,49 @@ +:root { + --width: 28rem; + --spacing: var(--sl-spacing-medium); +} + +.sl-alert-stack { + position: fixed; + z-index: var(--sl-z-index-toast); + width: var(--width); + max-width: 100%; + max-height: 100%; + overflow: auto; + padding: 0 var(--spacing); + + sl-alert { + --box-shadow: var(--sl-shadow-large); + margin: var(--spacing) 0; + } +} + +.sl-alert-stack[data-placement='top-start'] { + top: 0; + left: 0; +} + +.sl-alert-stack[data-placement='top'] { + top: 0; + left: calc(50% - var(--width) / 2); +} + +.sl-alert-stack[data-placement='top-end'] { + top: 0; + right: 0; +} + +.sl-alert-stack[data-placement='bottom-start'] { + bottom: 0; + left: 0; +} + +.sl-alert-stack[data-placement='bottom'] { + bottom: 0; + left: calc(50% - var(--width) / 2); +} + +.sl-alert-stack[data-placement='bottom-end'] { + bottom: 0; + right: 0; +} 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..3e9c87c1 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -13,6 +13,8 @@ import { Component, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } * @part close-button - The close button. */ +const stack = Object.assign(document.createElement('div'), { className: 'sl-alert-stack' }); + @Component({ tag: 'sl-alert', styleUrl: 'alert.scss', @@ -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; @@ -33,11 +36,31 @@ export class Alert { /** The type of alert. */ @Prop() type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary'; + /** + * Determines how the alert will be shown. If this is anything other than `inline`, the alert will be shown in a stack + * as a "toast" notification. When the alert is shown as a notification, it will be hoisted to a stack and removed + * from the DOM when hidden. (You can reuse alerts that have been removed by storing a reference to the element.) + */ + @Prop() placement: 'inline' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end' = 'inline'; + + /** The length of time, in milliseconds, the alert will show before closing itself. */ + @Prop() duration = Infinity; + @Watch('open') handleOpenChange() { this.open ? this.show() : this.hide(); } + @Watch('duration') + handleDurationChange() { + clearTimeout(this.autoHideTimeout); + + // Restart the timeout if the duration changes and the alert is open + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); + } + } + /** Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened. */ @Event() slShow: EventEmitter; @@ -80,6 +103,14 @@ export class Alert { this.host.clientWidth; // force a reflow this.isShowing = true; this.open = true; + + if (this.placement !== 'inline') { + this.appendToStack(); + } + + if (this.duration < Infinity) { + this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); + } } /** Hides the alert */ @@ -96,6 +127,7 @@ export class Alert { return; } + clearTimeout(this.autoHideTimeout); this.isShowing = false; this.open = false; } @@ -110,10 +142,34 @@ export class Alert { // Ensure we only emit one event when the target element is no longer visible if (event.propertyName === 'opacity' && target.classList.contains('alert')) { this.host.hidden = !this.open; + + if (this.placement !== 'inline' && !this.open) { + this.removeFromStack(); + } + this.open ? this.slAfterShow.emit() : this.slAfterHide.emit(); } } + appendToStack() { + if (!stack.parentElement) { + document.body.append(stack); + } + + stack.dataset.placement = this.placement; + stack.append(this.host); + } + + removeFromStack() { + this.host.remove(); + + // Remove the stack from the DOM when there are no more alerts + const openAlerts = [...stack.querySelectorAll('sl-alert')].filter((el: HTMLSlAlertElement) => el.open === true); + if (openAlerts.length === 0) { + stack.remove(); + } + } + render() { return ( <Host hidden> @@ -145,7 +201,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';