Skip to content

Commit

Permalink
Feat(web): Introduce stacking of the Toast queue
Browse files Browse the repository at this point in the history
  • Loading branch information
adamkudrna committed Apr 16, 2024
1 parent 0ca4ef1 commit 86ed8a5
Show file tree
Hide file tree
Showing 22 changed files with 1,063 additions and 437 deletions.
15 changes: 15 additions & 0 deletions packages/web/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your-local-path>/spirit-design-system/packages/web`
Expand Down
217 changes: 198 additions & 19 deletions packages/web/src/js/Toast.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand All @@ -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[];

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit 86ed8a5

Please sign in to comment.