From 4b290d102d910e75df0d85c54c8bf886d8b99c5a Mon Sep 17 00:00:00 2001 From: Vahid Nesro <63849626+Vahid1919@users.noreply.github.com> Date: Fri, 17 Nov 2023 15:49:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20sd-notification=20(#5?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/_components/alert/alert.stories.ts | 26 -- .../src/_components/alert/alert.styles.ts | 97 ----- .../src/_components/alert/alert.test.ts | 91 ----- .../components/src/_components/alert/alert.ts | 245 ------------ .../_components/divider/divider.stories.ts | 26 -- .../src/_components/divider/divider.styles.ts | 25 -- .../src/_components/divider/divider.test.ts | 30 -- .../src/_components/divider/divider.ts | 40 -- .../components/carousel/carousel.stories.ts | 6 +- .../src/components/icon/library.system.ts | 8 +- .../notification/notification.stories.ts | 350 +++++++++++++++++ .../notification/notification.test.ts | 351 ++++++++++++++++++ .../components/notification/notification.ts | 326 ++++++++++++++++ packages/components/src/internal/test.ts | 23 +- packages/components/src/solid-styles.css | 3 + .../src/{themes => styles}/_utility.css | 30 +- packages/components/tailwind.config.cjs | 12 + 17 files changed, 1091 insertions(+), 598 deletions(-) delete mode 100644 packages/components/src/_components/alert/alert.stories.ts delete mode 100644 packages/components/src/_components/alert/alert.styles.ts delete mode 100644 packages/components/src/_components/alert/alert.test.ts delete mode 100644 packages/components/src/_components/alert/alert.ts delete mode 100644 packages/components/src/_components/divider/divider.stories.ts delete mode 100644 packages/components/src/_components/divider/divider.styles.ts delete mode 100644 packages/components/src/_components/divider/divider.test.ts delete mode 100644 packages/components/src/_components/divider/divider.ts create mode 100644 packages/components/src/components/notification/notification.stories.ts create mode 100644 packages/components/src/components/notification/notification.test.ts create mode 100644 packages/components/src/components/notification/notification.ts rename packages/components/src/{themes => styles}/_utility.css (54%) diff --git a/packages/components/src/_components/alert/alert.stories.ts b/packages/components/src/_components/alert/alert.stories.ts deleted file mode 100644 index 42d782189..000000000 --- a/packages/components/src/_components/alert/alert.stories.ts +++ /dev/null @@ -1,26 +0,0 @@ -import '../../solid-components'; -import { storybookDefaults, storybookTemplate } from '../../../scripts/storybook/helper'; -import { withActions } from '@storybook/addon-actions/decorator'; - -const { argTypes, args, parameters } = storybookDefaults('sd-alert'); -const { generateTemplate } = storybookTemplate('sd-alert'); - -export default { - title: 'Components/sd-alert', - component: 'sd-alert', - args, - argTypes, - parameters: {...parameters}, - decorators: [withActions] as any -}; - - -/** - * Default: This shows sd-alert in its default state. - */ - -export const Default = { - render: (args: any) => { - return generateTemplate({ args }); - } -}; diff --git a/packages/components/src/_components/alert/alert.styles.ts b/packages/components/src/_components/alert/alert.styles.ts deleted file mode 100644 index 819f6857a..000000000 --- a/packages/components/src/_components/alert/alert.styles.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { css } from 'lit'; -import componentStyles from '../../styles/component.styles'; - -export default css` - ${componentStyles} - - :host { - display: contents; - - /* For better DX, we'll reset the margin here so the base part can inherit it */ - margin: 0; - } - - .alert { - position: relative; - display: flex; - align-items: stretch; - background-color: var(--sd-panel-background-color); - border: solid var(--sd-panel-border-width) var(--sd-panel-border-color); - border-top-width: calc(var(--sd-panel-border-width) * 3); - border-radius: var(--sd-border-radius-medium); - font-family: var(--sd-font-sans); - font-size: var(--sd-font-size-small); - font-weight: var(--sd-font-weight-normal); - line-height: 1.6; - color: var(--sd-color-neutral-700); - margin: inherit; - } - - .alert:not(.alert--has-icon) .alert__icon, - .alert:not(.alert--closable) .alert__close-button { - display: none; - } - - .alert__icon { - flex: 0 0 auto; - display: flex; - align-items: center; - font-size: var(--sd-font-size-large); - padding-inline-start: var(--sd-spacing-large); - } - - .alert--primary { - border-top-color: var(--sd-color-primary-600); - } - - .alert--primary .alert__icon { - color: var(--sd-color-primary-600); - } - - .alert--success { - border-top-color: var(--sd-color-success-600); - } - - .alert--success .alert__icon { - color: var(--sd-color-success-600); - } - - .alert--neutral { - border-top-color: var(--sd-color-neutral-600); - } - - .alert--neutral .alert__icon { - color: var(--sd-color-neutral-600); - } - - .alert--warning { - border-top-color: var(--sd-color-warning-600); - } - - .alert--warning .alert__icon { - color: var(--sd-color-warning-600); - } - - .alert--danger { - border-top-color: var(--sd-color-danger-600); - } - - .alert--danger .alert__icon { - color: var(--sd-color-danger-600); - } - - .alert__message { - flex: 1 1 auto; - display: block; - padding: var(--sd-spacing-large); - overflow: hidden; - } - - .alert__close-button { - flex: 0 0 auto; - display: flex; - align-items: center; - font-size: var(--sd-font-size-medium); - padding-inline-end: var(--sd-spacing-medium); - } -`; diff --git a/packages/components/src/_components/alert/alert.test.ts b/packages/components/src/_components/alert/alert.test.ts deleted file mode 100644 index a0e14d004..000000000 --- a/packages/components/src/_components/alert/alert.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; -import sinon from 'sinon'; -import type SdAlert from './alert'; - -describe('', () => { - it('should be visible with the open attribute', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - - expect(base.hidden).to.be.false; - }); - - it('should not be visible without the open attribute', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - - expect(base.hidden).to.be.true; - }); - - it('should emit sd-show and sd-after-show when calling show()', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - const showHandler = sinon.spy(); - const afterShowHandler = sinon.spy(); - - el.addEventListener('sd-show', showHandler); - el.addEventListener('sd-after-show', afterShowHandler); - el.show(); - - await waitUntil(() => showHandler.calledOnce); - await waitUntil(() => afterShowHandler.calledOnce); - - expect(showHandler).to.have.been.calledOnce; - expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; - }); - - it('should emit sd-hide and sd-after-hide when calling hide()', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - const hideHandler = sinon.spy(); - const afterHideHandler = sinon.spy(); - - el.addEventListener('sd-hide', hideHandler); - el.addEventListener('sd-after-hide', afterHideHandler); - el.hide(); - - await waitUntil(() => hideHandler.calledOnce); - await waitUntil(() => afterHideHandler.calledOnce); - - expect(hideHandler).to.have.been.calledOnce; - expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; - }); - - it('should emit sd-show and sd-after-show when setting open = true', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - const showHandler = sinon.spy(); - const afterShowHandler = sinon.spy(); - - el.addEventListener('sd-show', showHandler); - el.addEventListener('sd-after-show', afterShowHandler); - el.open = true; - - await waitUntil(() => showHandler.calledOnce); - await waitUntil(() => afterShowHandler.calledOnce); - - expect(showHandler).to.have.been.calledOnce; - expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; - }); - - it('should emit sd-hide and sd-after-hide when setting open = false', async () => { - const el = await fixture(html` I am an alert `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; - const hideHandler = sinon.spy(); - const afterHideHandler = sinon.spy(); - - el.addEventListener('sd-hide', hideHandler); - el.addEventListener('sd-after-hide', afterHideHandler); - el.open = false; - - await waitUntil(() => hideHandler.calledOnce); - await waitUntil(() => afterHideHandler.calledOnce); - - expect(hideHandler).to.have.been.calledOnce; - expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; - }); -}); diff --git a/packages/components/src/_components/alert/alert.ts b/packages/components/src/_components/alert/alert.ts deleted file mode 100644 index d0693704d..000000000 --- a/packages/components/src/_components/alert/alert.ts +++ /dev/null @@ -1,245 +0,0 @@ -import '../icon-button/icon-button'; -import { animateTo, stopAnimations } from '../../internal/animate'; -import { classMap } from 'lit/directives/class-map.js'; -import { customElement } from '../../../src/internal/register-custom-element'; -import { property, query } from 'lit/decorators.js'; -import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; -import { HasSlotController } from '../../internal/slot'; -import { html } from 'lit'; -import { LocalizeController } from '../../utilities/localize'; -import { waitForEvent } from '../../internal/event'; -import { watch } from '../../internal/watch'; -import SolidElement from '../../internal/solid-element'; -import styles from './alert.styles'; -import type { CSSResultGroup } from 'lit'; - -const toastStack = Object.assign(document.createElement('div'), { className: 'sd-toast-stack' }); - -/** - * @summary Alerts are used to display important messages inline or as toast notifications. - * @documentation https://solid.union-investment.com/[storybook-link]/alert - * @status stable - * @since 1.0 - * - * @dependency sd-icon-button - * - * @slot - The alert's main content. - * @slot icon - An icon to show in the alert. Works best with ``. - * - * @event sd-show - Emitted when the alert opens. - * @event sd-after-show - Emitted after the alert opens and all animations are complete. - * @event sd-hide - Emitted when the alert closes. - * @event sd-after-hide - Emitted after the alert closes and all animations are complete. - * - * @csspart base - The component's base wrapper. - * @csspart icon - The container that wraps the optional icon. - * @csspart message - The container that wraps the alert's main content. - * @csspart close-button - The close button, an ``. - * @csspart close-button__base - The close button's exported `base` part. - * - * @animation alert.show - The animation to use when showing the alert. - * @animation alert.hide - The animation to use when hiding the alert. - */ - -@customElement('sd-alert') -export default class SdAlert extends SolidElement { - static styles: CSSResultGroup = styles; - - private autoHideTimeout: number; - private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix'); - private readonly localize = new LocalizeController(this); - - @query('[part~="base"]') base: HTMLElement; - - /** - * Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can - * use the `show()` and `hide()` methods and this attribute will reflect the alert's open state. - */ - @property({ type: Boolean, reflect: true }) open = false; - - /** Enables a close button that allows the user to dismiss the alert. */ - @property({ type: Boolean, reflect: true }) closable = false; - - /** The alert's theme variant. */ - @property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary'; - - /** - * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with - * the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning - * the alert will not close on its own. - */ - @property({ type: Number }) duration = Infinity; - - firstUpdated() { - this.base.hidden = !this.open; - } - - private restartAutoHide() { - clearTimeout(this.autoHideTimeout); - if (this.open && this.duration < Infinity) { - this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration); - } - } - - private handleCloseClick() { - this.hide(); - } - - private handleMouseMove() { - this.restartAutoHide(); - } - - @watch('open', { waitUntilFirstUpdate: true }) - async handleOpenChange() { - if (this.open) { - // Show - this.emit('sd-show'); - - if (this.duration < Infinity) { - this.restartAutoHide(); - } - - await stopAnimations(this.base); - this.base.hidden = false; - const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() }); - await animateTo(this.base, keyframes, options); - - this.emit('sd-after-show'); - } else { - // Hide - this.emit('sd-hide'); - - clearTimeout(this.autoHideTimeout); - - await stopAnimations(this.base); - const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() }); - await animateTo(this.base, keyframes, options); - this.base.hidden = true; - - this.emit('sd-after-hide'); - } - } - - @watch('duration') - handleDurationChange() { - this.restartAutoHide(); - } - - /** Shows the alert. */ - async show() { - if (this.open) { - return undefined; - } - - this.open = true; - return waitForEvent(this, 'sd-after-show'); - } - - /** Hides the alert */ - async hide() { - if (!this.open) { - return undefined; - } - - this.open = false; - return waitForEvent(this, 'sd-after-hide'); - } - - /** - * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when - * dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by - * calling this method again. The returned promise will resolve after the alert is hidden. - */ - async toast() { - return new Promise(resolve => { - if (toastStack.parentElement === null) { - document.body.append(toastStack); - } - - toastStack.appendChild(this); - - // Wait for the toast stack to render - requestAnimationFrame(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition - this.clientWidth; - this.show(); - }); - - this.addEventListener( - 'sd-after-hide', - () => { - toastStack.removeChild(this); - resolve(); - - // Remove the toast stack from the DOM when there are no more alerts - if (toastStack.querySelector('sd-alert') === null) { - toastStack.remove(); - } - }, - { once: true } - ); - }); - } - - render() { - return html` - - `; - } -} - -setDefaultAnimation('alert.show', { - keyframes: [ - { opacity: 0, scale: 0.8 }, - { opacity: 1, scale: 1 } - ], - options: { duration: 250, easing: 'ease' } -}); - -setDefaultAnimation('alert.hide', { - keyframes: [ - { opacity: 1, scale: 1 }, - { opacity: 0, scale: 0.8 } - ], - options: { duration: 250, easing: 'ease' } -}); - -declare global { - interface HTMLElementTagNameMap { - 'sd-alert': SdAlert; - } -} diff --git a/packages/components/src/_components/divider/divider.stories.ts b/packages/components/src/_components/divider/divider.stories.ts deleted file mode 100644 index 0f535eacc..000000000 --- a/packages/components/src/_components/divider/divider.stories.ts +++ /dev/null @@ -1,26 +0,0 @@ -import '../../solid-components'; -import { storybookDefaults, storybookTemplate } from '../../../scripts/storybook/helper'; -import { withActions } from '@storybook/addon-actions/decorator'; - -const { argTypes, args, parameters } = storybookDefaults('sd-divider'); -const { generateTemplate } = storybookTemplate('sd-divider'); - -export default { - title: 'Components/sd-divider', - component: 'sd-divider', - args, - argTypes, - parameters: {...parameters}, - decorators: [withActions] as any -}; - - -/** - * Default: This shows sd-divider in its default state. - */ - -export const Default = { - render: (args: any) => { - return generateTemplate({ args }); - } -}; diff --git a/packages/components/src/_components/divider/divider.styles.ts b/packages/components/src/_components/divider/divider.styles.ts deleted file mode 100644 index 55602b491..000000000 --- a/packages/components/src/_components/divider/divider.styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { css } from 'lit'; -import componentStyles from '../../styles/component.styles'; - -export default css` - ${componentStyles} - - :host { - --color: var(--sd-panel-border-color); - --width: var(--sd-panel-border-width); - --spacing: var(--sd-spacing-medium); - } - - :host(:not([vertical])) { - display: block; - border-top: solid var(--width) var(--color); - margin: var(--spacing) 0; - } - - :host([vertical]) { - display: inline-block; - height: 100%; - border-left: solid var(--width) var(--color); - margin: 0 var(--spacing); - } -`; diff --git a/packages/components/src/_components/divider/divider.test.ts b/packages/components/src/_components/divider/divider.test.ts deleted file mode 100644 index d21904755..000000000 --- a/packages/components/src/_components/divider/divider.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; -import type SdDivider from './divider'; - -describe('', () => { - describe('defaults ', () => { - it('passes accessibility test', async () => { - const el = await fixture(html` `); - await expect(el).to.be.accessible(); - }); - - it('default properties', async () => { - const el = await fixture(html` `); - - expect(el.vertical).to.be.false; - expect(el.getAttribute('role')).to.equal('separator'); - expect(el.getAttribute('aria-orientation')).to.equal('horizontal'); - }); - }); - - describe('vertical property change ', () => { - it('aria-orientation is updated', async () => { - const el = await fixture(html` `); - - el.vertical = true; - await elementUpdated(el); - - expect(el.getAttribute('aria-orientation')).to.equal('vertical'); - }); - }); -}); diff --git a/packages/components/src/_components/divider/divider.ts b/packages/components/src/_components/divider/divider.ts deleted file mode 100644 index 86ce0faa7..000000000 --- a/packages/components/src/_components/divider/divider.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { customElement } from '../../../src/internal/register-custom-element'; -import {property } from 'lit/decorators.js'; -import { watch } from '../../internal/watch'; -import SolidElement from '../../internal/solid-element'; -import styles from './divider.styles'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary Dividers are used to visually separate or group elements. - * @documentation https://solid.union-investment.com/[storybook-link]/divider - * @status stable - * @since 1.0 - * - * @cssproperty --color - The color of the divider. - * @cssproperty --width - The width of the divider. - * @cssproperty --spacing - The spacing of the divider. - */ -@customElement('sd-divider') -export default class SdDivider extends SolidElement { - static styles: CSSResultGroup = styles; - - /** Draws the divider in a vertical orientation. */ - @property({ type: Boolean, reflect: true }) vertical = false; - - connectedCallback() { - super.connectedCallback(); - this.setAttribute('role', 'separator'); - } - - @watch('vertical') - handleVerticalChange() { - this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal'); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sd-divider': SdDivider; - } -} diff --git a/packages/components/src/components/carousel/carousel.stories.ts b/packages/components/src/components/carousel/carousel.stories.ts index 3125797e2..4d979ee24 100644 --- a/packages/components/src/components/carousel/carousel.stories.ts +++ b/packages/components/src/components/carousel/carousel.stories.ts @@ -2,7 +2,6 @@ import '../../solid-components'; import { html } from 'lit'; import { storybookDefaults, storybookHelpers, storybookTemplate } from '../../../scripts/storybook/helper'; -import { userEvent } from '@storybook/testing-library'; import { waitUntil } from '@open-wc/testing-helpers'; const { argTypes, parameters } = storybookDefaults('sd-carousel'); @@ -243,7 +242,8 @@ export const Mouseless = { play: async ({ canvasElement }: { canvasElement: HTMLUnknownElement }) => { const el = canvasElement.querySelector('.mouseless sd-carousel'); - await waitUntil(() => el?.shadowRoot?.querySelector('scroll-container')); - await userEvent.type(el!.shadowRoot!.querySelector('scroll-container')!, '{space}', { pointerEventsCheck: 0 }); + await waitUntil(() => el?.shadowRoot?.querySelector('#scroll-container')); + + el?.shadowRoot?.querySelector('#scroll-container')!.focus(); } }; diff --git a/packages/components/src/components/icon/library.system.ts b/packages/components/src/components/icon/library.system.ts index 62fb916de..b00bdebbc 100644 --- a/packages/components/src/components/icon/library.system.ts +++ b/packages/components/src/components/icon/library.system.ts @@ -84,7 +84,13 @@ export const icons = { - ` + `, + 'confirm-circle': ` + `, + warning: ` + `, + 'exclamation-circle': ` + ` }; const systemLibrary: IconLibrary = { diff --git a/packages/components/src/components/notification/notification.stories.ts b/packages/components/src/components/notification/notification.stories.ts new file mode 100644 index 000000000..1a05df988 --- /dev/null +++ b/packages/components/src/components/notification/notification.stories.ts @@ -0,0 +1,350 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import '../../solid-components'; +import { html } from 'lit'; +import { storybookDefaults, storybookHelpers, storybookTemplate } from '../../../scripts/storybook/helper'; +import { userEvent } from '@storybook/testing-library'; +import { waitUntil } from '@open-wc/testing-helpers'; + +const { argTypes, parameters } = storybookDefaults('sd-notification'); +const { generateTemplate } = storybookTemplate('sd-notification'); +const { overrideArgs } = storybookHelpers('sd-notification'); + +export default { + title: 'Components/sd-notification', + component: 'sd-notification', + args: overrideArgs([ + { + type: 'slot', + name: 'default', + value: `
Lorem ipsum dolor sit.
` + }, + { + type: 'attribute', + name: 'open', + value: true + } + ]), + argTypes, + parameters: { ...parameters }, + decorators: [ + (story: () => typeof html) => html` + + ${story()} + ` + ] +}; + +/** + * This shows sd-notification in its default state. + */ + +export const Default = { + render: (args: any) => { + return generateTemplate({ + args + }); + } +}; + +/** + * Use the `variant` attribute to change the theme of the notification. + */ + +export const Variants = { + parameters: { controls: { exclude: ['variant', 'open'] } }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { type: 'attribute', name: 'variant' } + }, + args, + constants: { type: 'attribute', name: 'open', value: true } + }); + } +}; + +/** + * Use the `closable` attribute to toggle a close button. + */ + +export const Closable = { + parameters: { controls: { exclude: ['closable', 'open'] } }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { type: 'attribute', name: 'closable' } + }, + args, + constants: { type: 'attribute', name: 'open', value: true } + }); + } +}; + +/** + * Use the `duration` attribute to set the duration (in milliseconds) of the notification. + */ + +export const Duration = { + parameters: { controls: { exclude: ['duration'] } }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { type: 'attribute', name: 'duration', values: [Infinity, 5000] } + }, + args, + constants: { type: 'attribute', name: 'open', value: true } + }); + } +}; + +/** + * Use the `duration-indicator` attribute to enable an animation that visualizes the duration of a notification. + */ + +export const DurationIndicator = { + parameters: { controls: { exclude: ['duration', 'duration-indicator', 'open'] } }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { type: 'attribute', name: 'duration-indicator', values: [true] } + }, + args, + constants: [ + { type: 'attribute', name: 'duration', value: 10000 }, + { type: 'attribute', name: 'open', value: true } + ] + }); + } +}; + +/** + * Display a toast notification at the top-right of the screen by using the `toast` method. The default position is `top-right`. + */ +export const ToastNotification = { + parameters: { + controls: { + exclude: ['open', 'closable', 'variant', 'toast-stack', 'duration', 'duration-indicator', 'default-slot'] + } + }, + name: 'Toast Notification (Default)', + render: (_args: Record) => { + return html` +
+ + Info + + + Success + + Warning + Error +
+ + `; + }, + play: async ({ canvasElement }: { canvasElement: HTMLUnknownElement }) => { + const button = canvasElement.querySelector('#top-right'); + await userEvent.click(button!); + } +}; + +/** + * Display a toast notification positioned at the bottom-center of the screen. Set the `toastStack` attribute to `bottom-center` for the alternative position of the toast sd-notification. + */ +export const ToastBottomCenter = { + parameters: { + controls: { + exclude: ['open', 'closable', 'variant', 'toast-stack', 'duration', 'duration-indicator'] + } + }, + name: 'Toast Notification (Bottom Center)', + render: (_args: Record) => { + return html` +
+ + Info + + + Success + + Warning + Error +
+ + + `; + }, + play: async ({ canvasElement }: { canvasElement: HTMLUnknownElement }) => { + const button = canvasElement.querySelector('#bottom-center'); + await userEvent.click(button!); + } +}; + +/** + * Use the `base`, `icon`, `content`, `message`, `duration-indicator__elapsed`, `duration-indicator__total` and `close-button`, part selectors to customize the notification. + */ + +export const Parts = { + parameters: { + controls: { + exclude: [ + 'base', + 'icon', + 'content', + 'message', + 'duration-indicator__elapsed', + 'duration-indicator__total', + 'close-button' + ] + } + }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { + type: 'template', + name: 'sd-notification::part(...){outline: solid 2px red}', + values: [ + 'base', + 'icon', + 'content', + 'message', + 'duration-indicator__elapsed', + 'duration-indicator__total', + 'close-button' + ].map(part => { + return { + title: part, + value: `
%TEMPLATE%
` + }; + }) + } + }, + args, + constants: [ + { type: 'attribute', name: 'duration', value: Infinity }, + { type: 'attribute', name: 'duration-indicator', value: true }, + { type: 'attribute', name: 'closable', value: true }, + { type: 'attribute', name: 'open', value: true } + ] + }); + } +}; + +/** + * sd-notifications are fully accessibile via keyboard. + */ + +export const Mouseless = { + render: (args: any) => { + return html`
+ ${generateTemplate({ + args, + constants: [ + { type: 'attribute', name: 'closable', value: true }, + { type: 'attribute', name: 'open', value: true } + ] + })} +
`; + }, + + play: async ({ canvasElement }: { canvasElement: HTMLUnknownElement }) => { + const el = canvasElement.querySelector('.mouseless sd-notification'); + await waitUntil(() => el?.shadowRoot?.querySelector('sd-button')); + + el?.shadowRoot?.querySelector('sd-button')?.focus(); + } +}; diff --git a/packages/components/src/components/notification/notification.test.ts b/packages/components/src/components/notification/notification.test.ts new file mode 100644 index 000000000..c5f4b2eb8 --- /dev/null +++ b/packages/components/src/components/notification/notification.test.ts @@ -0,0 +1,351 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { clickOnElement, moveMouseOnElement } from '../../internal/test.js'; +import { queryByTestId } from '../../internal/test/data-testid-helpers.js'; +import { resetMouse } from '@web/test-runner-commands'; +import sinon from 'sinon'; + +import type SdButton from '../button/button.js'; +import type SdNotification from './notification.js'; + +const getNotificationContainer = (notification: SdNotification): HTMLElement => { + return notification.shadowRoot!.querySelector('[part="base"]')!; +}; + +const getIconSlot = (notification: SdNotification): HTMLElement => { + return notification.shadowRoot!.querySelector('[part="icon"]')!; +}; + +const expectNotificationToBeVisible = (notification: SdNotification): void => { + const notificationContainer = getNotificationContainer(notification); + const style = window.getComputedStyle(notificationContainer); + expect(style.display).not.to.equal('none'); + expect(style.visibility).not.to.equal('hidden'); + expect(style.visibility).not.to.equal('collapse'); +}; + +const expectNotificationToBeInvisible = (notification: SdNotification): void => { + const notifictionContainer = getNotificationContainer(notification); + const style = window.getComputedStyle(notifictionContainer); + expect(style.display, 'notification should be invisible').to.equal('none'); +}; + +const expectHideAndAfterHideToBeEmittedInCorrectOrder = async ( + notification: SdNotification, + action: () => void | Promise +) => { + const hidePromise = oneEvent(notification, 'sd-hide', false); + const afterHidePromise = oneEvent(notification, 'sd-after-hide', false); + let afterHideHappened = false; + oneEvent(notification, 'sd-after-hide', false).then(() => (afterHideHappened = true)); + + action(); + + await hidePromise; + expect(afterHideHappened).to.be.false; + + await afterHidePromise; + expectNotificationToBeInvisible(notification); +}; + +const expectShowAndAfterShowToBeEmittedInCorrectOrder = async ( + notification: SdNotification, + action: () => void | Promise +) => { + const showPromise = oneEvent(notification, 'sd-show', false); + const afterShowPromise = oneEvent(notification, 'sd-after-show', false); + let afterShowHappened = false; + oneEvent(notification, 'sd-after-show', false).then(() => (afterShowHappened = true)); + + action(); + + await showPromise; + expect(afterShowHappened).to.be.false; + + await afterShowPromise; + expectNotificationToBeVisible(notification); +}; + +const getCloseButton = (notification: SdNotification): SdButton | null | undefined => + notification.shadowRoot?.querySelector('[part="close-button"]'); + +describe('', () => { + let clock: sinon.SinonFakeTimers | null = null; + + afterEach(async () => { + clock?.restore(); + await resetMouse(); + }); + + it('renders', async () => { + const notification = await fixture( + html`I am a notification` + ); + + expectNotificationToBeVisible(notification); + }); + + it('is accessible', async () => { + const notification = await fixture( + html`I am a notification` + ); + + await expect(notification).to.be.accessible(); + }); + + describe('notification visibility', () => { + it('should be visible with the closed attribute is false', async () => { + const notification = await fixture( + html`I am a notification` + ); + + expectNotificationToBeVisible(notification); + }); + + it('should not be visible when closed', async () => { + const notification = await fixture(html` I am a notification`); + + expectNotificationToBeInvisible(notification); + }); + + it('should emit sd-show and sd-after-show when calling show()', async () => { + const notification = await fixture(html` I am a notification`); + + expectNotificationToBeInvisible(notification); + + await expectShowAndAfterShowToBeEmittedInCorrectOrder(notification, () => notification.show()); + }); + + it('should emit sd-hide and sd-after-hide when calling hide()', async () => { + const notification = await fixture( + html` I am a notification` + ); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => notification.hide()); + }); + + it('should emit sd-show and sd-after-show when opened', async () => { + const notification = await fixture(html` + I am a notification + `); + + await expectShowAndAfterShowToBeEmittedInCorrectOrder(notification, () => { + notification.open = true; + }); + }); + + it('should emit sd-hide and sd-after-hide when setting open = false', async () => { + const notification = await fixture(html` + I am a notification + `); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => { + notification.open = false; + }); + }); + }); + + describe('close button', () => { + it('shows a close button if the notification has the closable attribute', () => async () => { + const notification = await fixture(html` + I am a notification + `); + const closeButton = getCloseButton(notification); + + expect(closeButton).to.be.visible; + }); + + it('clicking the close button closes the notification', () => async () => { + const notification = await fixture(html` + I am a notification + `); + const closeButton = getCloseButton(notification); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => clickOnElement(closeButton!)); + }); + }); + + describe('toast', () => { + const getToastStack = (): HTMLDivElement | null => document.querySelector('.sd-toast-stack'); + + const closeRemainingNotifications = async (): Promise => { + const toastStack = getToastStack(); + if (toastStack?.children) { + for (const element of toastStack.children) { + await (element as SdNotification).hide(); + } + } + }; + + beforeEach(async () => { + await closeRemainingNotifications(); + }); + + it('can be rendered as a toast', async () => { + const notification = await fixture(html`I am a notification`); + + expectShowAndAfterShowToBeEmittedInCorrectOrder(notification, () => notification.toast()); + const toastStack = getToastStack(); + expect(toastStack).to.be.visible; + expect(toastStack?.firstChild).to.be.equal(notification); + }); + + it('resolves only after being closed', async () => { + const notification = await fixture( + html`I am a notification` + ); + const afterShowEvent = oneEvent(notification, 'sd-after-show', false); + let toastPromiseResolved = false; + notification.toast().then(() => (toastPromiseResolved = true)); + await afterShowEvent; + expect(toastPromiseResolved).to.be.false; + + const closePromise = oneEvent(notification, 'sd-after-hide', false); + const closeButton = getCloseButton(notification); + + await clickOnElement(closeButton!); + + await closePromise; + await aTimeout(0); + + expect(toastPromiseResolved).to.be.true; + }); + + const expectToastStack = () => { + const toastStack = getToastStack(); + expect(toastStack).not.to.be.null; + }; + + const expectNoToastStack = () => { + const toastStack = getToastStack(); + expect(toastStack).to.be.null; + }; + + const openToast = async (notification: SdNotification): Promise => { + const openPromise = oneEvent(notification, 'sd-after-show', false); + notification.toast(); + await openPromise; + }; + + const closeToast = async (notification: SdNotification): Promise => { + const closePromise = oneEvent(notification, 'sd-after-hide', false); + const closeButton = getCloseButton(notification); + await clickOnElement(closeButton!); + await closePromise; + await aTimeout(0); + }; + + it('deletes the toast stack after the last notification is done', async () => { + const container = await fixture( + html`
+ notification 1 + notification 2 +
` + ); + + const notification1 = queryByTestId(container, 'notification1'); + const notification2 = queryByTestId(container, 'notification2'); + await openToast(notification1!); + + expectToastStack(); + + await openToast(notification2!); + + expectToastStack(); + + await closeToast(notification1!); + + expectToastStack(); + + await closeToast(notification2!); + + expectNoToastStack(); + }); + }); + + describe('timer controlled closing', () => { + it('closes after a predefined amount of time', async () => { + clock = sinon.useFakeTimers(); + const notification = await fixture( + html` I am a notification` + ); + + expectNotificationToBeVisible(notification); + + clock.tick(2999); + + expectNotificationToBeVisible(notification); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => { + clock?.tick(1); + }); + }); + + it('pauses the closing timer on mouse-over', async () => { + clock = sinon.useFakeTimers(); + const notification = await fixture( + html` I am a notification` + ); + + expectNotificationToBeVisible(notification); + + clock.tick(1000); + + await moveMouseOnElement(notification); + + clock.tick(1999); + + expectNotificationToBeVisible(notification); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => { + clock?.tick(1); + }); + }); + + it('resets the closing timer after opening', async () => { + clock = sinon.useFakeTimers(); + const notification = await fixture( + html` I am a notification` + ); + + expectNotificationToBeInvisible(notification); + + clock.tick(1000); + + const afterShowPromise = oneEvent(notification, 'sd-after-show', false); + notification.show(); + await afterShowPromise; + + clock.tick(2999); + + await expectHideAndAfterHideToBeEmittedInCorrectOrder(notification, () => { + clock?.tick(1); + }); + }); + }); + + describe('notification variants', () => { + const variants = ['info', 'success', 'warning', 'error']; + const variantToClassMap = { + info: 'bg-info', + success: 'bg-success', + warning: 'bg-warning', + error: 'bg-error' + }; + + variants.forEach(variant => { + it(`adapts to the variant: ${variant}`, async () => { + const notification = await fixture( + html`I am a notification` + ); + + const notificationContainer = getIconSlot(notification); + expect(notificationContainer).to.have.class( + variantToClassMap[variant as 'info' | 'success' | 'warning' | 'error'] + ); + }); + }); + }); +}); diff --git a/packages/components/src/components/notification/notification.ts b/packages/components/src/components/notification/notification.ts new file mode 100644 index 000000000..f12e210db --- /dev/null +++ b/packages/components/src/components/notification/notification.ts @@ -0,0 +1,326 @@ +import { animateTo, stopAnimations } from '../../internal/animate.js'; +import { css, html } from 'lit'; +import { customElement } from '../../internal/register-custom-element.js'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { waitForEvent } from '../../internal/event.js'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles'; +import cx from 'classix'; +import SolidElement from '../../internal/solid-element.js'; + +const toastStackDefault = Object.assign(document.createElement('div'), { + className: 'sd-toast-stack sd-toast-stack--top-right' +}); +const toastStackBottomCenter = Object.assign(document.createElement('div'), { + className: 'sd-toast-stack sd-toast-stack--bottom-center' +}); + +/** + * @summary Alerts are used to display important messages inline or as toast notifications. + * @documentation https://solid.union-investment.com/[storybook-link]/notification + * @status stable + * @since 1.22.0 + * + * @dependency sd-button + * + * @slot - The sd-notification's main content. + * @slot icon - An icon to show in the sd-notification. Works best with ``. + * + * @event sd-show - Emitted when the notification opens. + * @event sd-after-show - Emitted after the notification opens and all animations are complete. + * @event sd-hide - Emitted when the notification closes. + * @event sd-after-hide - Emitted after the notification closes and all animations are complete. + * + * @csspart base - The component's base wrapper. + * @csspart icon - The container that wraps the optional icon. + * @csspart content - The container that wraps the notifications's main content and the close button. + * @csspart message - The container that wraps the notifications's main content. + * @csspart duration-indicator__elapsed - The current duration indicator. + * @csspart duration-indicator__total - The total duration indicator. + * @csspart close-button - The close button, an ``. + * + * @animation notification.show - The animation to use when showing the sd-notification. + * @animation notifiation.hide - The animation to use when hiding the sd-notification. + */ + +@customElement('sd-notification') +export default class SdNotification extends SolidElement { + private autoHideTimeout: number; + private readonly localize = new LocalizeController(this); + + @query('[part~="base"]') base: HTMLElement; + + /** + * Indicates whether or not sd-notification is open. You can toggle this attribute to show and hide the notification, or you can + * use the `show()` and `hide()` methods and this attribute will reflect the notifications's open state. + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** Enables a close button that allows the user to dismiss the notification. */ + @property({ type: Boolean, reflect: true }) closable = false; + + /** The sd-notification's theme. */ + @property({ reflect: true }) variant: 'info' | 'success' | 'error' | 'warning' = 'info'; + + /** The position of the toasted sd-notification. */ + @property({ reflect: true, attribute: 'toast-stack' }) toastStack: 'top-right' | 'bottom-center' = 'top-right'; + + /** + * The length of time, in milliseconds, the sd-notification will show before closing itself. If the user interacts with + * the notification before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning + * the notification will not close on its own. + */ + @property({ type: Number }) duration = Infinity; + + /** Enables an animation that visualizes the duration of a notification. */ + @property({ type: Boolean, reflect: true, attribute: 'duration-indicator' }) durationIndicator = false; + + private remainingDuration = this.duration; + private startTime = Date.now(); + + firstUpdated() { + this.base.hidden = !this.open; + } + + private startAutoHide() { + clearTimeout(this.autoHideTimeout); + this.startTime = Date.now(); + this.remainingDuration = this.duration; + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration); + } + } + + private onHover() { + clearTimeout(this.autoHideTimeout); + + if (this.duration < Infinity) { + this.remainingDuration -= Date.now() - this.startTime; + } + } + + private onHoverEnd() { + this.startTime = Date.now(); + clearTimeout(this.autoHideTimeout); + + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = window.setTimeout(() => { + this.hide(); + }, this.remainingDuration); + } + } + + private handleCloseClick() { + this.hide(); + } + + @watch('open', { waitUntilFirstUpdate: true }) + async handleOpenChange() { + if (this.open) { + // Show + this.emit('sd-show'); + + if (this.duration < Infinity) { + this.startAutoHide(); + } + await stopAnimations(this.base); + this.base.hidden = false; + const { keyframes, options } = getAnimation(this, 'notification.show', { dir: this.localize.dir() }); + await animateTo(this.base, keyframes, options); + + this.emit('sd-after-show'); + } else { + // Hide + this.emit('sd-hide'); + + clearTimeout(this.autoHideTimeout); + + await stopAnimations(this.base); + const { keyframes, options } = getAnimation(this, 'notification.hide', { dir: this.localize.dir() }); + await animateTo(this.base, keyframes, options); + this.base.hidden = true; + + this.emit('sd-after-hide'); + } + } + + @watch('duration') + handleDurationChange() { + this.startAutoHide(); + } + + /** Shows the notification. */ + async show() { + if (this.open) { + return undefined; + } + + this.open = true; + return waitForEvent(this, 'sd-after-show'); + } + + /** Hides the notification */ + async hide() { + if (!this.open) { + return undefined; + } + + this.open = false; + return waitForEvent(this, 'sd-after-hide'); + } + + /** + * Displays the notification as a toast notification. This will move the notification out of its position in the DOM and, when + * dismissed, it will be removed from the DOM completely. By storing a reference to the notification, you can reuse it by + * calling this method again. The returned promise will resolve after the notification is hidden. + */ + async toast() { + return new Promise(resolve => { + const toastStack = this.toastStack === 'bottom-center' ? toastStackBottomCenter : toastStackDefault; + + if (toastStack.parentElement === null) { + document.body.append(toastStack); + } + + toastStack.appendChild(this); + + // Wait for the toast stack to render + requestAnimationFrame(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition + this.clientWidth; + this.show(); + }); + + this.addEventListener( + 'sd-after-hide', + () => { + toastStack.removeChild(this); + resolve(); + + // Remove the toast stack from the DOM when there are no more alerts + if (toastStack.querySelector('sd-notification') === null) { + toastStack.remove(); + } + }, + { once: true } + ); + }); + } + + render() { + return html` + + `; + } + + /** + * Inherits Tailwindclasses and includes additional styling. + */ + static styles = [ + componentStyles, + SolidElement.styles, + css` + :host { + display: contents; + } + + #notification:hover #duration-indicator__elapsed { + animation-play-state: paused !important; + } + ` + ]; +} + +setDefaultAnimation('notification.show', { + keyframes: [ + { opacity: 0, scale: 0.8 }, + { opacity: 1, scale: 1 } + ], + options: { duration: 250, easing: 'ease' } +}); + +setDefaultAnimation('notification.hide', { + keyframes: [ + { opacity: 1, scale: 1 }, + { opacity: 0, scale: 0.8 } + ], + options: { duration: 250, easing: 'ease' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sd-notification': SdNotification; + } +} diff --git a/packages/components/src/internal/test.ts b/packages/components/src/internal/test.ts index 935ca70c9..80f11a351 100644 --- a/packages/components/src/internal/test.ts +++ b/packages/components/src/internal/test.ts @@ -11,6 +11,12 @@ export async function clickOnElement( /** The vertical offset to apply to the position when clicking */ offsetY = 0 ) { + const { clickX, clickY } = determineMousePosition(el, position, offsetX, offsetY); + + await sendMouse({ type: 'click', position: [clickX, clickY] }); +} + +function determineMousePosition(el: Element, position: string, offsetX: number, offsetY: number) { const { x, y, width, height } = el.getBoundingClientRect(); const centerX = Math.floor(x + window.pageXOffset + width / 2); const centerY = Math.floor(y + window.pageYOffset + height / 2); @@ -41,6 +47,21 @@ export async function clickOnElement( clickX += offsetX; clickY += offsetY; + return { clickX, clickY }; +} - await sendMouse({ type: 'click', position: [clickX, clickY] }); +/** A testing utility that moves the mouse onto an element. */ +export async function moveMouseOnElement( + /** The element to click */ + el: Element, + /** The location of the element to click */ + position: 'top' | 'right' | 'bottom' | 'left' | 'center' = 'center', + /** The horizontal offset to apply to the position when clicking */ + offsetX = 0, + /** The vertical offset to apply to the position when clicking */ + offsetY = 0 +) { + const { clickX, clickY } = determineMousePosition(el, position, offsetX, offsetY); + + await sendMouse({ type: 'move', position: [clickX, clickY] }); } diff --git a/packages/components/src/solid-styles.css b/packages/components/src/solid-styles.css index c03c1adcc..83875a7b8 100644 --- a/packages/components/src/solid-styles.css +++ b/packages/components/src/solid-styles.css @@ -7,3 +7,6 @@ @import './styles/table-cell/table-cell.css'; @import './styles/table/table.css'; @import './styles/headline/headline.css'; + +/* Utility classes that can't be contained in a component and must be applied to the light DOM */ +@import './styles/_utility.css'; diff --git a/packages/components/src/themes/_utility.css b/packages/components/src/styles/_utility.css similarity index 54% rename from packages/components/src/themes/_utility.css rename to packages/components/src/styles/_utility.css index 460ced756..0cd913b29 100644 --- a/packages/components/src/themes/_utility.css +++ b/packages/components/src/styles/_utility.css @@ -10,20 +10,24 @@ } .sd-toast-stack { - position: fixed; - top: 0; - inset-inline-end: 0; - z-index: var(--sd-z-index-toast); - width: 28rem; - max-width: 100%; - max-height: 100%; - overflow: auto; -} + @apply fixed z-alert-group max-w-[400px] max-h-full box-border; + + &--top-right { + @apply top-0 right-0 mr-4; + } -.sd-toast-stack sd-alert { - margin: var(--sd-spacing-medium); + &--bottom-center { + @apply bottom-0 -translate-x-1/2; + inset-inline-start: 50%; + } } -.sd-toast-stack sd-alert::part(base) { - box-shadow: var(--sd-shadow-large); +.sd-toast-stack { + sd-notification::part(base) { + @apply shadow; + } + + sd-notification::part(content) { + @apply border-white; + } } diff --git a/packages/components/tailwind.config.cjs b/packages/components/tailwind.config.cjs index 0efd75d14..783d5cb51 100644 --- a/packages/components/tailwind.config.cjs +++ b/packages/components/tailwind.config.cjs @@ -4,6 +4,18 @@ const theme = require('../tokens/src/create-theme.cjs'); // Brandshape uses background color in Figma but is a SVG on our side, therefore we have add the color here theme.fill.neutral[100] = theme.backgroundColor.neutral[100].replace('/*', '/* Only needed for brandshape –'); +theme.extend = { + keyframes: { + grow: { + '0%': { width: '0%' }, + '100%': { width: '100%' } + } + }, + animation: { + grow: 'grow linear' + } +}; + // Check if the script triggered is a Storybook script, e. g. `pnpm build/storybook` or `pnpm storybook` const includeStorybookStories = process.env.npm_lifecycle_event?.includes('storybook');