From ad9de5475229a18a3027de48c5bd20970cd8ac46 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 16 Sep 2020 09:34:54 -0400 Subject: [PATCH 01/10] Light DOM variation --- docs/components/alert.md | 74 +++++++++++++++++++++-- docs/tokens/z-index.md | 1 + src/components.d.ts | 16 +++++ src/components/alert/alert.light-dom.scss | 49 +++++++++++++++ src/components/alert/alert.scss | 12 +++- src/components/alert/alert.tsx | 60 +++++++++++++++++- src/styles/shoelace.scss | 2 + 7 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 src/components/alert/alert.light-dom.scss diff --git a/docs/components/alert.md b/docs/components/alert.md index d89679bbf..662dc4a2f 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 +
+ Primary + Success + Info + Warning + Danger + + + + This is super informative
+ You can tell by how pretty the alert is. +
+ + + + Your changes have been saved
+ You can safely exit the app now. +
+ + + + Your settings have been updated
+ Some settings will take affect the next time you log in. +
+ + + + Your session has ended
+ Please login again to continue. +
+ + + + Your account has been deleted
+ We're very sorry to see you go! +
+
+ + +``` + + + + + + + + + ```html preview @@ -46,16 +110,16 @@ Set the `type` attribute to change the alert's type. - This will end your session
- You will be logged out until you log in again. + Your session has ended
+ Please login again to continue.

- Delete this file?
- This is permanent, which means forever! + Your account has been deleted
+ We're very sorry to see you go!
``` diff --git a/docs/tokens/z-index.md b/docs/tokens/z-index.md index 41d6f3467..c596a5d73 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 c108421be..940bf8459 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 000000000..ec1238c10 --- /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 4911db012..d060ef768 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 9fcf4b1a0..3e9c87c15 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 ( diff --git a/src/styles/shoelace.scss b/src/styles/shoelace.scss index 1e0fa5c48..b00b8e9a7 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'; From d8b8c8a050994b16f9a28387d99e187367a3011e Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 16 Sep 2020 10:16:08 -0400 Subject: [PATCH 02/10] Use toast prop and no light dom --- docs/components/alert.md | 10 +-- src/components.d.ts | 12 ++-- src/components/alert/alert.light-dom.scss | 82 +++++++++++------------ src/components/alert/alert.scss | 9 +++ src/components/alert/alert.tsx | 24 +++++-- src/styles/shoelace.scss | 1 - 6 files changed, 78 insertions(+), 60 deletions(-) diff --git a/docs/components/alert.md b/docs/components/alert.md index 662dc4a2f..ee3de0b48 100644 --- a/docs/components/alert.md +++ b/docs/components/alert.md @@ -18,31 +18,31 @@ Alerts are designed to be shown dynamically, so you must include the `open` attr Warning Danger - + This is super informative
You can tell by how pretty the alert is.
- + Your changes have been saved
You can safely exit the app now.
- + Your settings have been updated
Some settings will take affect the next time you log in.
- + Your session has ended
Please login again to continue.
- + Your account has been deleted
We're very sorry to see you go! diff --git a/src/components.d.ts b/src/components.d.ts index 940bf8459..1c69c3191 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -23,14 +23,14 @@ 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. */ "show": () => Promise; + /** + * When true, the alert will be shown as a "toast" notification. In this case, the alert will be hoisted to a stack and removed from the DOM when closed. By storing a reference to the alert element, you can reuse it by calling `alert.show()` even after it's removed from the DOM. + */ + "toast": boolean; /** * The type of alert. */ @@ -1451,9 +1451,9 @@ declare namespace LocalJSX { */ "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.) + * When true, the alert will be shown as a "toast" notification. In this case, the alert will be hoisted to a stack and removed from the DOM when closed. By storing a reference to the alert element, you can reuse it by calling `alert.show()` even after it's removed from the DOM. */ - "placement"?: 'inline' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end'; + "toast"?: boolean; /** * The type of alert. */ diff --git a/src/components/alert/alert.light-dom.scss b/src/components/alert/alert.light-dom.scss index ec1238c10..154c52550 100644 --- a/src/components/alert/alert.light-dom.scss +++ b/src/components/alert/alert.light-dom.scss @@ -1,49 +1,49 @@ -:root { - --width: 28rem; - --spacing: var(--sl-spacing-medium); -} +// :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-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 { +// --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-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'] { +// top: 0; +// left: calc(50% - var(--width) / 2); +// } -.sl-alert-stack[data-placement='top-end'] { - top: 0; - right: 0; -} +// .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-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'] { +// bottom: 0; +// left: calc(50% - var(--width) / 2); +// } -.sl-alert-stack[data-placement='bottom-end'] { - bottom: 0; - right: 0; -} +// .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 d060ef768..12e81c060 100644 --- a/src/components/alert/alert.scss +++ b/src/components/alert/alert.scss @@ -2,9 +2,11 @@ /** * @prop --box-shadow: The alert's box shadow. + * @prop --toast-spacing: The spacing to use when alerts are stacked as "toast" notifications. */ :host { --box-shadow: none; + --stack-spacing: var(--sl-spacing-medium); display: block; @@ -32,6 +34,13 @@ transition: var(--sl-transition-medium) opacity ease, var(--sl-transition-medium) transform ease; } +.alert--toast { + width: 28rem; + max-width: calc(100% - var(--stack-spacing) * 2); + box-shadow: var(--sl-shadow-large); + margin: var(--stack-spacing); +} + .alert--open { opacity: 1; transform: scale(1); diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index 3e9c87c15..0dba99bd5 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -37,11 +37,11 @@ export class 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.) + * When true, the alert will be shown as a "toast" notification. In this case, the alert will be hoisted to a stack + * and removed from the DOM when closed. By storing a reference to the alert element, you can reuse it by calling + * `alert.show()` even after it's removed from the DOM. */ - @Prop() placement: 'inline' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end' = 'inline'; + @Prop() toast = false; /** The length of time, in milliseconds, the alert will show before closing itself. */ @Prop() duration = Infinity; @@ -104,7 +104,7 @@ export class Alert { this.isShowing = true; this.open = true; - if (this.placement !== 'inline') { + if (this.toast) { this.appendToStack(); } @@ -143,7 +143,7 @@ export class Alert { if (event.propertyName === 'opacity' && target.classList.contains('alert')) { this.host.hidden = !this.open; - if (this.placement !== 'inline' && !this.open) { + if (this.toast && !this.open) { this.removeFromStack(); } @@ -156,7 +156,16 @@ export class Alert { document.body.append(stack); } - stack.dataset.placement = this.placement; + Object.assign(stack.style, { + position: 'fixed', + top: '0', + right: '0', + zIndex: 'var(--sl-z-index-toast)', + maxWidth: '100%', + maxHeight: '100%', + overflow: 'auto' + }); + stack.append(this.host); } @@ -180,6 +189,7 @@ export class Alert { alert: true, 'alert--open': this.open, 'alert--closable': this.closable, + 'alert--toast': this.toast, // States 'alert--primary': this.type === 'primary', diff --git a/src/styles/shoelace.scss b/src/styles/shoelace.scss index b00b8e9a7..152488cba 100644 --- a/src/styles/shoelace.scss +++ b/src/styles/shoelace.scss @@ -269,5 +269,4 @@ // 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'; From 017e6db629e4802f84373410f963284b791ca483 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 16 Sep 2020 10:19:06 -0400 Subject: [PATCH 03/10] Fix transitions --- src/components/alert/alert.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index 0dba99bd5..d3bcb7754 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -166,6 +166,7 @@ export class Alert { overflow: 'auto' }); + stack.clientWidth; // force a reflow stack.append(this.host); } From dbb1a69e679d6985b6c61dfb256935006588fa5f Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 16 Sep 2020 11:10:25 -0400 Subject: [PATCH 04/10] Add light dom styles; reset duration on mouse move --- src/components.d.ts | 4 +- src/components/alert/alert.light-dom.scss | 58 ++++------------------- src/components/alert/alert.scss | 12 +++-- src/components/alert/alert.tsx | 52 ++++++++++---------- src/styles/shoelace.scss | 1 + 5 files changed, 47 insertions(+), 80 deletions(-) diff --git a/src/components.d.ts b/src/components.d.ts index 1c69c3191..ec1580e01 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -12,7 +12,7 @@ export namespace Components { */ "closable": boolean; /** - * The length of time, in milliseconds, the alert will show before closing itself. + * 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 duration will restart. */ "duration": number; /** @@ -1427,7 +1427,7 @@ declare namespace LocalJSX { */ "closable"?: boolean; /** - * The length of time, in milliseconds, the alert will show before closing itself. + * 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 duration will restart. */ "duration"?: number; /** diff --git a/src/components/alert/alert.light-dom.scss b/src/components/alert/alert.light-dom.scss index 154c52550..8f4a88c98 100644 --- a/src/components/alert/alert.light-dom.scss +++ b/src/components/alert/alert.light-dom.scss @@ -1,49 +1,9 @@ -// :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; -// } +.sl-toast-stack { + position: fixed; + top: 0; + right: 0; + z-index: var(--sl-z-index-toast); + max-width: 100%; + max-height: 100%; + overflow: auto; +} diff --git a/src/components/alert/alert.scss b/src/components/alert/alert.scss index 12e81c060..fa86bad00 100644 --- a/src/components/alert/alert.scss +++ b/src/components/alert/alert.scss @@ -2,11 +2,13 @@ /** * @prop --box-shadow: The alert's box shadow. - * @prop --toast-spacing: The spacing to use when alerts are stacked as "toast" notifications. + * @prop --toast-spacing: The spacing to use when the alert is shown as a toast notification. + * @prop --toast-width: The width of the alert when shown as a toast notification. */ :host { --box-shadow: none; - --stack-spacing: var(--sl-spacing-medium); + --toast-spacing: var(--sl-spacing-medium); + --toast-width: 28rem; display: block; @@ -35,10 +37,10 @@ } .alert--toast { - width: 28rem; - max-width: calc(100% - var(--stack-spacing) * 2); + width: var(--toast-width); + max-width: calc(100% - var(--toast-spacing) * 2); box-shadow: var(--sl-shadow-large); - margin: var(--stack-spacing); + margin: var(--toast-spacing); } .alert--open { diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index d3bcb7754..ebc667093 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -13,7 +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' }); +const stack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); +stack.dataset.placement = 'top'; @Component({ tag: 'sl-alert', @@ -43,7 +44,10 @@ export class Alert { */ @Prop() toast = false; - /** The length of time, in milliseconds, the alert will show before closing itself. */ + /** + * 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 duration will restart. + */ @Prop() duration = Infinity; @Watch('open') @@ -53,12 +57,7 @@ export class Alert { @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); - } + this.restartAutoHide(); } /** Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened. */ @@ -75,6 +74,7 @@ export class Alert { connectedCallback() { this.handleCloseClick = this.handleCloseClick.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); this.handleTransitionEnd = this.handleTransitionEnd.bind(this); } @@ -93,9 +93,16 @@ export class Alert { return; } + if (this.toast) { + this.appendToStack(); + } + const slShow = this.slShow.emit(); if (slShow.defaultPrevented) { this.open = false; + if (this.toast) { + this.removeFromStack(); + } return; } @@ -104,10 +111,6 @@ export class Alert { this.isShowing = true; this.open = true; - if (this.toast) { - this.appendToStack(); - } - if (this.duration < Infinity) { this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); } @@ -136,18 +139,21 @@ export class Alert { this.hide(); } + handleMouseMove() { + this.restartAutoHide(); + } + handleTransitionEnd(event: TransitionEvent) { const target = event.target as HTMLElement; // 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; + this.open ? this.slAfterShow.emit() : this.slAfterHide.emit(); if (this.toast && !this.open) { this.removeFromStack(); } - - this.open ? this.slAfterShow.emit() : this.slAfterHide.emit(); } } @@ -156,16 +162,6 @@ export class Alert { document.body.append(stack); } - Object.assign(stack.style, { - position: 'fixed', - top: '0', - right: '0', - zIndex: 'var(--sl-z-index-toast)', - maxWidth: '100%', - maxHeight: '100%', - overflow: 'auto' - }); - stack.clientWidth; // force a reflow stack.append(this.host); } @@ -180,6 +176,13 @@ export class Alert { } } + restartAutoHide() { + clearTimeout(this.autoHideTimeout); + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = setTimeout(() => this.hide(), this.duration); + } + } + render() { return (
``` -### Toast Notifications - -When the `toast` prop is used, the alert will be displayed as a toast notification. To facilitate this, the alert is appended to the toast stack the first time it is shown and removed from the DOM when dismissed. By storing a reference to the alert, you can use it again later as shown in this example. - -```html preview -
- Primary - Success - Info - Warning - Danger - - - - This is super informative
- You can tell by how pretty the alert is. -
- - - - Your changes have been saved
- You can safely exit the app now. -
- - - - Your settings have been updated
- Settings will take affect on next login. -
- - - - Your session has ended
- Please login again to continue. -
- - - - Your account has been deleted
- We're very sorry to see you go! -
-
- - -``` - -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 toast alerts are shown. By default, the toast stack is positioned at the top-right of the viewport. When more than one alert is visible, they will stack vertically. - -You can change the toast stack's position and behavior in your stylesheet. To make toasts appear at the top-left of the viewport, for example, add the following to your stylesheet. - -```css -.sl-toast-stack { - left: 0; - right: auto; -} -``` - -?> By design, toasts cannot be shown in more than one stack. That would be distracting and confusing to users, which makes for a poor experience. - -### Creating Toasts Dynamically - -Toast alerts can be created declaratively, but you can create them imperatively as well. Just make sure to append them to the DOM before calling `show()`. - -```html preview -
- Create New Toast -
- - -``` - ### Duration -Set the `duration` prop to automatically hide an alert after a period of time. This is useful for alerts that don't require user acknowledgement and works especially well with the `toast` prop. +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
Show Alert - + This alert will automatically hide itself after three seconds, unless you interact with it. @@ -211,4 +108,114 @@ Set the `duration` prop to automatically hide an alert after a period of time. T ``` +### Toast Notifications + +To display an alert as a toast notification, call its `toast()` method. This will move the alert out of its position in the DOM and into the [toast stack](#toast-stack) where it will be shown. Once dismissed, it will be removed from the DOM completely. To reuse an alert, store a reference to it and call `toast()` again the next time you want it to appear. + +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 +
+ Primary + Success + Info + Warning + Danger + + + + This is super informative
+ You can tell by how pretty the alert is. +
+ + + + Your changes have been saved
+ You can safely exit the app now. +
+ + + + Your settings have been updated
+ Settings will take affect on next login. +
+ + + + Your session has ended
+ Please login again to continue. +
+ + + + Your account has been deleted
+ We're very sorry to see you go! +
+
+ + +``` + +For convenience, you can create a utility that emits toast notifications with a single function call. To do this, generate the alert dynamically, append it to the body, and call its `toast()` method as shown in the example below. + +```html preview +
+ Create Toast +
+ + +``` + +### Toast Stack + +The toast stack is a fixed position singleton element created and managed by the alert component. It will be added and removed from the DOM as needed when toast alerts are shown. By default, the toast stack is positioned at the top-right of the viewport. When more than one alert is visible, they will stack vertically. + +You can change the toast stack's position and behavior in your stylesheet. To make toasts appear at the top-left of the viewport, for example, add the following CSS. + +```css +.sl-toast-stack { + left: 0; + right: auto; +} +``` + +?> By design, toasts cannot be shown in more than one stack. This makes for a poor user experience, as it distracts and confuses users. + [component-metadata:sl-alert] diff --git a/src/components.d.ts b/src/components.d.ts index 449b32bdd..de18ceba1 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -28,9 +28,9 @@ export namespace Components { */ "show": () => Promise; /** - * When true, the alert will be shown as a toast notification. To facilitate this, the alert is appended to the toast stack the first time it is shown and removed from the DOM when dismissed. + * 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 resolves when the alert is hidden. */ - "toast": boolean; + "toast": () => Promise; /** * The type of alert. */ @@ -1450,10 +1450,6 @@ declare namespace LocalJSX { * Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */ "open"?: boolean; - /** - * When true, the alert will be shown as a toast notification. To facilitate this, the alert is appended to the toast stack the first time it is shown and removed from the DOM when dismissed. - */ - "toast"?: boolean; /** * The type of alert. */ diff --git a/src/components/alert/alert.light-dom.scss b/src/components/alert/alert.light-dom.scss index 8f4a88c98..93921af44 100644 --- a/src/components/alert/alert.light-dom.scss +++ b/src/components/alert/alert.light-dom.scss @@ -3,7 +3,13 @@ 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 fa86bad00..d060ef768 100644 --- a/src/components/alert/alert.scss +++ b/src/components/alert/alert.scss @@ -2,13 +2,9 @@ /** * @prop --box-shadow: The alert's box shadow. - * @prop --toast-spacing: The spacing to use when the alert is shown as a toast notification. - * @prop --toast-width: The width of the alert when shown as a toast notification. */ :host { --box-shadow: none; - --toast-spacing: var(--sl-spacing-medium); - --toast-width: 28rem; display: block; @@ -36,13 +32,6 @@ transition: var(--sl-transition-medium) opacity ease, var(--sl-transition-medium) transform ease; } -.alert--toast { - width: var(--toast-width); - max-width: calc(100% - var(--toast-spacing) * 2); - box-shadow: var(--sl-shadow-large); - margin: var(--toast-spacing); -} - .alert--open { opacity: 1; transform: scale(1); diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index 95984dbf4..1e41483d4 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -36,12 +36,6 @@ export class Alert { /** The type of alert. */ @Prop({ reflect: true }) type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary'; - /** - * When true, the alert will be shown as a toast notification. To facilitate this, the alert is appended to the toast - * stack the first time it is shown and removed from the DOM when dismissed. - */ - @Prop({ reflect: true }) toast = false; - /** * 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 duration will restart. @@ -91,16 +85,9 @@ export class Alert { return; } - if (this.toast) { - this.appendToStack(); - } - const slShow = this.slShow.emit(); if (slShow.defaultPrevented) { this.open = false; - if (this.toast) { - this.removeFromStack(); - } return; } @@ -133,6 +120,38 @@ export class Alert { 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 resolves when the alert is hidden. + */ + @Method() + async toast() { + return new Promise(resolve => { + if (!stack.parentElement) { + document.body.append(stack); + } + + stack.clientWidth; // force a reflow + stack.append(this.host); + this.show(); + + this.host.addEventListener( + 'slAfterHide', + () => { + this.host.remove(); + resolve(); + + // Remove the stack from the DOM when there are no more alerts + if (stack.querySelector('sl-alert') === null) { + stack.remove(); + } + }, + { once: true } + ); + }); + } + handleCloseClick() { this.hide(); } @@ -148,28 +167,6 @@ export class Alert { if (event.propertyName === 'opacity' && target.classList.contains('alert')) { this.host.hidden = !this.open; this.open ? this.slAfterShow.emit() : this.slAfterHide.emit(); - - if (this.toast && !this.open) { - this.removeFromStack(); - } - } - } - - appendToStack() { - if (!stack.parentElement) { - document.body.append(stack); - } - - stack.clientWidth; // force a reflow - stack.append(this.host); - } - - removeFromStack() { - this.host.remove(); - - // Remove the stack from the DOM when there are no more alerts - if (stack.querySelector('sl-alert') === null) { - stack.remove(); } } @@ -190,7 +187,6 @@ export class Alert { alert: true, 'alert--open': this.open, 'alert--closable': this.closable, - 'alert--toast': this.toast, 'alert--primary': this.type === 'primary', 'alert--success': this.type === 'success', 'alert--info': this.type === 'info', From be3aae8cb26500692f760abfe9e02881d6f86d42 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 18 Sep 2020 09:40:21 -0400 Subject: [PATCH 10/10] Polish off toast --- docs/components/alert.md | 21 +++++++++++++-------- src/components.d.ts | 6 +++--- src/components/alert/alert.tsx | 19 +++++++++---------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/components/alert.md b/docs/components/alert.md index d9660c3ce..b5076bda0 100644 --- a/docs/components/alert.md +++ b/docs/components/alert.md @@ -110,7 +110,7 @@ Set the `duration` prop to automatically hide an alert after a period of time. T ### Toast Notifications -To display an alert as a toast notification, call its `toast()` method. This will move the alert out of its position in the DOM and into the [toast stack](#toast-stack) where it will be shown. Once dismissed, it will be removed from the DOM completely. To reuse an alert, store a reference to it and call `toast()` again the next time you want it to appear. +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. @@ -165,7 +165,9 @@ You should always use the `closable` prop so users can dismiss the notification. ``` -For convenience, you can create a utility that emits toast notifications with a single function call. To do this, generate the alert dynamically, append it to the body, and call its `toast()` method as shown in the example below. +### 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
@@ -177,11 +179,14 @@ For convenience, you can create a utility that emits toast notifications with a const button = container.querySelector('sl-button'); let count = 0; + // Always escape HTML for text arguments! function escapeHtml(html) { - return document.createElement('div').appendChild(document.createTextNode(html)).parentNode.innerHTML; + const div = document.createElement('div'); + div.textContent = html; + return div.innerHTML; } - // Custom function to emit toast notifications on the fly + // 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, @@ -203,11 +208,11 @@ For convenience, you can create a utility that emits toast notifications with a ``` -### Toast Stack +### The Toast Stack -The toast stack is a fixed position singleton element created and managed by the alert component. It will be added and removed from the DOM as needed when toast alerts are shown. By default, the toast stack is positioned at the top-right of the viewport. When more than one alert is visible, they will stack vertically. +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. -You can change the toast stack's position and behavior in your stylesheet. To make toasts appear at the top-left of the viewport, for example, add the following CSS. +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 { @@ -216,6 +221,6 @@ You can change the toast stack's position and behavior in your stylesheet. To ma } ``` -?> By design, toasts cannot be shown in more than one stack. This makes for a poor user experience, as it distracts and confuses users. +?> 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/src/components.d.ts b/src/components.d.ts index de18ceba1..123426b36 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -12,7 +12,7 @@ export namespace Components { */ "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 duration will restart. + * 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; /** @@ -28,7 +28,7 @@ export namespace Components { */ "show": () => Promise; /** - * 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 resolves when the alert is hidden. + * 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; /** @@ -1427,7 +1427,7 @@ declare namespace LocalJSX { */ "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 duration will restart. + * 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; /** diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx index 1e41483d4..201357b72 100644 --- a/src/components/alert/alert.tsx +++ b/src/components/alert/alert.tsx @@ -1,6 +1,6 @@ import { Component, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } from '@stencil/core'; -const stack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); +const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); /** * @since 2.0 @@ -38,7 +38,7 @@ export class Alert { /** * 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 duration will restart. + * alert before it closes (e.g. moves the mouse over it), the timer will restart. */ @Prop() duration = Infinity; @@ -123,17 +123,16 @@ export class Alert { /** * 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 resolves when the alert is hidden. + * calling this method again. The returned promise will resolve after the alert is hidden. */ @Method() async toast() { return new Promise(resolve => { - if (!stack.parentElement) { - document.body.append(stack); + if (!toastStack.parentElement) { + document.body.append(toastStack); } - stack.clientWidth; // force a reflow - stack.append(this.host); + toastStack.append(this.host); this.show(); this.host.addEventListener( @@ -142,9 +141,9 @@ export class Alert { this.host.remove(); resolve(); - // Remove the stack from the DOM when there are no more alerts - if (stack.querySelector('sl-alert') === null) { - stack.remove(); + // Remove the toast stack from the DOM when there are no more alerts + if (toastStack.querySelector('sl-alert') === null) { + toastStack.remove(); } }, { once: true }