diff --git a/packages/web/src/js/Collapse.ts b/packages/web/src/js/Collapse.ts index dc27395e36..cfd543dd00 100644 --- a/packages/web/src/js/Collapse.ts +++ b/packages/web/src/js/Collapse.ts @@ -1,11 +1,11 @@ import BaseComponent from './BaseComponent'; import { - ARIA_CONTROLS_ATTRIBUTE, - ARIA_EXPANDED_ATTRIBUTE, - CLASSNAME_OPEN, - CLASSNAME_TRANSITION, - NAME_DATA_TARGET, - NAME_DATA_TOGGLE, + ATTRIBUTE_ARIA_CONTROLS, + ATTRIBUTE_ARIA_EXPANDED, + ATTRIBUTE_DATA_TARGET, + ATTRIBUTE_DATA_TOGGLE, + CLASS_NAME_OPEN, + CLASS_NAME_TRANSITIONING, } from './constants'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; @@ -48,8 +48,8 @@ class Collapse extends BaseComponent { }; this.state = { open: - this.element.hasAttribute(ARIA_EXPANDED_ATTRIBUTE) && - this.element.getAttribute(ARIA_EXPANDED_ATTRIBUTE) === 'true', + this.element.hasAttribute(ATTRIBUTE_ARIA_EXPANDED) && + this.element.getAttribute(ATTRIBUTE_ARIA_EXPANDED) === 'true', init: false, }; @@ -74,7 +74,7 @@ class Collapse extends BaseComponent { } for (const item of children) { - const itemTrigger = SelectorEngine.findOne(`[${NAME_DATA_TOGGLE}="${NAME}"]`, item); + const itemTrigger = SelectorEngine.findOne(`[${ATTRIBUTE_DATA_TOGGLE}="${NAME}"]`, item); const instance = Collapse.getInstance(itemTrigger); if (instance?.state?.open && itemTrigger !== trigger) { @@ -113,10 +113,10 @@ class Collapse extends BaseComponent { } updateTriggerElement(open: boolean = this.state.open) { - const triggers = SelectorEngine.findAll(`[${NAME_DATA_TARGET}="${this.meta.id}"]`); + const triggers = SelectorEngine.findAll(`[${ATTRIBUTE_DATA_TARGET}="${this.meta.id}"]`); const updateElement = (element: Element | HTMLElement) => { - element.setAttribute(ARIA_CONTROLS_ATTRIBUTE, this.meta.id); - element.setAttribute(ARIA_EXPANDED_ATTRIBUTE, String(open)); + element.setAttribute(ATTRIBUTE_ARIA_CONTROLS, this.meta.id); + element.setAttribute(ATTRIBUTE_ARIA_EXPANDED, String(open)); if (this.meta.hideOnCollapse && open) { element.remove(); this.appendNodeToParent(); @@ -140,11 +140,11 @@ class Collapse extends BaseComponent { } this.adjustCollapsibleElementHeight(open); if (this.state.init) { - this.target?.classList.add(CLASSNAME_TRANSITION); + this.target?.classList.add(CLASS_NAME_TRANSITIONING); } executeAfterTransition(this.target, () => { - this.target?.classList.remove(CLASSNAME_TRANSITION); - this.target?.classList.toggle(CLASSNAME_OPEN, open); + this.target?.classList.remove(CLASS_NAME_TRANSITIONING); + this.target?.classList.toggle(CLASS_NAME_OPEN, open); if (open) { this.target?.setAttribute('style', 'height: 100%'); } else { @@ -188,7 +188,7 @@ class Collapse extends BaseComponent { } initEvents() { - const triggers = SelectorEngine.findAll(`[${NAME_DATA_TARGET}="${this.meta.id}"]`); + const triggers = SelectorEngine.findAll(`[${ATTRIBUTE_DATA_TARGET}="${this.meta.id}"]`); if (triggers.length === 1) { EventHandler.on(this.element, 'click', () => this.toggle()); } else { @@ -197,7 +197,7 @@ class Collapse extends BaseComponent { } destroyEvents() { - const triggers = SelectorEngine.findAll(`[${NAME_DATA_TARGET}="${this.meta.id}"]`); + const triggers = SelectorEngine.findAll(`[${ATTRIBUTE_DATA_TARGET}="${this.meta.id}"]`); if (triggers.length === 1) { EventHandler.off(this.element, 'click', () => this.toggle()); } else { diff --git a/packages/web/src/js/Toast.ts b/packages/web/src/js/Toast.ts new file mode 100644 index 0000000000..10758bc052 --- /dev/null +++ b/packages/web/src/js/Toast.ts @@ -0,0 +1,103 @@ +import BaseComponent from './BaseComponent'; +import { + ATTRIBUTE_ARIA_EXPANDED, + ATTRIBUTE_DATA_TARGET, + CLASS_NAME_HIDDEN, + CLASS_NAME_OPEN, + CLASS_NAME_TRANSITIONING, +} from './constants'; +import { enableDismissTrigger, enableToggleTrigger, executeAfterTransition, SpiritConfig } from './utils'; +import { EventHandler, SelectorEngine } from './dom'; + +const NAME = 'toast'; +const DATA_KEY = `${NAME}`; +const EVENT_KEY = `.${DATA_KEY}`; + +const EVENT_HIDE = `hide${EVENT_KEY}`; +const EVENT_HIDDEN = `hidden${EVENT_KEY}`; +const EVENT_SHOW = `show${EVENT_KEY}`; +const EVENT_SHOWN = `shown${EVENT_KEY}`; + +class Toast extends BaseComponent { + isShown: boolean; + triggers: HTMLElement[]; + + static get NAME() { + return NAME; + } + + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); + + this.triggers = this.getTriggers(); + + this.isShown = this.checkShownState(); + } + + checkShownState() { + return this.element.classList.contains(CLASS_NAME_OPEN) || !this.element.classList.contains(CLASS_NAME_HIDDEN); + } + + getTriggers() { + const id = this.element && (this.element.getAttribute('id') as string); + + return SelectorEngine.findAll(`[${ATTRIBUTE_DATA_TARGET}="#${id}"]`); + } + + show() { + if (this.isShown) { + return; + } + + const showEvent = EventHandler.trigger(this.element, Toast.eventName(EVENT_SHOW)) as Event; + if (showEvent.defaultPrevented) { + return; + } + + 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); + + executeAfterTransition(this.element, () => { + EventHandler.trigger(this.element, Toast.eventName(EVENT_SHOWN)); + this.element.classList.remove(CLASS_NAME_TRANSITIONING); + }); + + this.isShown = true; + } + + hide() { + if (!this.isShown) { + return; + } + + const hideEvent = EventHandler.trigger(this.element, Toast.eventName(EVENT_HIDE)) as Event; + if (hideEvent.defaultPrevented) { + return; + } + + this.triggers.forEach((element) => { + element?.setAttribute(ATTRIBUTE_ARIA_EXPANDED, 'false'); + }); + + this.element.classList.remove(CLASS_NAME_OPEN); + 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(); + }); + + this.isShown = false; + } +} + +enableToggleTrigger(Toast, 'show', 'target'); +enableDismissTrigger(Toast, 'hide', 'target'); + +export default Toast; diff --git a/packages/web/src/js/__tests__/Collapse.test.ts b/packages/web/src/js/__tests__/Collapse.test.ts index f883d48c05..5898bbfc46 100644 --- a/packages/web/src/js/__tests__/Collapse.test.ts +++ b/packages/web/src/js/__tests__/Collapse.test.ts @@ -1,7 +1,7 @@ import { clearFixture, getFixture } from '../../../tests/helpers/fixture'; import EventHandler from '../dom/EventHandler'; import Collapse from '../Collapse'; -import { CLASSNAME_OPEN, CLASSNAME_TRANSITION } from '../constants'; +import { CLASS_NAME_OPEN, CLASS_NAME_TRANSITIONING } from '../constants'; describe('Collapse', () => { let fixtureEl: Element; @@ -75,10 +75,10 @@ describe('Collapse', () => { await collapse.show(); expect(element.getAttribute('aria-expanded')).toBe('true'); - expect(target).toHaveClass(CLASSNAME_TRANSITION); + expect(target).toHaveClass(CLASS_NAME_TRANSITIONING); EventHandler.trigger(target, 'transitionend'); - expect(target).toHaveClass(CLASSNAME_OPEN); + expect(target).toHaveClass(CLASS_NAME_OPEN); }); }); @@ -197,16 +197,16 @@ describe('Collapse', () => { const collapse0 = new Collapse(element0); expect(target0).toHaveClass('Collapse'); - expect(target1).toHaveClass(CLASSNAME_OPEN); + expect(target1).toHaveClass(CLASS_NAME_OPEN); await collapse0.show(); - expect(target0).toHaveClass(CLASSNAME_TRANSITION); + expect(target0).toHaveClass(CLASS_NAME_TRANSITIONING); EventHandler.trigger(target0, 'transitionend'); EventHandler.trigger(target1, 'transitionend'); - expect(target0).toHaveClass(CLASSNAME_OPEN); + expect(target0).toHaveClass(CLASS_NAME_OPEN); expect(target1).toHaveClass('Collapse'); }); }); diff --git a/packages/web/src/js/constants.ts b/packages/web/src/js/constants.ts index 2c898832dc..9585600392 100644 --- a/packages/web/src/js/constants.ts +++ b/packages/web/src/js/constants.ts @@ -1,9 +1,10 @@ -export const ARIA_EXPANDED_ATTRIBUTE = 'aria-expanded'; -export const ARIA_CONTROLS_ATTRIBUTE = 'aria-controls'; +export const ATTRIBUTE_ARIA_EXPANDED = 'aria-expanded'; +export const ATTRIBUTE_ARIA_CONTROLS = 'aria-controls'; +export const ATTRIBUTE_DATA_DISMISS = 'data-spirit-dismiss'; +export const ATTRIBUTE_DATA_TARGET = 'data-spirit-target'; +export const ATTRIBUTE_DATA_TOGGLE = 'data-spirit-toggle'; -export const NAME_DATA_TOGGLE = 'data-spirit-toggle'; -export const NAME_DATA_TARGET = 'data-spirit-target'; - -export const CLASSNAME_EXPANDED = 'is-expanded'; -export const CLASSNAME_OPEN = 'is-open'; -export const CLASSNAME_TRANSITION = 'is-transitioning'; +export const CLASS_NAME_HIDDEN = 'is-hidden'; +export const CLASS_NAME_OPEN = 'is-open'; +export const CLASS_NAME_TRANSITIONING = 'is-transitioning'; +export const CLASS_NAME_VISIBLE = 'is-visible'; diff --git a/packages/web/src/js/index.esm.ts b/packages/web/src/js/index.esm.ts index e82b7ced8f..b34a58ef17 100644 --- a/packages/web/src/js/index.esm.ts +++ b/packages/web/src/js/index.esm.ts @@ -8,6 +8,7 @@ export { default as Offcanvas } from './Offcanvas'; export { default as Password } from './Password'; export { default as ScrollView } from './ScrollView'; export { default as Tabs } from './Tabs'; +export { default as Toast } from './Toast'; export { default as Tooltip } from './Tooltip'; export * from './constants'; export * from './dom'; diff --git a/packages/web/src/js/index.umd.ts b/packages/web/src/js/index.umd.ts index d7b52c9a07..db6a024105 100644 --- a/packages/web/src/js/index.umd.ts +++ b/packages/web/src/js/index.umd.ts @@ -8,6 +8,7 @@ import Offcanvas from './Offcanvas'; import Password from './Password'; import ScrollView from './ScrollView'; import Tabs from './Tabs'; +import Toast from './Toast'; import Tooltip from './Tooltip'; import * as constants from './constants'; import * as dom from './dom'; @@ -24,6 +25,7 @@ export default { Password, ScrollView, Tabs, + Toast, Tooltip, constants, dom, diff --git a/packages/web/src/scss/components/Toast/README.md b/packages/web/src/scss/components/Toast/README.md index 5d4ce9aaa8..7a46760025 100644 --- a/packages/web/src/scss/components/Toast/README.md +++ b/packages/web/src/scss/components/Toast/README.md @@ -7,6 +7,20 @@ Toast is a composition of a few subcomponents: - [Toast](#toast) - [ToastBar](#toastbar) +## JavaScript Plugin + +For full functionality, you need to provide Spirit JavaScript, which will handle toggling of the Toast component: + +```html + +``` + +You will find the [full documentation](#javascript-plugin-api) of the plugin below on this page. + +Please consult the [main README][web-readme] for how to include JavaScript plugins. + +Or, feel free to write the controlling script yourself. + ## Toast The Toast component is a container responsible for positioning the [ToastBar](#toastbar) component. It is capable of @@ -187,6 +201,25 @@ For example: ``` +### Opening the ToastBar + +Use our JavaScript plugin to open a Toast **that is present in the DOM,** e.g.: + +```html + +``` + +👉 Advanced toast queue control is not yet implemented. + ### Dismissible ToastBar To make the ToastBar dismissible, add the `ToastBar--dismissible` modifier class, a unique `id` attribute, and a close @@ -201,6 +234,7 @@ button: type="button" class="Button Button--small Button--square Button--inverted" data-spirit-dismiss="toast" + data-spirit-target="#my-dismissible-toast" aria-controls="my-dismissible-toast" aria-expanded="true" > @@ -214,8 +248,6 @@ button: 👉 Please keep in mind that the Button color should match the ToastBar color. -⚠️ The JavaScript functionality for dismissing the ToastBar is yet to be implemented. - ## Full Example ```html @@ -252,6 +284,42 @@ button: ``` +## JavaScript Plugin API + +| Method | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getInstance` | _Static_ method which allows you to get the FileUploader instance associated with a DOM element. | +| `getOrCreateInstance` | _Static_ method which allows you to get the FileUploader instance associated with a DOM element, or create a new one in case it wasn’t initialized. | +| `hide` | Hides the toast element. Returns to the caller before the toast has actually been hidden (i.e. before the `hidden.toast` event occurs). | +| `show` | Reveals the toast element. **Returns to the caller before the toast has actually been shown** (i.e. before the `shown.toast` event occurs). | + +```js +const toast = Toast.getInstance('#example'); // Returns a toast instance + +toast.show(); +``` + +### JavaScript Events + +| Method | Description | +| -------------- | ------------------------------------------------------------------------------------- | +| `hidden.toast` | This event is fired when the `hide` instance has finished being hidden from the user. | +| `hide.toast` | This event is fired immediately when the `hide` instance method has been called. | +| `show.toast` | This event fires immediately when the `show` instance method is called. | +| `shown.toast` | This event is fired when the `show` instance has finished being shown to the user. | + +```js +const myToastEl = document.getElementById('myToast'); +const toast = Toast.getOrCreateInstance(myToastEl); + +myToastEl.addEventListener('hidden.toast', () => { + // Do something… +}); + +toast.hide(); +``` + +[web-readme]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md [mdn-role-log]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/log_role [mdn-aria-live]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live [dictionary-alignment]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#alignment diff --git a/packages/web/src/scss/components/Toast/_ToastBar.scss b/packages/web/src/scss/components/Toast/_ToastBar.scss index a2e3e6d698..44e06e695d 100644 --- a/packages/web/src/scss/components/Toast/_ToastBar.scss +++ b/packages/web/src/scss/components/Toast/_ToastBar.scss @@ -48,6 +48,24 @@ margin-inline-end: theme.$bar-action-margin-inline-end; // 4. } +.ToastBar.is-transitioning { + @media (prefers-reduced-motion: no-preference) { + transition-property: visibility, opacity; + transition-duration: theme.$bar-transition-duration; + transition-timing-function: theme.$bar-transition-timing; + } +} + +.ToastBar.is-hidden { + visibility: hidden; + opacity: 0; +} + +.ToastBar.is-visible { + visibility: visible; + opacity: 1; +} + @include dictionaries.generate-colors( $class-name: 'ToastBar', $dictionary-values: theme.$color-dictionary, diff --git a/packages/web/src/scss/components/Toast/_theme.scss b/packages/web/src/scss/components/Toast/_theme.scss index 40d144b636..5a6c69900e 100644 --- a/packages/web/src/scss/components/Toast/_theme.scss +++ b/packages/web/src/scss/components/Toast/_theme.scss @@ -1,6 +1,7 @@ @use 'sass:list'; @use '@tokens' as tokens; @use '../../settings/dictionaries'; +@use '../../settings/transitions'; $alignments-x: ( left: start, @@ -29,6 +30,8 @@ $bar-content-gap: tokens.$space-500; $bar-message-gap-x: tokens.$space-700; $bar-message-gap-y: tokens.$space-500; $bar-action-margin-inline-end: tokens.$space-400; +$bar-transition-duration: transitions.$duration-medium; +$bar-transition-timing: transitions.$timing-eased-out; $color-dictionary: list.join('inverted', dictionaries.$emotion-colors); $color-dictionary-config: ( diff --git a/packages/web/src/scss/components/Toast/index.html b/packages/web/src/scss/components/Toast/index.html index af5a460014..7d1db95c26 100644 --- a/packages/web/src/scss/components/Toast/index.html +++ b/packages/web/src/scss/components/Toast/index.html @@ -72,10 +72,25 @@