Skip to content

Commit

Permalink
Feat(web-twig): Make Toast work with dynamic collapsible queue and dy…
Browse files Browse the repository at this point in the history
…namic ToastBars #DS-1223
  • Loading branch information
crishpeen committed May 2, 2024
1 parent 7348776 commit ac409c5
Show file tree
Hide file tree
Showing 12 changed files with 381 additions and 115 deletions.
22 changes: 22 additions & 0 deletions apps/web-twig-demo/assets/scripts/toast-dynamic.ts
@@ -0,0 +1,22 @@
import { Toast } from '@lmc-eu/spirit-web/src/js/index.esm';

const addDynamicToast = (event, containerId) => {
const formElement = event.target.closest('form');
const config = {
color: formElement.querySelector('#toast-color').value,
containerId,
content: formElement.querySelector('#toast-content').value,
hasIcon: formElement.querySelector('#toast-has-icon').checked,
id: `my-dynamic-toast-${Date.now()}`,
isDismissible: formElement.querySelector('#toast-is-dismissible').checked,
};

const toast = new Toast(null, config);
toast.show();
console.log('Created dynamic toast with config:', config);
};

// Make it available in the global scope
window.addDynamicToast = addDynamicToast;

export default addDynamicToast;
1 change: 1 addition & 0 deletions apps/web-twig-demo/webpack.config.js
Expand Up @@ -25,6 +25,7 @@ Encore
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('app', './assets/app.ts')
.addEntry('toastDynamic', './assets/scripts/toast-dynamic.ts')
.addEntry('fileUploaderImagePreview', './assets/scripts/file-uploader-image-preview.ts')
.addEntry('fileUploaderMetaData', './assets/scripts/file-uploader-meta-data.ts')
.addEntry('formValidations', './assets/scripts/form-validations.ts')
Expand Down
132 changes: 108 additions & 24 deletions packages/web-twig/src/Resources/components/Toast/README.md
Expand Up @@ -101,27 +101,41 @@ 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
#### Collapsing

The collapsible Toast queue is turned on by default and can 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.

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 collapses when there are more than 3 ToastBars. To turn off this behavior and make the queue scrollable when it does not fit the screen,
set the `isCollapsible` prop to `false`.

⚠️ 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.

```html
<Toast isCollapsible="{" false }>
<!-- ToastBar components go here -->
</Toast>
```

#### 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.

### API

| Name | Type | Default | Required | Description |
| ------------ | ----------------------------------------------------------- | -------- | -------- | --------------------------------------- |
| `alignmentX` | [[AlignmentX dictionary][dictionary-alignment] \| `object`] | `center` || Horizontal alignment of the toast queue |
| `alignmentY` | [`top` \| `bottom` \| `object`] | `bottom` || Vertical alignment of the toast queue |
| Name | Type | Default | Required | Description |
| --------------- | ----------------------------------------------------------- | -------- | -------- | ----------------------------------------------------------------- |
| `alignmentX` | [[AlignmentX dictionary][dictionary-alignment] \| `object`] | `center` || Horizontal alignment of the toast queue |
| `alignmentY` | [`top` \| `bottom` \| `object`] | `bottom` || Vertical alignment of the toast queue |
| `isCollapsible` | `bool` | `true` || If true, Toast queue collapses if there are more than 3 ToastBars |

On top of the API options, the components accept [additional attributes][readme-additional-attributes].
If you need more control over the styling of a component, you can use [style props][readme-style-props]
Expand Down Expand Up @@ -184,7 +198,7 @@ to use the **inverted underlined** variant of the link (for all ToastBar colors)

👉 **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

Expand All @@ -199,17 +213,22 @@ For example:
</ToastBar>
```

### Opening the ToastBar
### Basic Interactions

Use our JavaScript plugin to open a Toast **that is present in the DOM,** e.g.:
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 show a Toast **that is present in the DOM,** e.g.:

```twig
<Button
data-spirit-toggle="toast"
data-spirit-target="#my-hidden-toast"
aria-expanded="false"
>
Show the hidden toast
Show hidden toast
</Button>
```

Expand All @@ -229,12 +248,13 @@ To make the ToastBar dismissible, add the `isDismissible` prop along with a uniq

| Name | Type | Default | Required | Description |
| --------------- | ------------------------------------------------------------ | ---------- | -------- | -------------------------------------------------------------------- |
| `color` | [[Emotion Color dictionary][dictionary-color] \| `inverted`] | `inverted` || Color variant |
| `closeLabel` | `string` | `Close` || Close label |
| `color` | [[Emotion Color dictionary][dictionary-color] \| `inverted`] | `inverted` || Color variant |
| `hasIcon` | `bool` | `false` \* || If true, an icon is shown along the message |
| `iconName` | `string` | `info` \* || Name of a custom icon to be shown along the message |
| `id` | `string` ||| Optional ToastBar ID. Required when `isDismissible` is set to `true` |
| `isDismissible` | `bool` | `false` || If true, ToastBar can be dismissed by user |
| `isTemplate` | `bool` | `false` || If true, ToastBar will be adjusted for rendering inside `<template>` |
| `isOpen` | `bool` | `true` || If true, ToastBar is visible |

(\*) For each emotion color, a default icon is defined.
Expand All @@ -256,6 +276,68 @@ and [escape hatches][readme-escape-hatches].
</Toast>
```

### Creating Dynamic ToastBars

To create ToastBar components dynamically, make sure to add the ToastBar template inside the [`<template>`][mdn-template] tag.
The `<template>` tag must be inserted anywhere inside the Toast container. Our [JavaScript Toast plugin][web-toast-js-plugin] will then pick up
the template and apply it on any toasts to be shown to the user, using the configuration provided.
The template `ToastBar` has to have the `isTemplate` prop set.

⚠️ In order to make the dynamic ToastBar icons work, you need to include the SVG sprites in your project. You
can use the `Icon` component with `isSymbol` prop. Otherwise, the icons will not be displayed as the JS plugin
does not render the icons by itself, it just sets the `use` tag with the correct `xlink:href` attribute.
Also, do not forget to set the `hidden` attribute on the wrapping element to hide the icons from the screen.

```twig
<div hidden>
<Icon name="check-plain" isSymbol />
<Icon name="danger" isSymbol />
<Icon name="info" isSymbol />
<Icon name="warning" isSymbol />
</div>
<Toast id="toast-example">
<template data-spirit-snippet="item">
<ToastBar isTemplate />
</template>
</Toast>
```

Or preconfigure the template with some default values:

```twig
<div hidden>
<Icon name="check-plain" isSymbol />
<Icon name="danger" isSymbol />
<Icon name="info" isSymbol />
<Icon name="warning" isSymbol />
</div>
<Toast id="toast-example">
<template data-spirit-snippet="item">
<ToastBar isTemplate color="success" hasIcon isDismissible />
</template>
</Toast>
```

Then configure and create a new Toast instance and call the `show` method on it, for example:

```js
import Toast from '@lmc-eu/spirit-web/dist/js/Toast';

const toast = new Toast(null, {
color: 'informative', // One of ['inverted' (default), 'success', 'warning, 'danger', 'informative']
containerId: 'toast-example', // Must match the ID of the Toast container in HTML
content: 'Hello, this is my toast message!', // Can be plain text or HTML
hasIcon: true,
// iconName: 'info', // Optional icon name used as the #fragment in the SVG sprite URL
id: 'my-toast', // An ID is required for dismissible ToastBar
isDismissible: true,
});

toast.show();
```

## JavaScript Plugin

For full functionality you need to provide JavaScript which will handle the toggling of the Toast component.
Expand All @@ -270,15 +352,17 @@ Or feel free to write controlling scripts yourself.

👉 Check the [component's docs in the web package][web-js-api] to see the full documentation and API of the plugin.

[web-toast]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Toast
[web-readme]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md
[web-js-api]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/src/scss/components/Toast/README.md#javascript-api
[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
[dictionary-color]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#color
[icon-package]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/icons
[mdn-aria-live]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live
[mdn-role-log]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/log_role
[mdn-template]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template
[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-twig/README.md#additional-attributes
[readme-escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-twig/README.md#escape-hatches
[readme-style-props]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-twig/README.md#style-props
[scott-o-hara-toast]: https://www.scottohara.me/blog/2019/07/08/a-toast-to-a11y-toasts.html
[icon-package]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/icons
[web-js-api]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/src/scss/components/Toast/README.md#javascript-api
[web-readme]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md
[web-toast-js-plugin]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Toast#javascript-plugin-api
[web-toast]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Toast
Expand Up @@ -2,8 +2,12 @@

{% block content %}

<DocsSection title="Alignment">
{% include '@components/Toast/stories/ToastAlignment.twig' %}
<DocsSection title="Static Toast">
{% include '@components/Toast/stories/ToastStaticToast.twig' %}
</DocsSection>

<DocsSection title="Dynamic Toast Queue">
{% include '@components/Toast/stories/ToastDynamicToastQueue.twig' %}
</DocsSection>

<DocsSection title="Content Variations">
Expand Down
10 changes: 6 additions & 4 deletions packages/web-twig/src/Resources/components/Toast/Toast.twig
@@ -1,10 +1,12 @@
{# API #}
{%- set props = props | default([]) -%}
{%- set _alignmentX = props.alignmentX | default('center') %}
{%- set _alignmentY = props.alignmentY | default('bottom') %}
{%- set _alignmentX = props.alignmentX | default('center') -%}
{%- set _alignmentY = props.alignmentY | default('bottom') -%}
{%- set _isCollapsible = props.isCollapsible ?? true -%}

{# Class names #}
{%- set _rootClassName = _spiritClassPrefix ~ 'Toast' -%}
{%- set _rootCollapsibleClassName = _isCollapsible ? _spiritClassPrefix ~ 'Toast--collapsible' : null -%}
{%- set _queueClassName = _spiritClassPrefix ~ 'Toast__queue' -%}

{# Miscellaneous #}
Expand All @@ -22,15 +24,15 @@
{%- endif -%}
{%- endfor -%}

{%- set _classNames = [ _rootClassName, _styleProps.className ] | merge(_alignmentClasses) -%}
{%- set _classNames = [ _rootClassName, _rootCollapsibleClassName, _styleProps.className ] | merge(_alignmentClasses) -%}

<div
{{ mainProps(props) }}
{{ styleProp(_styleProps) }}
{{ classProp(_classNames) }}
role="log"
>
<div class="Toast__queue">
<div class="Toast__queue" data-spirit-element="toast-queue">
{% block content %}{% endblock %}
</div>
</div>
58 changes: 35 additions & 23 deletions packages/web-twig/src/Resources/components/Toast/ToastBar.twig
Expand Up @@ -6,15 +6,17 @@
{%- set _iconName = props.iconName | default(null) -%}
{%- set _id = props.id | default(null) -%}
{%- set _isDismissible = props.isDismissible | default(false) -%}
{%- set _isTemplate = props.isTemplate | default(false) -%}
{%- set _isOpen = props.isOpen ?? true -%}

{# Class names #}
{%- set _rootClassName = _spiritClassPrefix ~ 'ToastBar' -%}
{%- set _rootColorClassName = _spiritClassPrefix ~ 'ToastBar--' ~ _color -%}
{%- set _rootDismissibleClassName = _isDismissible is same as(true) ? _spiritClassPrefix ~ 'ToastBar--dismissible' : null -%}
{%- set _rootDismissibleClassName = _isDismissible is same as(true) and _isTemplate is same as(false) ? _spiritClassPrefix ~ 'ToastBar--dismissible' : null -%}
{%- set _boxClassName = _spiritClassPrefix ~ 'ToastBar__box' -%}
{%- set _contentClassName = _spiritClassPrefix ~ 'ToastBar__content' -%}
{%- set _messageClassName = _spiritClassPrefix ~ 'ToastBar__message' -%}
{%- set _isOpenClassName = _isOpen ? 'is-open' : 'is-hidden' -%}
{%- set _isOpenClassName = _isOpen or _isTemplate ? 'is-open' : 'is-hidden' -%}

{# Attributes #}
{%- set _idAttr = _id ? 'id="' ~ _id | escape('html_attr') ~ '"' : null -%}
Expand All @@ -31,34 +33,44 @@
{%- set _iconNameByColor = 'danger' -%}
{% endif %}
{%- set _iconNameValue = _iconName | default(_iconNameByColor) -%}
{%- set _mainPropsWithoutReservedAttributes = props | filter((value, prop) => prop is not same as('id')) -%}
{%- set _mainPropsWithoutReservedAttributes = props | filter((value, prop) => prop not in ['id', 'data-spirit-populate-field']) -%}

<div
{{ mainProps(_mainPropsWithoutReservedAttributes) }}
{{ styleProp(_styleProps) }}
{{ classProp(_classNames) }}
{{ _idAttr | raw }}
data-spirit-populate-field="item"
>
<div class="{{ _contentClassName }}">
{% if _hasIcon or _iconName %}
<Icon boxSize="20" name="{{ _iconNameValue }}" />
{% endif %}
<div class="{{ _messageClassName }}">
{% block content %}{% endblock %}
<div class="{{ _boxClassName }}">
<div class="{{ _contentClassName }}">
{% if _hasIcon or _iconName %}
{% if _isTemplate %}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-spirit-populate-field="icon">
<use xlink:href="#" />
</svg>
{% else %}
<Icon boxSize="20" name="{{ _iconNameValue }}" isReusable={ false } />
{% endif %}
{% endif %}
<div class="{{ _messageClassName }}" data-spirit-populate-field="message">
{% block content %}{% endblock %}
</div>
</div>
{% if _isDismissible is same as(true) %}
<Button
color="{{ _color }}"
data-spirit-dismiss="toast"
data-spirit-populate-field="close-button"
data-spirit-target="{{ '#' ~ _id | escape('html_attr') }}"
aria-controls="{{ _id | escape('html_attr') }}"
aria-expanded="true"
size="small"
isSquare
>
<Icon isReusable={ false } name="close" />
<VisuallyHidden>{{ _closeLabel }}</VisuallyHidden>
</Button>
{% endif %}
</div>
{% if _isDismissible is same as(true) %}
<Button
color="{{ _color }}"
data-spirit-dismiss="toast"
data-spirit-target="{{ '#' ~ _id | escape('html_attr') }}"
aria-controls="{{ _id | escape('html_attr') }}"
aria-expanded="true"
size="small"
isSquare
>
<Icon isReusable="{{ false }}" name="close" />
<VisuallyHidden>{{ _closeLabel }}</VisuallyHidden>
</Button>
{% endif %}
</div>
Expand Up @@ -20,6 +20,7 @@
<Toast
alignmentX="{{ { mobile: 'center', tablet: 'left', desktop: 'right' } }}"
alignmentY="{{ { tablet: 'top' } }}"
isCollapsible={ false }
UNSAFE_className="custom-class"
UNSAFE_style="outline: 5px solid blue; outline-offset: -5px;"
>
Expand Down

0 comments on commit ac409c5

Please sign in to comment.