From be98561ac04159e866c1b52ee1b84d633b2aa23f Mon Sep 17 00:00:00 2001 From: Pavel Klibani Date: Wed, 3 Apr 2024 12:04:45 +0200 Subject: [PATCH] Feat(web-react): Introduce Toast component - solve #DS-1113 --- .storybook/assets/stylesheets/index.scss | 1 + packages/web-react/scripts/entryPoints.js | 1 + .../web-react/src/components/Toast/README.md | 275 ++++++++++++++++++ .../web-react/src/components/Toast/Toast.tsx | 19 ++ .../src/components/Toast/ToastBar.tsx | 76 +++++ .../components/Toast/__tests__/Toast.test.tsx | 46 +++ .../Toast/__tests__/ToastBar.test.tsx | 55 ++++ .../__tests__/useToastBarStyleProps.test.ts | 31 ++ .../Toast/__tests__/useToastIcon.test.tsx | 26 ++ .../__tests__/useToastStyleProps.test.ts | 25 ++ .../src/components/Toast/constants.ts | 14 + .../components/Toast/demo/ToastAlignment.tsx | 152 ++++++++++ .../src/components/Toast/demo/ToastColors.tsx | 42 +++ .../Toast/demo/ToastContentVariations.tsx | 40 +++ .../src/components/Toast/demo/index.tsx | 28 ++ .../web-react/src/components/Toast/index.html | 1 + .../web-react/src/components/Toast/index.ts | 4 + .../Toast/stories/Toast.stories.tsx | 65 +++++ .../Toast/stories/ToastBar.stories.tsx | 81 ++++++ .../components/Toast/useToastBarStyleProps.ts | 23 ++ .../src/components/Toast/useToastIcon.tsx | 19 ++ .../components/Toast/useToastStyleProps.ts | 40 +++ packages/web-react/src/components/index.ts | 1 + packages/web-react/src/hooks/useIconName.ts | 4 +- packages/web-react/src/types/index.ts | 1 + packages/web-react/src/types/toast.ts | 37 +++ 26 files changed, 1105 insertions(+), 2 deletions(-) create mode 100644 packages/web-react/src/components/Toast/README.md create mode 100644 packages/web-react/src/components/Toast/Toast.tsx create mode 100644 packages/web-react/src/components/Toast/ToastBar.tsx create mode 100644 packages/web-react/src/components/Toast/__tests__/Toast.test.tsx create mode 100644 packages/web-react/src/components/Toast/__tests__/ToastBar.test.tsx create mode 100644 packages/web-react/src/components/Toast/__tests__/useToastBarStyleProps.test.ts create mode 100644 packages/web-react/src/components/Toast/__tests__/useToastIcon.test.tsx create mode 100644 packages/web-react/src/components/Toast/__tests__/useToastStyleProps.test.ts create mode 100644 packages/web-react/src/components/Toast/constants.ts create mode 100644 packages/web-react/src/components/Toast/demo/ToastAlignment.tsx create mode 100644 packages/web-react/src/components/Toast/demo/ToastColors.tsx create mode 100644 packages/web-react/src/components/Toast/demo/ToastContentVariations.tsx create mode 100644 packages/web-react/src/components/Toast/demo/index.tsx create mode 100644 packages/web-react/src/components/Toast/index.html create mode 100644 packages/web-react/src/components/Toast/index.ts create mode 100644 packages/web-react/src/components/Toast/stories/Toast.stories.tsx create mode 100644 packages/web-react/src/components/Toast/stories/ToastBar.stories.tsx create mode 100644 packages/web-react/src/components/Toast/useToastBarStyleProps.ts create mode 100644 packages/web-react/src/components/Toast/useToastIcon.tsx create mode 100644 packages/web-react/src/components/Toast/useToastStyleProps.ts create mode 100644 packages/web-react/src/types/toast.ts diff --git a/.storybook/assets/stylesheets/index.scss b/.storybook/assets/stylesheets/index.scss index ab05a73c7d..e61788599f 100644 --- a/.storybook/assets/stylesheets/index.scss +++ b/.storybook/assets/stylesheets/index.scss @@ -27,6 +27,7 @@ @forward '../../../packages/web/src/scss/components/Tag'; @forward '../../../packages/web/src/scss/components/TextArea'; @forward '../../../packages/web/src/scss/components/TextField'; +@forward '../../../packages/web/src/scss/components/Toast'; @forward '../../../packages/web/src/scss/components/Tooltip'; @forward '../../../packages/web/src/scss/utilities'; diff --git a/packages/web-react/scripts/entryPoints.js b/packages/web-react/scripts/entryPoints.js index d20664fa27..099c3b6fcc 100644 --- a/packages/web-react/scripts/entryPoints.js +++ b/packages/web-react/scripts/entryPoints.js @@ -36,6 +36,7 @@ const entryPoints = [ { dirs: ['components', 'TextArea'] }, { dirs: ['components', 'TextField'] }, { dirs: ['components', 'TextFieldBase'] }, + { dirs: ['components', 'Toast'] }, { dirs: ['components', 'Tooltip'] }, { dirs: ['components', 'VisuallyHidden'] }, ]; diff --git a/packages/web-react/src/components/Toast/README.md b/packages/web-react/src/components/Toast/README.md new file mode 100644 index 0000000000..3966bd82c7 --- /dev/null +++ b/packages/web-react/src/components/Toast/README.md @@ -0,0 +1,275 @@ +# Toast + +This is the React implementation of the [Toast][web-toast] component. + +Toast displays a brief, temporary notification that appears at a prescribed location of an application window. + +Toast is a composition of a few subcomponents: + +- [Toast](#toast) + - [ToastBar](#toastbar) + +## Toast + +The Toast component is a container responsible for positioning the [ToastBar](#toastbar) component. It is capable of +handling even multiple toast messages at once, stacking them in a [queue](#toast-queue). + +```jsx +import { Toast } from '@lmc-eu/spirit-web-react/components'; +``` + +```jsx + + + +``` + +### Accessibility + +The wrapping Toast container has the [`role="log"`][mdn-role-log] attribute set (which results in an implicit +[`aria-live`][mdn-aria-live] value of `polite`). Assistive technologies then announce any **dynamic changes** inside the +container as they happen. In order for this to work, the Toast component **must be present in the DOM** on the initial +page load, even when empty. + +### Alignment + +The Toast component is positioned at the bottom of the screen by default. It is also fixed to the bottom of the screen, +so it will always be visible, even when the user scrolls. Available alignment options are derived from the +[AlignmentX and AlignmentY][dictionary-alignment] dictionaries and are as follows: + +- `top` `left`, +- `top` `center`, +- `top` `right`, +- `bottom` `left`, +- `bottom` `center` (default), +- `bottom` `right`. + +Use the `alignmentX` and `alignmentY` options to change the alignment of the Toast component. + +ℹī¸ The `center` vertical alignment is not supported, as it would not make sense for a toast notification to be in the +middle of the screen. + +Example: + +```jsx + + + +``` + +### Responsive Alignment + +Pass an object to props to set different values for different breakpoints. The values will be applied from mobile to +desktop and if not set for a breakpoint, the value from the previous breakpoint will be used. + +Example: + +```jsx + + + +``` + +### Mobile Screens + +Positioning becomes trickier on mobile screens due to the presence of notches, rounded corners, and the virtual +keyboard. The Toast component tries to find the best position to be visible using the following detection mechanisms: + +1. On **devices with rounded displays and/or notches** (e.g. iPhone X and newer), the Toast component is pushed inwards + to avoid the rounded corners. The `viewport-fit="cover"` meta tag is required for this feature to work: + + ```html + + ``` + +2. **Android Chrome only:** When the vertical alignment is set to `bottom` and the virtual keyboard is open, the Toast + component is pushed upwards to avoid being covered by the keyboard. This feature requires the following JavaScript + snippet and is currently supported only in Chrome 94 on Android and later. + + ```js + // Enable CSS to detect the presence of virtual keyboard: + if ('virtualKeyboard' in navigator) { + navigator.virtualKeyboard.overlaysContent = true; + } + ``` + +### Toast Queue + +When multiple ToastBar components are present, they stack up in a queue, separated by a gap. The ToastBar components are +sorted from top to bottom for the `top` vertical alignment, and from bottom to top for the `bottom` vertical alignment. + +👉 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: + +⚠ī¸ **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.** + +⚠ī¸ 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. + +### 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 | + +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] +and [escape hatches][readme-escape-hatches]. + +## ToastBar + +The ToastBar component is the actual toast notification. It is a simple container with a message and a few optional +elements. + +Minimum example: + +```jsx +import { ToastBar } from '@lmc-eu/spirit-web-react/components'; +``` + +```jsx +Message only +``` + +### Optional Icon + +An icon can be displayed in the ToastBar component, depending on the color of the ToastBar: + +```jsx + + Message with icon + +``` + +Alternatively, a custom icon can be used: + +```jsx + + Message with custom icon + +``` + +#### Default Icons According to Color Variant + +| Color name | Icon name | +| ------------- | ------------- | +| `danger` | `danger` | +| `informative` | `info` | +| `inverted` | `info` | +| `success` | `check-plain` | +| `warning` | `warning` | + +### Action Link + +An action link can be added to the ToastBar component: + +```jsx + + Message with action + + Action + + +``` + +👉 For the sake of flexibility, developers can pass the link as part of the message. However, it is strongly recommended +to use the **inverted underlined** variant of the link (for all ToastBar colors) to make it stand out from the message. + +👉 **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. + +### Colors + +The ToastBar component is available in all [emotion colors][dictionary-color], plus the `inverted` variant (default). +Use the `color` option to change the color of the ToastBar component. + +For example: + +```jsx + + Success message + +``` + +### Opening the ToastBar + +Set `isOpen` prop to `true` to open a Toast **that is present in the DOM,** e.g.: + +```jsx + + Opened ToastBar + +``` + +👉 Advanced toast queue control is not yet implemented. + +### Dismissible ToastBar + +To make the ToastBar dismissible, add the `isDismissible` prop along with a `onClose` function: + +```jsx + {}} isDismissible> + Dismissible message + +``` + +### API + +| Name | Type | Default | Required | Description | +| --------------- | ------------------------------------------------------------ | ---------- | -------- | -------------------------------------------------------------------- | +| `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 | +| `isOpen` | `bool` | `true` | ✕ | If true, ToastBar is visible | +| `onClose` | `function` | — | ✕ | Close button callback | + +(\*) For each emotion color, a default icon is defined. +The icons come from the [Icon package][icon-package], or from your custom source of icons. +Read the section [Default Icons according to Color Variant](#default-icons-according-to-color-variant). + +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] +and [escape hatches][readme-escape-hatches]. + +## Full Example + +```jsx +import { Button, Toast, ToastBar } from '@lmc-eu/spirit-web-react/components'; + +const [isOpen, setIsOpen] = React.useState(false) + + + + + setIsOpen(false)} isDismissible> + Toast message + Action + + +``` + +[web-toast]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Toast +[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 +[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 diff --git a/packages/web-react/src/components/Toast/Toast.tsx b/packages/web-react/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000..9a5b22a71a --- /dev/null +++ b/packages/web-react/src/components/Toast/Toast.tsx @@ -0,0 +1,19 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritToastProps } from '../../types'; +import { useToastStyleProps } from './useToastStyleProps'; + +const Toast = (props: SpiritToastProps) => { + const { children, alignmentX = 'center', alignmentY = 'bottom', ...restProps } = props; + const { classProps, props: modifiedProps } = useToastStyleProps({ ...restProps, alignmentX, alignmentY }); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + return ( +
+
{children}
+
+ ); +}; + +export default Toast; diff --git a/packages/web-react/src/components/Toast/ToastBar.tsx b/packages/web-react/src/components/Toast/ToastBar.tsx new file mode 100644 index 0000000000..5d3bf6c12e --- /dev/null +++ b/packages/web-react/src/components/Toast/ToastBar.tsx @@ -0,0 +1,76 @@ +import classNames from 'classnames'; +import React, { MutableRefObject, useRef } from 'react'; +import { Transition, TransitionStatus } from 'react-transition-group'; +import { useStyleProps } from '../../hooks'; +import { SpiritToastBarProps } from '../../types'; +import { Button } from '../Button'; +import { Icon } from '../Icon'; +import { VisuallyHidden } from '../VisuallyHidden'; +import { + DEFAULT_TOAST_COLOR, + ICON_BOX_SIZE, + TOAST_BAR_CLOSE_BUTTON_LABEL_DEFAULT, + TRANSITIONING_STYLES, + TRANSITION_DURATION, +} from './constants'; +import { useToastBarStyleProps } from './useToastBarStyleProps'; +import { useToastIcon } from './useToastIcon'; + +const ToastBar = (props: SpiritToastBarProps) => { + const { + id, + children, + closeLabel = TOAST_BAR_CLOSE_BUTTON_LABEL_DEFAULT, + color = DEFAULT_TOAST_COLOR, + hasIcon, + iconName, + isDismissible, + isOpen = true, + onClose = () => {}, + ...restProps + } = props; + const rootElementRef: MutableRefObject = useRef(null); + const toastIconName = useToastIcon({ color, iconName }); + const { classProps, props: modifiedProps } = useToastBarStyleProps({ + ...restProps, + color, + isDismissible, + id, + }); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + return ( + + {(transitionState: TransitionStatus) => ( +
+
+ {(hasIcon || iconName) && } +
{children}
+
+ + {isDismissible && onClose && ( + + )} +
+ )} +
+ ); +}; + +export default ToastBar; diff --git a/packages/web-react/src/components/Toast/__tests__/Toast.test.tsx b/packages/web-react/src/components/Toast/__tests__/Toast.test.tsx new file mode 100644 index 0000000000..67e906b792 --- /dev/null +++ b/packages/web-react/src/components/Toast/__tests__/Toast.test.tsx @@ -0,0 +1,46 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import Toast from '../Toast'; + +describe('Toast', () => { + classNamePrefixProviderTest(Toast, 'Toast'); + + stylePropsTest(Toast); + + restPropsTest(Toast, 'div'); + + it('should render with default alignments', () => { + const dom = render(); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).toHaveClass('Toast Toast--center Toast--bottom'); + }); + + it('should render with custom alignments', () => { + const dom = render(); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).toHaveClass('Toast Toast--left Toast--top'); + }); + + it('should render with responsive alignments', () => { + const dom = render( + , + ); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).toHaveClass( + 'Toast Toast--desktop--left Toast--tablet--center Toast--right Toast--desktop--top Toast--tablet--bottom Toast--top', + ); + }); +}); diff --git a/packages/web-react/src/components/Toast/__tests__/ToastBar.test.tsx b/packages/web-react/src/components/Toast/__tests__/ToastBar.test.tsx new file mode 100644 index 0000000000..1a0ecd71dd --- /dev/null +++ b/packages/web-react/src/components/Toast/__tests__/ToastBar.test.tsx @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import ToastBar from '../ToastBar'; + +describe('ToastBar', () => { + classNamePrefixProviderTest((props) => , 'ToastBar'); + + stylePropsTest((props) => , 'toastbar-test'); + + restPropsTest((props) => , 'div'); + + it('should not render', () => { + const dom = render(); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).not.toBeInTheDocument(); + }); + + it('should render', () => { + const dom = render(); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).toBeInTheDocument(); + }); + + it('should render text children', () => { + const dom = render(Hello World); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element.textContent).toBe('Hello World'); + }); + + it('should render icon and have danger class', () => { + const dom = render( + + Hello World + , + ); + + const element = dom.container.querySelector('div') as HTMLElement; + + expect(element).toHaveClass('ToastBar--danger ToastBar--dismissible'); + + const icon = dom.container.querySelector('svg') as SVGSVGElement; + + expect(icon).toBeInTheDocument(); + }); +}); diff --git a/packages/web-react/src/components/Toast/__tests__/useToastBarStyleProps.test.ts b/packages/web-react/src/components/Toast/__tests__/useToastBarStyleProps.test.ts new file mode 100644 index 0000000000..7b330d2ecc --- /dev/null +++ b/packages/web-react/src/components/Toast/__tests__/useToastBarStyleProps.test.ts @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { SpiritToastBarProps } from '../../../types'; +import { useToastBarStyleProps } from '../useToastBarStyleProps'; + +describe('useToastBarStyleProps', () => { + it('should return default classes', () => { + const props = { isOpen: true } as SpiritToastBarProps; + const { result } = renderHook(() => useToastBarStyleProps(props)); + + expect(result.current.classProps.root).toBe('ToastBar ToastBar--inverted'); + expect(result.current.classProps.content).toBe('ToastBar__content'); + expect(result.current.classProps.message).toBe('ToastBar__message'); + }); + + it('should return dismissible class', () => { + const props = { isDismissible: true } as SpiritToastBarProps; + const { result } = renderHook(() => useToastBarStyleProps(props)); + + expect(result.current.classProps.root).toContain('ToastBar--dismissible'); + }); + + it.each([['inverted'], ['informative'], ['success'], ['warning'], ['danger']])( + 'should return color class %s', + (color) => { + const props = { color } as SpiritToastBarProps; + const { result } = renderHook(() => useToastBarStyleProps(props)); + + expect(result.current.classProps.root).toContain(`ToastBar--${color}`); + }, + ); +}); diff --git a/packages/web-react/src/components/Toast/__tests__/useToastIcon.test.tsx b/packages/web-react/src/components/Toast/__tests__/useToastIcon.test.tsx new file mode 100644 index 0000000000..87bb5d39d4 --- /dev/null +++ b/packages/web-react/src/components/Toast/__tests__/useToastIcon.test.tsx @@ -0,0 +1,26 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { SpiritToastBarProps } from '../../../types'; +import { useToastIcon } from '../useToastIcon'; + +describe('useToastIcon', () => { + it('should return defaults', () => { + const props = {}; + const { result } = renderHook(() => useToastIcon(props)); + + expect(result.current).toBe('info'); + }); + + it.each([ + // color, expected icon name + ['danger', 'danger'], + ['informative', 'info'], + ['inverted', 'info'], + ['success', 'check-plain'], + ['warning', 'warning'], + ])('danger alert should return warning icon', (color, iconName) => { + const props = { color } as Partial; + const { result } = renderHook(() => useToastIcon(props)); + + expect(result.current).toBe(iconName); + }); +}); diff --git a/packages/web-react/src/components/Toast/__tests__/useToastStyleProps.test.ts b/packages/web-react/src/components/Toast/__tests__/useToastStyleProps.test.ts new file mode 100644 index 0000000000..d374ba1fc6 --- /dev/null +++ b/packages/web-react/src/components/Toast/__tests__/useToastStyleProps.test.ts @@ -0,0 +1,25 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { SpiritToastProps } from '../../../types'; +import { useToastStyleProps } from '../useToastStyleProps'; + +describe('useToastStyleProps', () => { + it('should return alignment classes', () => { + const props = { alignmentX: 'center', alignmentY: 'bottom' } as SpiritToastProps; + const { result } = renderHook(() => useToastStyleProps(props)); + + expect(result.current.classProps.root).toBe('Toast Toast--center Toast--bottom'); + expect(result.current.classProps.queue).toBe('Toast__queue'); + }); + + it('should return responsive alignment classes', () => { + const props = { + alignmentX: { desktop: 'left', tablet: 'center', mobile: 'right' }, + alignmentY: { desktop: 'top', tablet: 'bottom', mobile: 'top' }, + } as SpiritToastProps; + const { result } = renderHook(() => useToastStyleProps(props)); + + expect(result.current.classProps.root).toBe( + 'Toast Toast--desktop--left Toast--tablet--center Toast--right Toast--desktop--top Toast--tablet--bottom Toast--top', + ); + }); +}); diff --git a/packages/web-react/src/components/Toast/constants.ts b/packages/web-react/src/components/Toast/constants.ts new file mode 100644 index 0000000000..f3f6461300 --- /dev/null +++ b/packages/web-react/src/components/Toast/constants.ts @@ -0,0 +1,14 @@ +export const TRANSITION_DURATION = 250; + +export const TRANSITIONING_STYLES: Record = { + entering: 'is-open is-transitioning', + entered: 'is-open', + exiting: 'is-hidden is-transitioning', + exited: 'is-hidden', +}; + +export const ICON_BOX_SIZE = 20; + +export const TOAST_BAR_CLOSE_BUTTON_LABEL_DEFAULT = 'Close'; + +export const DEFAULT_TOAST_COLOR = 'inverted'; diff --git a/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx b/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx new file mode 100644 index 0000000000..0a0b26f29e --- /dev/null +++ b/packages/web-react/src/components/Toast/demo/ToastAlignment.tsx @@ -0,0 +1,152 @@ +import React, { ChangeEvent, useState } from 'react'; +import { AlignmentXDictionaryType, AlignmentYDictionaryType } from '../../../types'; +import { Button } from '../../Button'; +import { Link } from '../../Link'; +import { Radio } from '../../Radio'; +import { TextField } from '../../TextField'; +import Toast from '../Toast'; +import ToastBar from '../ToastBar'; + +const ToastAlignment = () => { + const [isOpenFirst, setIsOpenFirst] = React.useState(true); + const [isOpenSecond, setIsOpenSecond] = React.useState(true); + const [isOpenThird, setIsOpenThird] = React.useState(false); + const [alignmentY, setAlignmentY] = useState('bottom'); + const [alignmentX, setAlignmentX] = useState('center'); + + const buttonLabel = isOpenThird ? 'Hide the showed toast' : 'Show the hidden toast'; + + const handleAlignmentYChange = (e: ChangeEvent) => { + setAlignmentY(e.target.value as AlignmentYDictionaryType); + }; + + const handleAlignmentXChange = (e: ChangeEvent) => { + setAlignmentX(e.target.value as AlignmentXDictionaryType); + }; + + // Experimental, Chrome 94+ on Android only: + // Enable CSS to detect the presence of virtual keyboard. + if ('virtualKeyboard' in navigator) { + // @ts-expect-error 'navigator.virtualKeyboard' is of type 'unknown'. + navigator.virtualKeyboard.overlaysContent = true; + } + + return ( + <> +
+
+ Vertical alignment: + {' '} + +
+ +
+ Horizontal alignment: + {' '} + {' '} + +
+
+ +
+
+ Virtual keyboard interaction: + +
+
+ +
+ Show the toast prepared in the DOM: + +
+ + + setIsOpenFirst(false)} + color="success" + hasIcon + isDismissible + > + I was first! + + Action + + + setIsOpenSecond(false)} + color="informative" + hasIcon + isDismissible + > + I appeared later. This toast has a long message that wraps automatically. + + Action + + + setIsOpenThird(false)} + color="warning" + hasIcon + isDismissible + > + I was hidden and you exposed me! + + + + ); +}; + +export default ToastAlignment; diff --git a/packages/web-react/src/components/Toast/demo/ToastColors.tsx b/packages/web-react/src/components/Toast/demo/ToastColors.tsx new file mode 100644 index 0000000000..3c3b4d8ecb --- /dev/null +++ b/packages/web-react/src/components/Toast/demo/ToastColors.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Link } from '../../Link'; +import ToastBar from '../ToastBar'; + +const ToastColors = () => { + return ( + <> + {}} color="inverted" hasIcon isDismissible> + Inverted + + Action + + + {}} color="informative" hasIcon isDismissible> + Informative + + Action + + + {}} color="success" hasIcon isDismissible> + Success + + Action + + + {}} color="warning" hasIcon isDismissible> + Warning + + Action + + + {}} color="danger" hasIcon isDismissible> + Danger + + Action + + + + ); +}; + +export default ToastColors; diff --git a/packages/web-react/src/components/Toast/demo/ToastContentVariations.tsx b/packages/web-react/src/components/Toast/demo/ToastContentVariations.tsx new file mode 100644 index 0000000000..0c6515b4d5 --- /dev/null +++ b/packages/web-react/src/components/Toast/demo/ToastContentVariations.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Link } from '../../Link'; +import ToastBar from '../ToastBar'; + +const ToastContentVariations = () => { + return ( + <> + Message only + + Message with action + + Action + + + + When the text is long and reaches the maximum width limit, the action automatically wraps to the next line. + + Action + + + + Message with icon and action + + Action + + + {}} isDismissible> + Dismissible message + + + Dismissible message with custom icon and action + + Action + + + + ); +}; + +export default ToastContentVariations; diff --git a/packages/web-react/src/components/Toast/demo/index.tsx b/packages/web-react/src/components/Toast/demo/index.tsx new file mode 100644 index 0000000000..435115a136 --- /dev/null +++ b/packages/web-react/src/components/Toast/demo/index.tsx @@ -0,0 +1,28 @@ +// Because there is no `dist` directory during the CI run +/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment, import/extensions, import/no-unresolved +// @ts-ignore: No declaration file +import icons from '@lmc-eu/spirit-icons/dist/icons'; +import DocsSection from '../../../../docs/DocsSections'; +import { IconsProvider } from '../../../context'; +import ToastAlignment from './ToastAlignment'; +import ToastColors from './ToastColors'; +import ToastContentVariations from './ToastContentVariations'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/Toast/index.html b/packages/web-react/src/components/Toast/index.html new file mode 100644 index 0000000000..fde704df38 --- /dev/null +++ b/packages/web-react/src/components/Toast/index.html @@ -0,0 +1 @@ +{{> web-react-demo}} diff --git a/packages/web-react/src/components/Toast/index.ts b/packages/web-react/src/components/Toast/index.ts new file mode 100644 index 0000000000..3601153b68 --- /dev/null +++ b/packages/web-react/src/components/Toast/index.ts @@ -0,0 +1,4 @@ +export { default as Toast } from './Toast'; +export { default as ToastBar } from './ToastBar'; +export * from './Toast'; +export * from './ToastBar'; diff --git a/packages/web-react/src/components/Toast/stories/Toast.stories.tsx b/packages/web-react/src/components/Toast/stories/Toast.stories.tsx new file mode 100644 index 0000000000..ca1a8d637e --- /dev/null +++ b/packages/web-react/src/components/Toast/stories/Toast.stories.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { SpiritToastProps } from '../../../types'; +import { Button } from '../../Button'; +import ReadMe from '../README.md'; +import { Toast, ToastBar } from '..'; + +const meta: Meta = { + title: 'Components/Toast', + component: Toast, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + }, + alignmentX: { + control: 'select', + options: ['left', 'center', 'right'], + table: { + defaultValue: { summary: 'center' }, + }, + }, + alignmentY: { + control: 'select', + options: ['top', 'bottom'], + table: { + defaultValue: { summary: 'bottom' }, + }, + }, + }, + args: { + alignmentX: 'center', + alignmentY: 'bottom', + }, +}; + +export default meta; +type Story = StoryObj; + +const ToastWithHooks = (args: SpiritToastProps) => { + const [isOpen, setIsOpen] = useState(true); + const buttonLabel = isOpen ? 'Close toast' : 'Open toast'; + + return ( + <> + + + setIsOpen(false)}> + This is a toast message + + + + ); +}; + +export const ToastPlayground: Story = { + name: 'Toast', + render: (args) => , +}; diff --git a/packages/web-react/src/components/Toast/stories/ToastBar.stories.tsx b/packages/web-react/src/components/Toast/stories/ToastBar.stories.tsx new file mode 100644 index 0000000000..35eeb86ffb --- /dev/null +++ b/packages/web-react/src/components/Toast/stories/ToastBar.stories.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { SpiritToastBarProps } from '../../../types'; +import ReadMe from '../README.md'; +import { Toast, ToastBar } from '..'; + +const meta: Meta = { + title: 'Components/Toast', + component: ToastBar, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'text', + }, + closeLabel: { + control: 'text', + }, + color: { + control: 'select', + options: ['inverted', 'informative', 'success', 'warning', 'danger'], + table: { + defaultValue: { summary: 'inverted' }, + }, + }, + hasIcon: { + control: 'boolean', + table: { + defaultValue: { summary: false }, + }, + }, + iconName: { + control: 'text', + }, + isDismissible: { + control: 'boolean', + table: { + defaultValue: { summary: false }, + }, + }, + isOpen: { + control: 'boolean', + table: { + defaultValue: { summary: true }, + }, + }, + onClose: { + action: 'onClose', + }, + }, + args: { + id: 'toast-bar', + children: 'ToastBar Message', + closeLabel: 'Close', + color: 'inverted', + hasIcon: false, + iconName: '', + isDismissible: false, + isOpen: true, + onClose: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +const ToastBarComponent = (args: SpiritToastBarProps) => ( + + + +); + +export const ToastBarPlayground: Story = { + name: 'ToastBar', + render: ToastBarComponent, +}; diff --git a/packages/web-react/src/components/Toast/useToastBarStyleProps.ts b/packages/web-react/src/components/Toast/useToastBarStyleProps.ts new file mode 100644 index 0000000000..a74222babe --- /dev/null +++ b/packages/web-react/src/components/Toast/useToastBarStyleProps.ts @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import { useClassNamePrefix } from '../../hooks'; +import { SpiritToastBarProps } from '../../types'; + +export const useToastBarStyleProps = (props: Omit) => { + const { color, isDismissible, ...restProps } = props; + + const toastBarClass = useClassNamePrefix('ToastBar'); + const toastBarContentClass = `${toastBarClass}__content`; + const toastBarMessageClass = `${toastBarClass}__message`; + const colorClass = `${toastBarClass}--${color || 'inverted'}`; + const dismissibleClass = `${toastBarClass}--dismissible`; + const rootClass = classNames(toastBarClass, colorClass, isDismissible && dismissibleClass); + + return { + classProps: { + root: rootClass, + content: toastBarContentClass, + message: toastBarMessageClass, + }, + props: restProps, + }; +}; diff --git a/packages/web-react/src/components/Toast/useToastIcon.tsx b/packages/web-react/src/components/Toast/useToastIcon.tsx new file mode 100644 index 0000000000..161b9166d4 --- /dev/null +++ b/packages/web-react/src/components/Toast/useToastIcon.tsx @@ -0,0 +1,19 @@ +import { useIconName } from '../../hooks/useIconName'; +import { SpiritToastBarProps } from '../../types'; +import { DEFAULT_TOAST_COLOR } from './constants'; + +export function useToastIcon({ color, iconName }: Partial) { + const iconNameValue = useIconName( + color as string, + { + danger: 'danger', + informative: 'info', + inverted: 'info', + success: 'check-plain', + warning: 'warning', + }, + DEFAULT_TOAST_COLOR, + ); + + return iconName || iconNameValue; +} diff --git a/packages/web-react/src/components/Toast/useToastStyleProps.ts b/packages/web-react/src/components/Toast/useToastStyleProps.ts new file mode 100644 index 0000000000..1a40df3a02 --- /dev/null +++ b/packages/web-react/src/components/Toast/useToastStyleProps.ts @@ -0,0 +1,40 @@ +import classNames from 'classnames'; +import { useClassNamePrefix } from '../../hooks'; +import { SpiritToastProps } from '../../types'; + +export interface ToastStyles { + classProps: { + root: string; + queue: string; + }; + props: T; +} + +export function useToastStyleProps(props: SpiritToastProps): ToastStyles { + const { alignmentX, alignmentY, ...restProps } = props; + + const toastClass = useClassNamePrefix('Toast'); + + function processAlignment(alignment: SpiritToastProps['alignmentX'] | SpiritToastProps['alignmentY']) { + return typeof alignment === 'object' && alignment !== null + ? Object.keys(alignment).reduce((acc, key) => { + const infix = key === 'mobile' ? '' : `--${key}`; + + return [...acc, `${toastClass}${infix}--${alignment[key as keyof typeof alignment]}`]; + }, []) + : [`${toastClass}--${alignment}`]; + } + + const alignmentClasses = [...processAlignment(alignmentX), ...processAlignment(alignmentY)]; + + const toastRootClass = classNames(toastClass, ...alignmentClasses); + const toastQueueClass = `${toastClass}__queue`; + + return { + classProps: { + root: toastRootClass, + queue: toastQueueClass, + }, + props: restProps, + }; +} diff --git a/packages/web-react/src/components/index.ts b/packages/web-react/src/components/index.ts index 0326b64172..82fab8492c 100644 --- a/packages/web-react/src/components/index.ts +++ b/packages/web-react/src/components/index.ts @@ -31,5 +31,6 @@ export * from './Text'; export * from './TextArea'; export * from './TextField'; export * from './TextFieldBase'; +export * from './Toast'; export * from './Tooltip'; export * from './VisuallyHidden'; diff --git a/packages/web-react/src/hooks/useIconName.ts b/packages/web-react/src/hooks/useIconName.ts index c52fafdaad..c1b980a7b0 100644 --- a/packages/web-react/src/hooks/useIconName.ts +++ b/packages/web-react/src/hooks/useIconName.ts @@ -1,3 +1,3 @@ -export function useIconName(key: string | undefined, iconMap: Record) { - return key && iconMap[key] ? iconMap[key] : iconMap.default; +export function useIconName(key: string | undefined, iconMap: Record, defaultKey: string = 'default') { + return key && iconMap[key] ? iconMap[key] : iconMap[defaultKey]; } diff --git a/packages/web-react/src/types/index.ts b/packages/web-react/src/types/index.ts index 3eb792953f..ad1205b77c 100644 --- a/packages/web-react/src/types/index.ts +++ b/packages/web-react/src/types/index.ts @@ -29,5 +29,6 @@ export * from './text'; export * from './textArea'; export * from './textField'; export * from './textFieldBase'; +export * from './toast'; export * from './tooltip'; export * from './visuallyHidden'; diff --git a/packages/web-react/src/types/toast.ts b/packages/web-react/src/types/toast.ts new file mode 100644 index 0000000000..04a62b03f9 --- /dev/null +++ b/packages/web-react/src/types/toast.ts @@ -0,0 +1,37 @@ +import { + AlignmentXDictionaryType, + AlignmentYDictionaryType, + ChildrenProps, + EmotionColorsDictionaryType, + StyleProps, +} from './shared'; + +export interface BaseToastProps extends ChildrenProps, StyleProps {} + +export interface SpiritToastProps extends BaseToastProps { + alignmentX?: AlignmentXDictionaryType | { [key: string]: AlignmentXDictionaryType }; + alignmentY?: Omit | { [key: string]: Omit }; +} + +export interface BaseToastBarProps extends ChildrenProps, StyleProps { + id: string; +} + +export interface ToastBarHandlingProps { + isDismissible?: boolean; + isOpen?: boolean; + onClose?: () => void; +} + +export interface ToastBarProps extends BaseToastBarProps, ToastBarHandlingProps {} + +export interface TransitionToastBarProps { + transitionDuration?: number; +} + +export interface SpiritToastBarProps extends ToastBarProps, TransitionToastBarProps { + closeLabel?: string; + color?: EmotionColorsDictionaryType | 'inverted'; + hasIcon?: boolean; + iconName?: string; +}