From 71bc844c66dc57630a2a87734e4157901c9485b1 Mon Sep 17 00:00:00 2001 From: Adam Kudrna Date: Tue, 5 Mar 2024 17:30:42 +0100 Subject: [PATCH 1/2] Feat(web): Introduce `Toast` JavaScript plugin #DS-1115 --- packages/web/src/js/Collapse.ts | 34 +++--- packages/web/src/js/Toast.ts | 103 ++++++++++++++++++ .../web/src/js/__tests__/Collapse.test.ts | 12 +- packages/web/src/js/constants.ts | 17 +-- packages/web/src/js/index.esm.ts | 1 + packages/web/src/js/index.umd.ts | 2 + .../web/src/scss/components/Toast/README.md | 72 +++++++++++- .../src/scss/components/Toast/_ToastBar.scss | 18 +++ .../web/src/scss/components/Toast/_theme.scss | 3 + .../web/src/scss/components/Toast/index.html | 43 +++++++- 10 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 packages/web/src/js/Toast.ts 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..d04dc92990 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 Toast instance associated with a ToastBar DOM element. | +| `getOrCreateInstance` | _Static_ method which allows you to get the Toast instance associated with a ToastBar 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 @@

Alignment

+ +
+ Show the toast prepared in the DOM: + +
+ +
-
+

Alignment

type="button" class="Button Button--small Button--square Button--success" data-spirit-dismiss="toast" + data-spirit-target="#my-dismissible-toast" aria-controls="my-dismissible-toast" aria-expanded="true" > @@ -113,6 +129,7 @@

Alignment

type="button" class="Button Button--small Button--square Button--informative" data-spirit-dismiss="toast" + data-spirit-target="#my-other-dismissible-toast" aria-controls="my-other-dismissible-toast" aria-expanded="true" > @@ -123,6 +140,30 @@

Alignment

+ +
From 728a783adf566b94dd7b3a17fdcbf5817cc3eb69 Mon Sep 17 00:00:00 2001 From: Pavel Klibani Date: Wed, 6 Mar 2024 19:34:06 +0100 Subject: [PATCH 2/2] Test(web): Add tests for Toast component --- packages/web/src/js/__tests__/Toast.test.ts | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/web/src/js/__tests__/Toast.test.ts diff --git a/packages/web/src/js/__tests__/Toast.test.ts b/packages/web/src/js/__tests__/Toast.test.ts new file mode 100644 index 0000000000..7e8db41d5f --- /dev/null +++ b/packages/web/src/js/__tests__/Toast.test.ts @@ -0,0 +1,112 @@ +import { clearFixture, getFixture } from '../../../tests/helpers/fixture'; +import Toast from '../Toast'; +import { + ATTRIBUTE_ARIA_EXPANDED, + ATTRIBUTE_DATA_TARGET, + CLASS_NAME_HIDDEN, + CLASS_NAME_OPEN, + CLASS_NAME_TRANSITIONING, +} from '../constants'; +import EventHandler from '../dom/EventHandler'; + +const testId = 'toast-test'; + +const toastHtmlClosed = ` + +`; + +const toastHtmlOpened = ` +
+ +
+`; + +describe('Toast', () => { + let fixtureEl: Element; + + beforeAll(() => { + fixtureEl = getFixture(); + }); + + afterEach(() => { + clearFixture(); + }); + + describe('NAME', () => { + it('should return plugin name', () => { + expect(Toast.NAME).toEqual(expect.any(String)); + }); + }); + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Toast.DATA_KEY).toBe('toast'); + }); + }); + + describe('EVENT_KEY', () => { + it('should return plugin event key', () => { + expect(Toast.EVENT_KEY).toBe('.toast'); + }); + }); + + describe('constructor', () => { + it('should construct a toast', () => { + fixtureEl.innerHTML = toastHtmlClosed; + const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; + const toast = new Toast(element); + + expect(toast.element).toEqual(element); + }); + }); + + describe('show', () => { + it('should show a toast', async () => { + fixtureEl.innerHTML = toastHtmlClosed; + + const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; + const toast = new Toast(element); + const trigger = fixtureEl.querySelector(`[${ATTRIBUTE_DATA_TARGET}="#${testId}"]`) as HTMLButtonElement; + + const showSpy = jest.spyOn(Toast.prototype, 'show'); + + await toast.show(); + + expect(showSpy).toHaveBeenCalled(); + expect(trigger).toHaveAttribute(ATTRIBUTE_ARIA_EXPANDED, 'true'); + expect(element).toHaveClass(CLASS_NAME_OPEN); + expect(element).toHaveClass(CLASS_NAME_TRANSITIONING); + + EventHandler.trigger(element, 'transitionend'); + + expect(element).toHaveClass(CLASS_NAME_OPEN); + expect(element).not.toHaveClass(CLASS_NAME_HIDDEN); + expect(element).not.toHaveClass(CLASS_NAME_TRANSITIONING); + }); + }); + + describe('hide', () => { + it('should hide a toast', async () => { + fixtureEl.innerHTML = toastHtmlOpened; + + const element = fixtureEl.querySelector('.ToastBar') as HTMLElement; + const toast = new Toast(element); + const trigger = fixtureEl.querySelector(`[${ATTRIBUTE_DATA_TARGET}="#${testId}"]`) as HTMLButtonElement; + + const hideSpy = jest.spyOn(Toast.prototype, 'hide'); + + await toast.hide(); + + expect(hideSpy).toHaveBeenCalled(); + expect(trigger).toHaveAttribute(ATTRIBUTE_ARIA_EXPANDED, 'false'); + expect(element).toHaveClass(CLASS_NAME_HIDDEN); + expect(element).toHaveClass(CLASS_NAME_TRANSITIONING); + + EventHandler.trigger(element, 'transitionend'); + + expect(fixtureEl.querySelector('.ToastBar')).toBeNull(); + }); + }); +});