From 86ed8a5124d509edbdcf3201419ee74e18935073 Mon Sep 17 00:00:00 2001 From: Adam Kudrna Date: Wed, 28 Feb 2024 19:39:40 +0100 Subject: [PATCH] Feat(web): Introduce stacking of the `Toast` queue --- packages/web/CONTRIBUTING.md | 15 + packages/web/src/js/Toast.ts | 217 +++++- packages/web/src/js/__tests__/Toast.test.ts | 50 +- packages/web/src/js/constants.ts | 6 +- packages/web/src/js/utils/Transitions.ts | 12 +- .../src/scss/components/Button/_Button.scss | 11 +- .../src/scss/components/Collapse/_theme.scss | 2 +- .../src/scss/components/Header/_theme.scss | 4 +- .../web/src/scss/components/Modal/_theme.scss | 2 +- .../web/src/scss/components/Radio/_theme.scss | 2 +- .../components/ScrollView/_ScrollView.scss | 2 +- .../web/src/scss/components/Toast/README.md | 243 +++++-- .../web/src/scss/components/Toast/_Toast.scss | 83 ++- .../src/scss/components/Toast/_ToastBar.scss | 84 ++- .../web/src/scss/components/Toast/_theme.scss | 41 +- .../scss/components/Toast/dynamic-toast.mjs | 19 + .../web/src/scss/components/Toast/index.html | 629 +++++++++++------- .../web/src/scss/components/Tooltip/README.md | 18 +- .../src/scss/components/Tooltip/_theme.scss | 2 +- .../web/src/scss/settings/_transitions.scss | 10 +- .../tools/__tests__/_dictionaries.test.scss | 35 +- .../web/src/scss/tools/_dictionaries.scss | 13 +- 22 files changed, 1063 insertions(+), 437 deletions(-) create mode 100644 packages/web/src/scss/components/Toast/dynamic-toast.mjs diff --git a/packages/web/CONTRIBUTING.md b/packages/web/CONTRIBUTING.md index a7390e0801..2304efa8d7 100644 --- a/packages/web/CONTRIBUTING.md +++ b/packages/web/CONTRIBUTING.md @@ -115,6 +115,21 @@ Now use the reference from the theme in component styles: } ``` +## Documenting + +### JavaScript + +Our JavaScript plugins are documented in components' `README.md` files in the `src/scss` directory. +The documentation should include these sections: + +- Information that a JavaScript plugin is available, usually at the top of the README. +- JavaScript Plugin API โ€” a list of available options and methods, including an example. +- JavaScript Events โ€” a list of events that the plugin emits, including an example. +- As many examples as necessary throughout the whole README. + +๐Ÿ‘‰ We usually document only the โ€œkeyโ€ class methods which might be also described as +โ€œmethods that do not call any other methodsโ€. + ## Testing - `% cd /spirit-design-system/packages/web` diff --git a/packages/web/src/js/Toast.ts b/packages/web/src/js/Toast.ts index 10758bc052..e0c8e2ff8a 100644 --- a/packages/web/src/js/Toast.ts +++ b/packages/web/src/js/Toast.ts @@ -1,13 +1,19 @@ +import { CSSProperties } from 'react'; import BaseComponent from './BaseComponent'; import { ATTRIBUTE_ARIA_EXPANDED, + ATTRIBUTE_DATA_DISMISS, + ATTRIBUTE_DATA_ELEMENT, + ATTRIBUTE_DATA_POPULATE_FIELD, + ATTRIBUTE_DATA_SNIPPET, ATTRIBUTE_DATA_TARGET, CLASS_NAME_HIDDEN, - CLASS_NAME_OPEN, CLASS_NAME_TRANSITIONING, + CLASS_NAME_VISIBLE, } from './constants'; import { enableDismissTrigger, enableToggleTrigger, executeAfterTransition, SpiritConfig } from './utils'; import { EventHandler, SelectorEngine } from './dom'; +import { warning } from './common/utilities'; const NAME = 'toast'; const DATA_KEY = `${NAME}`; @@ -18,7 +24,38 @@ const EVENT_HIDDEN = `hidden${EVENT_KEY}`; const EVENT_SHOW = `show${EVENT_KEY}`; const EVENT_SHOWN = `shown${EVENT_KEY}`; +const COLOR_ICON_MAP = { + danger: 'danger', + informative: 'info', + inverted: 'info', + success: 'check-plain', + warning: 'warning', +}; + +const SELECTOR_QUEUE_ELEMENT = `[${ATTRIBUTE_DATA_ELEMENT}="toast-queue"]`; +const SELECTOR_TEMPLATE_ELEMENT = `[${ATTRIBUTE_DATA_SNIPPET}="item"]`; +const SELECTOR_ITEM_ELEMENT = `[${ATTRIBUTE_DATA_POPULATE_FIELD}="item"]`; +const SELECTOR_ICON_ELEMENT = `[${ATTRIBUTE_DATA_POPULATE_FIELD}="icon"]`; +const SELECTOR_CLOSE_BUTTON_ELEMENT = `[${ATTRIBUTE_DATA_POPULATE_FIELD}="close-button"]`; +const SELECTOR_DISMISS_TRIGGER_ELEMENT = `[${ATTRIBUTE_DATA_DISMISS}="${NAME}"]`; +const SELECTOR_MESSAGE_ELEMENT = `[${ATTRIBUTE_DATA_POPULATE_FIELD}="message"]`; + +export const SLOWEST_TRANSITION_PROPERTY_NAME = 'max-height'; // Keep in sync with transitions in `scss/Toast/_theme.scss`. + +type Color = keyof typeof COLOR_ICON_MAP; + +type Config = { + color: Color; + containerId: string; + content: HTMLElement | string; + hasIcon: boolean; + iconName: string; + id: string; + isDismissible: boolean; +}; + class Toast extends BaseComponent { + container: HTMLElement | null; isShown: boolean; triggers: HTMLElement[]; @@ -29,48 +66,185 @@ class Toast extends BaseComponent { constructor(element: SpiritElement, config?: SpiritConfig) { super(element, config); + this.container = this.getContainer(); + this.isShown = this.checkShownState(); this.triggers = this.getTriggers(); + } - this.isShown = this.checkShownState(); + checkShownState(): boolean { + return this.element + ? this.element.classList.contains(CLASS_NAME_VISIBLE) || !this.element.classList.contains(CLASS_NAME_HIDDEN) + : false; } - checkShownState() { - return this.element.classList.contains(CLASS_NAME_OPEN) || !this.element.classList.contains(CLASS_NAME_HIDDEN); + getContainer(): SpiritElement { + const config = this.config as Config; + + if (!this.element && !config.containerId) { + warning(false, `No Toast element found or no Toast container ID given.`); + + return null; + } + + if (this.element && !config.containerId) { + return this.element.closest(SELECTOR_QUEUE_ELEMENT); + } + + if (config.containerId) { + const container = SelectorEngine.findOne(`#${config.containerId}`); + + if (!container) { + warning(false, `No Toast container found with ID "${config.containerId}".`); + + return null; + } + + return SelectorEngine.findOne(SELECTOR_QUEUE_ELEMENT, container); + } + + return null; } - getTriggers() { + getTemplate(): SpiritElement { + const templateElement = SelectorEngine.findOne(SELECTOR_TEMPLATE_ELEMENT, this.container) as HTMLTemplateElement; + + if (!templateElement) { + warning(false, `No Toast template found.`); + + return null; + } + + const snippetElement = templateElement.content.cloneNode(true) as DocumentFragment; + + if (!snippetElement) { + warning(false, 'Could not create element from Toast template.'); + + return null; + } + + return snippetElement; + } + + getTriggers(): SpiritElement[] { const id = this.element && (this.element.getAttribute('id') as string); return SelectorEngine.findAll(`[${ATTRIBUTE_DATA_TARGET}="#${id}"]`); } - show() { + updateOrRemoveCloseButton(closeButtonElement: HTMLElement) { + const { color, id, isDismissible } = this.config as Config; + + if (isDismissible) { + closeButtonElement!.setAttribute('data-spirit-color', color); + closeButtonElement!.setAttribute('data-spirit-dismiss', 'toast'); + closeButtonElement!.setAttribute('data-spirit-target', `#${id}`); + closeButtonElement!.setAttribute('aria-controls', id); + } else { + closeButtonElement!.remove(); + } + } + + updateOrRemoveIcon(iconElement: HTMLElement) { + const { hasIcon, iconName, color } = this.config as Config; + + const iconUseElement = iconElement.querySelector('use') as SVGUseElement; + const originalIconPath = iconUseElement!.getAttribute('xlink:href') as string; + const iconPath = originalIconPath.substring(0, originalIconPath.indexOf('#')); + + if (hasIcon) { + iconUseElement!.setAttribute('xlink:href', `${iconPath}#${iconName || COLOR_ICON_MAP[color]}`); + } else { + iconElement!.remove(); + } + } + + createFromTemplate(): SpiritElement { + const template = this.getTemplate(); + if (!template) { + return null; + } + + const config = this.config as Config; + if (!config.content) { + warning(false, 'Toast content is required, nothing given.'); + + return null; + } + + const itemElement = template.querySelector(SELECTOR_ITEM_ELEMENT) as HTMLElement; + const iconElement = template.querySelector(SELECTOR_ICON_ELEMENT) as HTMLElement; + const closeButtonElement = template.querySelector(SELECTOR_CLOSE_BUTTON_ELEMENT) as HTMLElement; + const messageElement = template.querySelector(SELECTOR_MESSAGE_ELEMENT) as HTMLElement; + + itemElement!.setAttribute('id', config.id); + itemElement!.setAttribute('data-spirit-color', config.color); + + this.updateOrRemoveIcon(iconElement); + this.updateOrRemoveCloseButton(closeButtonElement); + + messageElement!.innerHTML = typeof config.content === 'string' ? config.content : config.content.outerHTML; + + return itemElement; + } + + addEvents(): void { + const dismissButtonElement = SelectorEngine.findOne(SELECTOR_DISMISS_TRIGGER_ELEMENT, this.element); + if (dismissButtonElement) { + EventHandler.on(dismissButtonElement, 'click', (event: Event) => { + event.preventDefault(); + this.hide(); + }); + } + } + + show(): void { + const config = this.config as Config; + if (this.isShown) { return; } + this.element = this.element || this.createFromTemplate(); + if (!this.element) { + return; + } + const showEvent = EventHandler.trigger(this.element, Toast.eventName(EVENT_SHOW)) as Event; if (showEvent.defaultPrevented) { return; } + this.container?.appendChild(this.element); + + if (config.isDismissible) { + this.addEvents(); + } + this.triggers.forEach((element) => { element?.setAttribute(ATTRIBUTE_ARIA_EXPANDED, 'true'); }); - this.element.classList.remove(CLASS_NAME_HIDDEN); - this.element.classList.add(CLASS_NAME_OPEN); - this.element.classList.add(CLASS_NAME_TRANSITIONING); + // Use setTimeout to force starting the transition + setTimeout(() => { + this.element.classList.remove(CLASS_NAME_HIDDEN); + this.element.classList.add(CLASS_NAME_VISIBLE); + this.element.classList.add(CLASS_NAME_TRANSITIONING); + }, 0); - executeAfterTransition(this.element, () => { - EventHandler.trigger(this.element, Toast.eventName(EVENT_SHOWN)); - this.element.classList.remove(CLASS_NAME_TRANSITIONING); - }); + executeAfterTransition( + this.element, + () => { + EventHandler.trigger(this.element, Toast.eventName(EVENT_SHOWN)); + this.element.classList.remove(CLASS_NAME_TRANSITIONING); + }, + true, + SLOWEST_TRANSITION_PROPERTY_NAME as CSSProperties, + ); this.isShown = true; } - hide() { + hide(): void { if (!this.isShown) { return; } @@ -84,14 +258,19 @@ class Toast extends BaseComponent { element?.setAttribute(ATTRIBUTE_ARIA_EXPANDED, 'false'); }); - this.element.classList.remove(CLASS_NAME_OPEN); + this.element.classList.remove(CLASS_NAME_VISIBLE); this.element.classList.add(CLASS_NAME_HIDDEN); this.element.classList.add(CLASS_NAME_TRANSITIONING); - executeAfterTransition(this.element, () => { - EventHandler.trigger(this.element, Toast.eventName(EVENT_HIDDEN)); - this.element.remove(); - }); + executeAfterTransition( + this.element, + () => { + EventHandler.trigger(this.element, Toast.eventName(EVENT_HIDDEN)); + this.element.remove(); + }, + true, + SLOWEST_TRANSITION_PROPERTY_NAME as CSSProperties, + ); this.isShown = false; } diff --git a/packages/web/src/js/__tests__/Toast.test.ts b/packages/web/src/js/__tests__/Toast.test.ts index 7e8db41d5f..4c0f8f13d8 100644 --- a/packages/web/src/js/__tests__/Toast.test.ts +++ b/packages/web/src/js/__tests__/Toast.test.ts @@ -1,25 +1,29 @@ import { clearFixture, getFixture } from '../../../tests/helpers/fixture'; -import Toast from '../Toast'; +import Toast, { SLOWEST_TRANSITION_PROPERTY_NAME } from '../Toast'; import { ATTRIBUTE_ARIA_EXPANDED, ATTRIBUTE_DATA_TARGET, CLASS_NAME_HIDDEN, - CLASS_NAME_OPEN, CLASS_NAME_TRANSITIONING, + CLASS_NAME_VISIBLE, } from '../constants'; import EventHandler from '../dom/EventHandler'; const testId = 'toast-test'; -const toastHtmlClosed = ` +const toastHtmlHidden = ` `; -const toastHtmlOpened = ` -
- +const toastHtmlVisible = ` +
+
+ +
`; @@ -54,7 +58,7 @@ describe('Toast', () => { describe('constructor', () => { it('should construct a toast', () => { - fixtureEl.innerHTML = toastHtmlClosed; + fixtureEl.innerHTML = toastHtmlHidden; const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; const toast = new Toast(element); @@ -64,7 +68,7 @@ describe('Toast', () => { describe('show', () => { it('should show a toast', async () => { - fixtureEl.innerHTML = toastHtmlClosed; + fixtureEl.innerHTML = toastHtmlHidden; const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; const toast = new Toast(element); @@ -72,24 +76,27 @@ describe('Toast', () => { const showSpy = jest.spyOn(Toast.prototype, 'show'); - await toast.show(); + toast.show(); expect(showSpy).toHaveBeenCalled(); expect(trigger).toHaveAttribute(ATTRIBUTE_ARIA_EXPANDED, 'true'); - expect(element).toHaveClass(CLASS_NAME_OPEN); - expect(element).toHaveClass(CLASS_NAME_TRANSITIONING); + expect(element).toHaveClass(CLASS_NAME_HIDDEN); + expect(element).not.toHaveClass(CLASS_NAME_TRANSITIONING); + expect(element).not.toHaveClass(CLASS_NAME_VISIBLE); - EventHandler.trigger(element, 'transitionend'); + EventHandler.trigger(element, 'transitionend', { propertyName: SLOWEST_TRANSITION_PROPERTY_NAME }); - expect(element).toHaveClass(CLASS_NAME_OPEN); - expect(element).not.toHaveClass(CLASS_NAME_HIDDEN); - expect(element).not.toHaveClass(CLASS_NAME_TRANSITIONING); + setTimeout(() => { + expect(element).toHaveClass(CLASS_NAME_VISIBLE); + expect(element).not.toHaveClass(CLASS_NAME_HIDDEN); + expect(element).not.toHaveClass(CLASS_NAME_TRANSITIONING); + }, 0); }); }); describe('hide', () => { it('should hide a toast', async () => { - fixtureEl.innerHTML = toastHtmlOpened; + fixtureEl.innerHTML = toastHtmlVisible; const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; const toast = new Toast(element); @@ -97,16 +104,19 @@ describe('Toast', () => { const hideSpy = jest.spyOn(Toast.prototype, 'hide'); - await toast.hide(); + toast.hide(); expect(hideSpy).toHaveBeenCalled(); expect(trigger).toHaveAttribute(ATTRIBUTE_ARIA_EXPANDED, 'false'); expect(element).toHaveClass(CLASS_NAME_HIDDEN); expect(element).toHaveClass(CLASS_NAME_TRANSITIONING); + expect(element).not.toHaveClass(CLASS_NAME_VISIBLE); - EventHandler.trigger(element, 'transitionend'); + EventHandler.trigger(element, 'transitionend', { propertyName: SLOWEST_TRANSITION_PROPERTY_NAME }); - expect(fixtureEl.querySelector('.ToastBar')).toBeNull(); + setTimeout(() => { + expect(fixtureEl.querySelector('.ToastBar')).toBeNull(); + }, 0); }); }); }); diff --git a/packages/web/src/js/constants.ts b/packages/web/src/js/constants.ts index 9585600392..45068da71b 100644 --- a/packages/web/src/js/constants.ts +++ b/packages/web/src/js/constants.ts @@ -1,6 +1,10 @@ -export const ATTRIBUTE_ARIA_EXPANDED = 'aria-expanded'; export const ATTRIBUTE_ARIA_CONTROLS = 'aria-controls'; +export const ATTRIBUTE_ARIA_EXPANDED = 'aria-expanded'; + export const ATTRIBUTE_DATA_DISMISS = 'data-spirit-dismiss'; +export const ATTRIBUTE_DATA_ELEMENT = 'data-spirit-element'; +export const ATTRIBUTE_DATA_POPULATE_FIELD = 'data-spirit-populate-field'; +export const ATTRIBUTE_DATA_SNIPPET = 'data-spirit-snippet'; export const ATTRIBUTE_DATA_TARGET = 'data-spirit-target'; export const ATTRIBUTE_DATA_TOGGLE = 'data-spirit-toggle'; diff --git a/packages/web/src/js/utils/Transitions.ts b/packages/web/src/js/utils/Transitions.ts index 416a91a2ec..0e8530376d 100644 --- a/packages/web/src/js/utils/Transitions.ts +++ b/packages/web/src/js/utils/Transitions.ts @@ -1,3 +1,4 @@ +import { CSSProperties } from 'react'; import EventHandler from '../dom/EventHandler'; const triggerTransitionEnd = (element: HTMLElement) => { @@ -26,7 +27,12 @@ const getTransitionDurationFromElement = (element: HTMLElement) => { const execute = (possibleCallback: (...args: unknown[]) => void, args = [], defaultValue = possibleCallback) => typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue; -const executeAfterTransition = (transitionElement: HTMLElement, callback: () => void, waitForTransition = true) => { +const executeAfterTransition = ( + transitionElement: HTMLElement, + callback: () => void, + waitForTransition = true, + propertyName: CSSProperties | null = null, +) => { if (!waitForTransition) { execute(callback); @@ -38,8 +44,8 @@ const executeAfterTransition = (transitionElement: HTMLElement, callback: () => let called = false; - const handler = (event: Event) => { - if (event.target !== transitionElement) { + const handler = (event: TransitionEvent) => { + if (event.target !== transitionElement || (propertyName && event.propertyName !== propertyName)) { return; } diff --git a/packages/web/src/scss/components/Button/_Button.scss b/packages/web/src/scss/components/Button/_Button.scss index 2d5fdcf759..fb97869a15 100644 --- a/packages/web/src/scss/components/Button/_Button.scss +++ b/packages/web/src/scss/components/Button/_Button.scss @@ -37,11 +37,12 @@ } @include dictionaries.generate-colors( - 'Button', - theme.$color-dictionary, - theme.$color-dictionary-config, - theme.$color-dictionary-states, - theme.$color-dictionary-overrides + $class-name: 'Button', + $dictionary-values: theme.$color-dictionary, + $config: theme.$color-dictionary-config, + $states: theme.$color-dictionary-states, + $overrides: theme.$color-dictionary-overrides, + $generate-data-attribute: true ); @include dictionaries.generate-sizes('Button', theme.$sizes); diff --git a/packages/web/src/scss/components/Collapse/_theme.scss b/packages/web/src/scss/components/Collapse/_theme.scss index c3fbbb7994..6c95cf50ce 100644 --- a/packages/web/src/scss/components/Collapse/_theme.scss +++ b/packages/web/src/scss/components/Collapse/_theme.scss @@ -3,4 +3,4 @@ $breakpoints: tokens.$breakpoints; $collapse-transition-timing: transitions.$timing-eased-in-out-fast; -$collapse-transition-duration: transitions.$duration-medium; +$collapse-transition-duration: transitions.$duration-200; diff --git a/packages/web/src/scss/components/Header/_theme.scss b/packages/web/src/scss/components/Header/_theme.scss index b30cf7b38d..79b4e8e1cf 100644 --- a/packages/web/src/scss/components/Header/_theme.scss +++ b/packages/web/src/scss/components/Header/_theme.scss @@ -29,7 +29,7 @@ $header-link-color-current: tokens.$text-primary-inverted-default; $header-link-background-color-current: tokens.$background-interactive-inverted-active; $header-link-current-border-thickness: 3px; $header-link-current-border-color: tokens.$action-selected-default; -$header-link-transition-duration: transitions.$duration-medium; +$header-link-transition-duration: transitions.$duration-200; $header-link-transition-timing: transitions.$timing-eased-out; // HeaderDialog @@ -39,7 +39,7 @@ $header-dialog-padding: tokens.$space-700; $header-dialog-color: tokens.$text-primary-inverted-default; $header-dialog-background-color: tokens.$background-inverted; $header-dialog-shadow: tokens.$shadow-400; -$header-dialog-transition-duration: transitions.$duration-medium; +$header-dialog-transition-duration: transitions.$duration-200; $header-dialog-transition-timing: transitions.$timing-eased-in-out; $header-dialog-backdrop-background-color: tokens.$background-backdrop; diff --git a/packages/web/src/scss/components/Modal/_theme.scss b/packages/web/src/scss/components/Modal/_theme.scss index 929519ff58..3ef487d605 100644 --- a/packages/web/src/scss/components/Modal/_theme.scss +++ b/packages/web/src/scss/components/Modal/_theme.scss @@ -6,7 +6,7 @@ $padding-x: tokens.$space-700; $padding-x-tablet: tokens.$space-1100; $padding-y: tokens.$space-600; $typography: tokens.$body-medium-text-regular; -$transition-duration: transitions.$duration-medium; +$transition-duration: transitions.$duration-200; $transition-scale-ratio: transitions.$scale-ratio-large-objects; $transition-shift-distance: transitions.$shift-distance-medium; diff --git a/packages/web/src/scss/components/Radio/_theme.scss b/packages/web/src/scss/components/Radio/_theme.scss index 41c4d49dc2..f4e43062c4 100644 --- a/packages/web/src/scss/components/Radio/_theme.scss +++ b/packages/web/src/scss/components/Radio/_theme.scss @@ -11,6 +11,6 @@ $input-box-shadow-y: tokens.$space-600; $input-box-shadow-before: inset $input-box-shadow-x $input-box-shadow-y form-fields-theme.$inline-field-input-color-checked; $input-box-shadow-disabled: inset $input-box-shadow-x $input-box-shadow-y form-fields-theme.$input-color-disabled; -$input-transition-duration: transitions.$duration-fast; +$input-transition-duration: transitions.$duration-100; $input-transition-timing: transitions.$timing-eased-in-out; $input-focus-shadow: tokens.$focus; diff --git a/packages/web/src/scss/components/ScrollView/_ScrollView.scss b/packages/web/src/scss/components/ScrollView/_ScrollView.scss index 40edc6363b..1ead9c83ef 100644 --- a/packages/web/src/scss/components/ScrollView/_ScrollView.scss +++ b/packages/web/src/scss/components/ScrollView/_ScrollView.scss @@ -58,7 +58,7 @@ visibility: hidden; opacity: 0; transition-property: visibility, opacity, transform; - transition-duration: transitions.$duration-medium; + transition-duration: transitions.$duration-200; } } diff --git a/packages/web/src/scss/components/Toast/README.md b/packages/web/src/scss/components/Toast/README.md index 7b76e2c78f..72d64e3aaf 100644 --- a/packages/web/src/scss/components/Toast/README.md +++ b/packages/web/src/scss/components/Toast/README.md @@ -125,21 +125,35 @@ sorted from top to bottom for the `top` vertical alignment, and from bottom to t ๐Ÿ‘‰ Please note the _actual_ order in the DOM is followed when users tab over the interface, no matter the _visual_ order of the toast queue. -#### Toast Queue Limitations - -While the Toast queue becomes scrollable when it does not fit the screen, we recommend displaying only a few toasts at -once for several reasons: +#### Scrolling -โš ๏ธ **We strongly discourage from displaying too many toasts at once as it may cause the page to be unusable, -especially on mobile screens. As of now, there is no automatic stacking of the toast queue items. It is the -responsibility of the developer to ensure that the Toast queue does not overflow the screen.** +By default, the Toast queue becomes scrollable when it does not fit the screen. -โš ๏ธ Please note that scrolling is only available on pointer-equipped devices (mouse, trackpad). Furthermore, scrolling is -only possible when the cursor is placed over the toast message boxes. This way the page content behind the toast -messages can remain accessible. +โš ๏ธ Please note that scrolling is not available on iOS devices due to a limitation in the WebKit engine. ๐Ÿ‘‰ Please note that the initial scroll position is always at the **top** of the queue. +#### Collapsing + +To make the queue collapsible, just add the `.Toast--collapsible` modifier class. The collapsible Toast queue can then +hold up to 3 ToastBar components. When the queue is full, the oldest ToastBar components are collapsed at the start of +the queue and are only accessible by closing the newer ones. + +```html +
+
+ +
+
+``` + +#### Toast Queue Limitations + +๐Ÿ‘‰ Please note only the _visible_ ToastBar components are scrollable. Collapsed items are not accessible until visible +items are dismissed. + +๐Ÿ‘‰ For the sake of simplicity, the collapsible items limit cannot be configured at the moment. + ## ToastBar The ToastBar component is the actual toast notification. It is a simple container with a message and a few optional @@ -149,8 +163,10 @@ Minimum example: ```html
-
-
Message only
+
+
+
Message only
+
``` @@ -161,11 +177,13 @@ An icon can be added to the ToastBar component: ```html
-
- -
Message with icon
+
+
+ +
Message with icon
+
``` @@ -176,10 +194,12 @@ An action link can be added to the ToastBar component: ```html
-
-
- Message with action - Action +
+
+
+ Message with action + Action +
@@ -187,7 +207,7 @@ An action link can be added to the ToastBar component: ๐Ÿ‘‰ **Do not put any important actions** like "Undo" in the ToastBar component (unless there are other means to perform said action), as it is very hard (if not impossible) to reach for users with assistive technologies. Read more about -[Toast accessibility](#scott-o-hara-toast) at Scott O'Hara's blog. +[Toast accessibility][scott-o-hara-toast] at Scott O'Hara's blog. ### Colors @@ -198,15 +218,22 @@ For example: ```html
-
-
Success message
+
+
+
Success message
+
``` -### Opening the ToastBar +### Basic Interactions + +For basic use cases, you can simply place the ToastBar component inside the Toast container and show/hide it using our +JavaScript plugin. + +#### Showing the Static ToastBar -Use our JavaScript plugin to open a Toast **that is present in the DOM,** e.g.: +Use our JavaScript plugin to show a Toast **that is present in the DOM,** e.g.: ```html ``` -๐Ÿ‘‰ Advanced toast queue control is not yet implemented. - -### Dismissible ToastBar +#### Dismissible ToastBar To make the ToastBar dismissible, add the `ToastBar--dismissible` modifier class, a unique `id` attribute, and a close button: ```html
-
-
Dismissible message
+
+
+
Dismissible message
+
+
-
``` ๐Ÿ‘‰ Please keep in mind that the Button color should match the ToastBar color. -## Full Example +#### Full Example ```html + +
-
-
- -
- Toast message - Action +
@@ -287,14 +327,72 @@ button: ``` +### Creating Dynamic ToastBars + +To create ToastBar components dynamically, make sure you have the `data-spirit-element="toast-queue"` attribute set on +the `.Toast__queue` element, with just the ToastBar template inside the [`