From 159f536b429350ac54cd28bd55cb63754a423a11 Mon Sep 17 00:00:00 2001 From: Marco D'Auria <101181211+dauriamarco@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:40:42 +0200 Subject: [PATCH] fix(sbb-dialog): fix accessibility with option to hide the header on scroll (#2231) In order to support the new feature of optionally hiding the header during scroll, we had to re structure all the dialog component. BREAKING CHANGE: The `sbb-dialog` component now supports the dedicated inner elements `sbb-dialog-title`, `sbb-dialog-content`, and `sbb-dialog-actions`. Use these components to respectively provide a title, a content and, optionally, a footer with an action group. Moreover, the full-screen variant (which occurred when no title was provided to the dialog) has been removed. To achieve a full-screen overlay, please use the `sbb-overlay` component. This was the previous implementation: ```html

Dialog content.

...
``` This is the new implementation: ```html Title Dialog content ... ``` Previously, a ***full-screen*** dialog was displayed if no title was provided to the dialog component: ```html

Dialog content.

``` It is now mandatory to use the `sbb-overlay` component to display the same variant: ```html

Overlay content.

``` --- src/components/core/dom/breakpoint.ts | 17 +- .../dialog/__snapshots__/dialog.spec.snap.js | 74 --- .../__snapshots__/dialog-actions.spec.snap.js | 45 ++ .../dialog/dialog-actions/dialog-actions.scss | 21 + .../dialog-actions/dialog-actions.spec.ts | 24 + .../dialog-actions/dialog-actions.stories.ts | 45 ++ .../dialog/dialog-actions/dialog-actions.ts | 28 + src/components/dialog/dialog-actions/index.ts | 1 + .../dialog/dialog-actions/readme.md | 29 + .../dialog/dialog-content/dialog-content.scss | 26 + .../dialog-content/dialog-content.spec.ts | 17 + .../dialog-content/dialog-content.stories.ts | 30 ++ .../dialog/dialog-content/dialog-content.ts | 30 ++ src/components/dialog/dialog-content/index.ts | 1 + .../dialog/dialog-content/readme.md | 15 + .../__snapshots__/dialog-title.spec.snap.js | 91 ++++ .../dialog/dialog-title/dialog-title.e2e.ts | 25 + .../dialog/dialog-title/dialog-title.scss | 59 ++ .../dialog/dialog-title/dialog-title.spec.ts | 22 + .../dialog-title/dialog-title.stories.ts | 100 ++++ .../dialog/dialog-title/dialog-title.ts | 130 +++++ src/components/dialog/dialog-title/index.ts | 1 + src/components/dialog/dialog-title/readme.md | 73 +++ src/components/dialog/dialog.spec.ts | 18 - src/components/dialog/dialog.stories.ts | 423 --------------- .../dialog/__snapshots__/dialog.spec.snap.js | 126 +++++ .../dialog/{ => dialog}/dialog.e2e.ts | 170 +++--- .../dialog/{ => dialog}/dialog.scss | 117 +--- src/components/dialog/dialog/dialog.spec.ts | 33 ++ .../dialog/dialog/dialog.stories.ts | 504 ++++++++++++++++++ src/components/dialog/{ => dialog}/dialog.ts | 363 +++++++------ src/components/dialog/dialog/index.ts | 1 + src/components/dialog/dialog/readme.md | 126 +++++ src/components/dialog/index.ts | 5 +- src/components/dialog/readme.md | 122 ----- .../pages/home/home--logged-in.stories.ts | 47 +- 36 files changed, 1950 insertions(+), 1009 deletions(-) delete mode 100644 src/components/dialog/__snapshots__/dialog.spec.snap.js create mode 100644 src/components/dialog/dialog-actions/__snapshots__/dialog-actions.spec.snap.js create mode 100644 src/components/dialog/dialog-actions/dialog-actions.scss create mode 100644 src/components/dialog/dialog-actions/dialog-actions.spec.ts create mode 100644 src/components/dialog/dialog-actions/dialog-actions.stories.ts create mode 100644 src/components/dialog/dialog-actions/dialog-actions.ts create mode 100644 src/components/dialog/dialog-actions/index.ts create mode 100644 src/components/dialog/dialog-actions/readme.md create mode 100644 src/components/dialog/dialog-content/dialog-content.scss create mode 100644 src/components/dialog/dialog-content/dialog-content.spec.ts create mode 100644 src/components/dialog/dialog-content/dialog-content.stories.ts create mode 100644 src/components/dialog/dialog-content/dialog-content.ts create mode 100644 src/components/dialog/dialog-content/index.ts create mode 100644 src/components/dialog/dialog-content/readme.md create mode 100644 src/components/dialog/dialog-title/__snapshots__/dialog-title.spec.snap.js create mode 100644 src/components/dialog/dialog-title/dialog-title.e2e.ts create mode 100644 src/components/dialog/dialog-title/dialog-title.scss create mode 100644 src/components/dialog/dialog-title/dialog-title.spec.ts create mode 100644 src/components/dialog/dialog-title/dialog-title.stories.ts create mode 100644 src/components/dialog/dialog-title/dialog-title.ts create mode 100644 src/components/dialog/dialog-title/index.ts create mode 100644 src/components/dialog/dialog-title/readme.md delete mode 100644 src/components/dialog/dialog.spec.ts delete mode 100644 src/components/dialog/dialog.stories.ts create mode 100644 src/components/dialog/dialog/__snapshots__/dialog.spec.snap.js rename src/components/dialog/{ => dialog}/dialog.e2e.ts (73%) rename src/components/dialog/{ => dialog}/dialog.scss (62%) create mode 100644 src/components/dialog/dialog/dialog.spec.ts create mode 100644 src/components/dialog/dialog/dialog.stories.ts rename src/components/dialog/{ => dialog}/dialog.ts (53%) create mode 100644 src/components/dialog/dialog/index.ts create mode 100644 src/components/dialog/dialog/readme.md delete mode 100644 src/components/dialog/readme.md diff --git a/src/components/core/dom/breakpoint.ts b/src/components/core/dom/breakpoint.ts index 4b4da9db6d..0e1c3b23b2 100644 --- a/src/components/core/dom/breakpoint.ts +++ b/src/components/core/dom/breakpoint.ts @@ -1,6 +1,7 @@ import { isBrowser } from './platform.js'; -export type Breakpoint = 'zero' | 'micro' | 'small' | 'medium' | 'wide' | 'large' | 'ultra'; +export const breakpoints = ['zero', 'micro', 'small', 'medium', 'wide', 'large', 'ultra'] as const; +export type Breakpoint = (typeof breakpoints)[number]; /** * Checks whether the document matches a particular media query. @@ -10,7 +11,11 @@ export type Breakpoint = 'zero' | 'micro' | 'small' | 'medium' | 'wide' | 'large * @param to The breakpoint corresponding to the `max-width` value of the media query (optional). * @returns A boolean indicating whether the window matches the breakpoint. */ -export function isBreakpoint(from?: Breakpoint, to?: Breakpoint): boolean { +export function isBreakpoint( + from?: Breakpoint, + to?: Breakpoint, + properties?: { includeMaxBreakpoint: boolean }, +): boolean { if (!isBrowser()) { // TODO: Remove and decide case by case what should be done on consuming end return false; @@ -19,7 +24,13 @@ export function isBreakpoint(from?: Breakpoint, to?: Breakpoint): boolean { const computedStyle = getComputedStyle(document.documentElement); const breakpointMin = from ? computedStyle.getPropertyValue(`--sbb-breakpoint-${from}-min`) : ''; const breakpointMax = to - ? `${parseFloat(computedStyle.getPropertyValue(`--sbb-breakpoint-${to}-min`)) - 0.0625}rem` + ? `${ + parseFloat( + computedStyle.getPropertyValue( + `--sbb-breakpoint-${to}-${properties?.includeMaxBreakpoint ? 'max' : 'min'}`, + ), + ) - (properties?.includeMaxBreakpoint ? 0 : 0.0625) + }rem` : ''; // subtract 1px (0.0625rem) from the max-width breakpoint const minWidth = breakpointMin && `(min-width: ${breakpointMin})`; diff --git a/src/components/dialog/__snapshots__/dialog.spec.snap.js b/src/components/dialog/__snapshots__/dialog.spec.snap.js deleted file mode 100644 index 612377223f..0000000000 --- a/src/components/dialog/__snapshots__/dialog.spec.snap.js +++ /dev/null @@ -1,74 +0,0 @@ -/* @web/test-runner snapshot v1 */ -export const snapshots = {}; - -snapshots["sbb-dialog renders"] = -`
-
-
-
- - - - - - -
-
- - -
- -
-
-
- - -`; -/* end snapshot sbb-dialog renders */ - -snapshots["sbb-dialog A11y tree Chrome"] = -`

- { - "role": "WebArea", - "name": "" -} -

-`; -/* end snapshot sbb-dialog A11y tree Chrome */ - -snapshots["sbb-dialog A11y tree Firefox"] = -`

- { - "role": "document", - "name": "" -} -

-`; -/* end snapshot sbb-dialog A11y tree Firefox */ - diff --git a/src/components/dialog/dialog-actions/__snapshots__/dialog-actions.spec.snap.js b/src/components/dialog/dialog-actions/__snapshots__/dialog-actions.spec.snap.js new file mode 100644 index 0000000000..f5251b9307 --- /dev/null +++ b/src/components/dialog/dialog-actions/__snapshots__/dialog-actions.spec.snap.js @@ -0,0 +1,45 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-dialog-actions renders"] = +` + +`; +/* end snapshot sbb-dialog-actions renders */ + +snapshots["sbb-dialog-actions A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "" +} +

+`; +/* end snapshot sbb-dialog-actions A11y tree Chrome */ + +snapshots["sbb-dialog-actions A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "" +} +

+`; +/* end snapshot sbb-dialog-actions A11y tree Firefox */ + +snapshots["sbb-dialog-actions A11y tree Safari"] = +`

+ { + "role": "WebArea", + "name": "" +} +

+`; +/* end snapshot sbb-dialog-actions A11y tree Safari */ + diff --git a/src/components/dialog/dialog-actions/dialog-actions.scss b/src/components/dialog/dialog-actions/dialog-actions.scss new file mode 100644 index 0000000000..d2a90cf00a --- /dev/null +++ b/src/components/dialog/dialog-actions/dialog-actions.scss @@ -0,0 +1,21 @@ +@use '../../core/styles' as sbb; + +:host { + display: contents; + + @include sbb.if-forced-colors { + --sbb-dialog-actions-border: var(--sbb-border-width-1x) solid CanvasText; + } +} + +.sbb-dialog-actions { + padding-inline: var(--sbb-dialog-padding-inline); + padding-block: var(--sbb-spacing-responsive-s); + margin-block-start: auto; + background-color: var(--sbb-dialog-background-color); + border-block-start: var(--sbb-dialog-actions-border); + + :host([data-overflows]:not([data-negative])) & { + @include sbb.shadow-level-9-soft; + } +} diff --git a/src/components/dialog/dialog-actions/dialog-actions.spec.ts b/src/components/dialog/dialog-actions/dialog-actions.spec.ts new file mode 100644 index 0000000000..cd325bb842 --- /dev/null +++ b/src/components/dialog/dialog-actions/dialog-actions.spec.ts @@ -0,0 +1,24 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private/index.js'; +import './dialog-actions.js'; + +describe('sbb-dialog-actions', () => { + it('renders', async () => { + const root = await fixture(html``); + + await expect(root).dom.to.equalSnapshot(); + + expect(root).shadowDom.to.be.equal(` +
+
+ + +
+
+ `); + }); + + testA11yTreeSnapshot(html``); +}); diff --git a/src/components/dialog/dialog-actions/dialog-actions.stories.ts b/src/components/dialog/dialog-actions/dialog-actions.stories.ts new file mode 100644 index 0000000000..b578e94cba --- /dev/null +++ b/src/components/dialog/dialog-actions/dialog-actions.stories.ts @@ -0,0 +1,45 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import './dialog-actions.js'; +import readme from './readme.md?raw'; + +import '../../button/button/index.js'; +import '../../button/secondary-button/index.js'; +import '../../link/index.js'; + +const Template = (): TemplateResult => + html` + + Link + + Cancel + Confirm + `; + +export const Default: StoryObj = { render: Template }; + +const meta: Meta = { + decorators: [ + (story) => html`
${story()}
`, + withActions as Decorator, + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-dialog/sbb-dialog-actions', +}; + +export default meta; diff --git a/src/components/dialog/dialog-actions/dialog-actions.ts b/src/components/dialog/dialog-actions/dialog-actions.ts new file mode 100644 index 0000000000..46eeafc270 --- /dev/null +++ b/src/components/dialog/dialog-actions/dialog-actions.ts @@ -0,0 +1,28 @@ +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbActionGroupElement } from '../../action-group/index.js'; + +import style from './dialog-actions.scss?lit&inline'; + +/** + * Use this component to display a footer into an `sbb-dialog` with an action group. + * + * @slot - Use the unnamed slot to add `sbb-block-link` or `sbb-button` elements to the `sbb-dialog-actions`. + */ +@customElement('sbb-dialog-actions') +export class SbbDialogActionsElement extends SbbActionGroupElement { + public static override styles: CSSResultGroup = [SbbActionGroupElement.styles, style]; + + protected override render(): TemplateResult { + return html`
${super.render()}
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-dialog-actions': SbbDialogActionsElement; + } +} diff --git a/src/components/dialog/dialog-actions/index.ts b/src/components/dialog/dialog-actions/index.ts new file mode 100644 index 0000000000..12fa6f2f6a --- /dev/null +++ b/src/components/dialog/dialog-actions/index.ts @@ -0,0 +1 @@ +export * from './dialog-actions.js'; diff --git a/src/components/dialog/dialog-actions/readme.md b/src/components/dialog/dialog-actions/readme.md new file mode 100644 index 0000000000..003ab3a96a --- /dev/null +++ b/src/components/dialog/dialog-actions/readme.md @@ -0,0 +1,29 @@ +The `sbb-dialog-actions` component extends the [sbb-action-group](/docs/components-sbb-action-group--docs) component. Use it in combination with the [sbb-dialog](/docs/components-sbb-dialog--docs) to display a footer with an action group. + +```html + + + Link + Cancel + Confirm + + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------------- | ----------------- | ------- | ------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `alignGroup` | `align-group` | public | `'start' \| 'center' \| 'stretch' \| 'end'` | `'start'` | Set the slotted `` children's alignment. | +| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom` | `'medium'` | Overrides the behaviour of `orientation` property. | +| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Indicates the orientation of the components inside the ``. | +| `buttonSize` | `button-size` | public | `SbbButtonSize` | `'l'` | Size of the nested sbb-button instances. This will overwrite the size attribute of nested sbb-button instances. | +| `linkSize` | `link-size` | public | `SbbLinkSize` | `'m'` | Size of the nested sbb-block-link instances. This will overwrite the size attribute of nested sbb-block-link instances. | + +## Slots + +| Name | Description | +| ---- | -------------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-block-link` or `sbb-button` elements to the `sbb-dialog-actions`. | diff --git a/src/components/dialog/dialog-content/dialog-content.scss b/src/components/dialog/dialog-content/dialog-content.scss new file mode 100644 index 0000000000..e2d6459b94 --- /dev/null +++ b/src/components/dialog/dialog-content/dialog-content.scss @@ -0,0 +1,26 @@ +@use '../../core/styles' as sbb; + +:host { + display: contents; +} + +.sbb-dialog-content { + @include sbb.scrollbar-rules; + + padding-inline: var(--sbb-dialog-padding-inline); + padding-block: var(--sbb-dialog-padding-block); + overflow: auto; + transform: translateY(var(--sbb-dialog-header-margin-block-start)); + margin-block: 0 calc(var(--sbb-dialog-header-height) * -1); + transition: var(--sbb-dialog-content-transition); + z-index: -1; + + // In order to improve the header transition on mobile (especially iOS) we use + // a combination of the transform and margin properties on touch devices, + // while on desktop we use just the margin-block for a better transition of the visible scrollbar. + @include sbb.mq($from: medium) { + transform: unset; + margin-block: var(--sbb-dialog-header-margin-block-start) 0; + transition: margin var(--sbb-dialog-animation-duration) var(--sbb-dialog-animation-easing); + } +} diff --git a/src/components/dialog/dialog-content/dialog-content.spec.ts b/src/components/dialog/dialog-content/dialog-content.spec.ts new file mode 100644 index 0000000000..5c4dbda497 --- /dev/null +++ b/src/components/dialog/dialog-content/dialog-content.spec.ts @@ -0,0 +1,17 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './dialog-content.js'; + +describe('sbb-dialog-content', () => { + it('renders', async () => { + const root = await fixture(html`Content`); + + expect(root).dom.to.be.equal(`Content`); + + expect(root).shadowDom.to.be.equal(` +
+ +
+ `); + }); +}); diff --git a/src/components/dialog/dialog-content/dialog-content.stories.ts b/src/components/dialog/dialog-content/dialog-content.stories.ts new file mode 100644 index 0000000000..7a25eb0a47 --- /dev/null +++ b/src/components/dialog/dialog-content/dialog-content.stories.ts @@ -0,0 +1,30 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import './dialog-content.js'; +import readme from './readme.md?raw'; + +const Template = (): TemplateResult => + html`This is a dialog content.`; + +export const Default: StoryObj = { render: Template }; + +const meta: Meta = { + decorators: [ + (story) => html`
${story()}
`, + withActions as Decorator, + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-dialog/sbb-dialog-content', +}; + +export default meta; diff --git a/src/components/dialog/dialog-content/dialog-content.ts b/src/components/dialog/dialog-content/dialog-content.ts new file mode 100644 index 0000000000..b1143f6308 --- /dev/null +++ b/src/components/dialog/dialog-content/dialog-content.ts @@ -0,0 +1,30 @@ +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import style from './dialog-content.scss?lit&inline'; + +/** + * Use this component to provide a content for an `sbb-dialog`. + * + * @slot - Use the unnamed slot to provide a dialog content. + */ +@customElement('sbb-dialog-content') +export class SbbDialogContentElement extends LitElement { + public static override styles: CSSResultGroup = style; + + protected override render(): TemplateResult { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-dialog-content': SbbDialogContentElement; + } +} diff --git a/src/components/dialog/dialog-content/index.ts b/src/components/dialog/dialog-content/index.ts new file mode 100644 index 0000000000..a3b11d2fd3 --- /dev/null +++ b/src/components/dialog/dialog-content/index.ts @@ -0,0 +1 @@ +export * from './dialog-content.js'; diff --git a/src/components/dialog/dialog-content/readme.md b/src/components/dialog/dialog-content/readme.md new file mode 100644 index 0000000000..a5edbba220 --- /dev/null +++ b/src/components/dialog/dialog-content/readme.md @@ -0,0 +1,15 @@ +Use the `sbb-dialog-content` in combination with the [sbb-dialog](/docs/components-sbb-dialog--docs) to display a content inside the dialog. + +```html + + Dialog content. + +``` + + + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------- | +| | Use the unnamed slot to provide a dialog content. | diff --git a/src/components/dialog/dialog-title/__snapshots__/dialog-title.spec.snap.js b/src/components/dialog/dialog-title/__snapshots__/dialog-title.spec.snap.js new file mode 100644 index 0000000000..03fb644d4f --- /dev/null +++ b/src/components/dialog/dialog-title/__snapshots__/dialog-title.spec.snap.js @@ -0,0 +1,91 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-dialog-title renders"] = +`
+ + + +
+`; +/* end snapshot sbb-dialog-title renders */ + +snapshots["sbb-dialog-title A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Title" + }, + { + "role": "button", + "name": "Close secondary window" + } + ] +} +

+`; +/* end snapshot sbb-dialog-title A11y tree Chrome */ + +snapshots["sbb-dialog-title A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Title" + }, + { + "role": "button", + "name": "Close secondary window" + } + ] +} +

+`; +/* end snapshot sbb-dialog-title A11y tree Firefox */ + +snapshots["sbb-dialog-title A11y tree Safari"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "heading", + "name": "Title" + }, + { + "role": "button", + "name": "Close secondary window" + } + ] +} +

+`; +/* end snapshot sbb-dialog-title A11y tree Safari */ + diff --git a/src/components/dialog/dialog-title/dialog-title.e2e.ts b/src/components/dialog/dialog-title/dialog-title.e2e.ts new file mode 100644 index 0000000000..0028d7f3de --- /dev/null +++ b/src/components/dialog/dialog-title/dialog-title.e2e.ts @@ -0,0 +1,25 @@ +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { EventSpy, waitForLitRender } from '../../core/testing/index.js'; + +import { SbbDialogTitleElement } from './dialog-title.js'; + +describe('sbb-dialog-title', () => { + let element: SbbDialogTitleElement; + + beforeEach(async () => { + element = await fixture(html`Title`); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbDialogTitleElement); + }); + + it('emits requestBackAction on back button click', async () => { + const myEventNameSpy = new EventSpy(SbbDialogTitleElement.events.backClick); + (element.shadowRoot!.querySelector('.sbb-dialog__back')! as HTMLElement).click(); + await waitForLitRender(element); + expect(myEventNameSpy.count).to.be.equal(1); + }); +}); diff --git a/src/components/dialog/dialog-title/dialog-title.scss b/src/components/dialog/dialog-title/dialog-title.scss new file mode 100644 index 0000000000..af5764bacb --- /dev/null +++ b/src/components/dialog/dialog-title/dialog-title.scss @@ -0,0 +1,59 @@ +@use '../../core/styles' as sbb; + +:host { + --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s) 0; + + display: contents; +} + +:host([data-overflows]) { + --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s); +} + +.sbb-title { + flex: 1; + overflow: hidden; + align-self: center; + + // Overwrite sbb-title default margin + margin: 0; +} + +.sbb-dialog__header { + display: flex; + gap: var(--sbb-spacing-fixed-6x); + align-items: start; + justify-content: space-between; + padding-inline: var(--sbb-dialog-padding-inline); + padding-block: var(--sbb-dialog-header-padding-block); + background-color: var(--sbb-dialog-background-color); + border-block-end: var(--sbb-dialog-title-border); + z-index: var(--sbb-dialog-z-index, var(--sbb-overlay-z-index)); + + // Apply show/hide animation unless it has a visible focus within. + :host(:not([data-has-visible-focus-within])) & { + transform: translateY(var(--sbb-dialog-header-margin-block-start)); + transition: { + property: box-shadow, transform; + duration: var(--sbb-dialog-animation-duration); + timing-function: var(--sbb-dialog-animation-easing); + } + } + + :host([data-overflows][data-has-visible-focus-within]) &, + :host([data-overflows]:not([negative], [data-hide-header])) & { + @include sbb.shadow-level-9-soft; + + @include sbb.if-forced-colors { + --sbb-dialog-title-border: var(--sbb-border-width-1x) solid CanvasText; + } + } + + @include sbb.mq($from: medium) { + border-radius: var(--sbb-dialog-border-radius) var(--sbb-dialog-border-radius) 0 0; + } +} + +.sbb-dialog__close { + margin-inline-start: auto; +} diff --git a/src/components/dialog/dialog-title/dialog-title.spec.ts b/src/components/dialog/dialog-title/dialog-title.spec.ts new file mode 100644 index 0000000000..d62478a02b --- /dev/null +++ b/src/components/dialog/dialog-title/dialog-title.spec.ts @@ -0,0 +1,22 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private/index.js'; +import './dialog-title.js'; + +describe('sbb-dialog-title', () => { + it('renders', async () => { + const root = await fixture(html`Title`); + + expect(root).dom.to.be.equal(` + Title + `); + + await expect(root).shadowDom.to.equalSnapshot(); + }); + + testA11yTreeSnapshot(html`Title`); +}); diff --git a/src/components/dialog/dialog-title/dialog-title.stories.ts b/src/components/dialog/dialog-title/dialog-title.stories.ts new file mode 100644 index 0000000000..4bfccfd387 --- /dev/null +++ b/src/components/dialog/dialog-title/dialog-title.stories.ts @@ -0,0 +1,100 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; +import { breakpoints } from '../../core/dom/index.js'; + +import { SbbDialogTitleElement } from './dialog-title.js'; +import readme from './readme.md?raw'; + +const level: InputType = { + control: { + type: 'inline-radio', + }, + options: [1, 2, 3, 4, 5, 6], +}; + +const backButton: InputType = { + control: { + type: 'boolean', + }, +}; + +const hideOnScroll: InputType = { + control: { + type: 'select', + }, + options: breakpoints, +}; + +const accessibilityCloseLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const accessibilityBackLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const defaultArgTypes: ArgTypes = { + level, + 'back-button': backButton, + 'hide-on-scroll': hideOnScroll, + 'accessibility-close-label': accessibilityCloseLabel, + 'accessibility-back-label': accessibilityBackLabel, +}; + +const defaultArgs: Args = { + 'back-button': true, + 'hide-on-scroll': hideOnScroll.options[0], + 'accessibility-close-label': 'Close dialog', + 'accessibility-back-label': 'Go back', +}; + +const Template = (args: Args): TemplateResult => + html`Dialog title`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const NoBackButton: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'back-button': false, 'accessibility-back-label': undefined }, +}; + +const meta: Meta = { + decorators: [ + (story) => html`
${story()}
`, + withActions as Decorator, + ], + parameters: { + actions: { + handles: [SbbDialogTitleElement.events.backClick], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-dialog/sbb-dialog-title', +}; + +export default meta; diff --git a/src/components/dialog/dialog-title/dialog-title.ts b/src/components/dialog/dialog-title/dialog-title.ts new file mode 100644 index 0000000000..8d31659839 --- /dev/null +++ b/src/components/dialog/dialog-title/dialog-title.ts @@ -0,0 +1,130 @@ +import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; +import { nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +import { SbbFocusVisibleWithinController } from '../../core/a11y/index.js'; +import { SbbLanguageController } from '../../core/controllers/index.js'; +import type { Breakpoint } from '../../core/dom/index.js'; +import { EventEmitter } from '../../core/eventing/index.js'; +import { i18nCloseDialog, i18nGoBack } from '../../core/i18n/index.js'; +import { SbbTitleElement } from '../../title/index.js'; + +import style from './dialog-title.scss?lit&inline'; + +import '../../button/secondary-button/index.js'; +import '../../button/transparent-button/index.js'; + +/** + * It displays a title inside a dialog header. + * + * @event {CustomEvent} requestBackAction - Emits whenever the back button is clicked. + * @cssprop --sbb-title-margin-block-start - This property is inherited from `SbbTitleElement` + * and is not relevant to dialog title margin customization. + * @cssprop --sbb-title-margin-block-end - This property is inherited from `SbbTitleElement` + * and is not relevant to dialog title margin customization. + */ +@customElement('sbb-dialog-title') +export class SbbDialogTitleElement extends SbbTitleElement { + public static override styles: CSSResultGroup = [SbbTitleElement.styles, style]; + public static readonly events: Record = { + backClick: 'requestBackAction', + } as const; + + /** + * Whether a back button is displayed next to the title. + */ + @property({ attribute: 'back-button', type: Boolean }) public backButton = false; + + /** + * This will be forwarded as aria-label to the close button element. + */ + @property({ attribute: 'accessibility-close-label' }) public accessibilityCloseLabel: + | string + | undefined; + + /** + * This will be forwarded as aria-label to the back button element. + */ + @property({ attribute: 'accessibility-back-label' }) public accessibilityBackLabel: + | string + | undefined; + + /** + * Whether to hide the title up to a certain breakpoint. + */ + @property({ attribute: 'hide-on-scroll' }) + public set hideOnScroll(value: '' | Breakpoint | boolean) { + this._hideOnScroll = value === '' ? true : value; + } + public get hideOnScroll(): Breakpoint | boolean { + return this._hideOnScroll; + } + private _hideOnScroll: Breakpoint | boolean = false; + + private _backClick: EventEmitter = new EventEmitter( + this, + SbbDialogTitleElement.events.backClick, + ); + private _language = new SbbLanguageController(this); + + public constructor() { + super(); + this.level = '2'; + this.visualLevel = '3'; + } + + public override connectedCallback(): void { + super.connectedCallback(); + new SbbFocusVisibleWithinController(this); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('backButton') || changedProperties.has('accessibilityBackLabel')) { + this.backButton = !this.backButton && !!this.accessibilityBackLabel ? true : this.backButton; + } + } + + protected override render(): TemplateResult { + const TAG_NAME = this.negative ? 'sbb-transparent-button' : 'sbb-secondary-button'; + + /* eslint-disable lit/binding-positions */ + const closeButton = html` + <${unsafeStatic(TAG_NAME)} + class="sbb-dialog__close" + aria-label=${this.accessibilityCloseLabel || i18nCloseDialog[this._language.current]} + ?negative=${this.negative} + size="m" + type="button" + icon-name="cross-small" + sbb-dialog-close + > + `; + + const backButton = html` + <${unsafeStatic(TAG_NAME)} + class="sbb-dialog__back" + aria-label=${this.accessibilityBackLabel || i18nGoBack[this._language.current]} + ?negative=${this.negative} + size="m" + type="button" + icon-name="chevron-small-left-small" + @click=${() => this._backClick.emit()} + > + `; + /* eslint-enable lit/binding-positions */ + + return html` +
+ ${this.backButton ? backButton : nothing} ${super.render()} ${closeButton} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-dialog-title': SbbDialogTitleElement; + } +} diff --git a/src/components/dialog/dialog-title/index.ts b/src/components/dialog/dialog-title/index.ts new file mode 100644 index 0000000000..9522560630 --- /dev/null +++ b/src/components/dialog/dialog-title/index.ts @@ -0,0 +1 @@ +export * from './dialog-title.js'; diff --git a/src/components/dialog/dialog-title/readme.md b/src/components/dialog/dialog-title/readme.md new file mode 100644 index 0000000000..d4997773e8 --- /dev/null +++ b/src/components/dialog/dialog-title/readme.md @@ -0,0 +1,73 @@ +The `sbb-dialog-title` component extends the [sbb-title](/docs/components-sbb-title--docs) component. Use it in combination with the [sbb-dialog](/docs/components-sbb-dialog--docs) to display a header in the dialog with a title, a close button and an optional back button. + +```html + + + A describing title of the dialog + + +``` + +## States + +The title can have a `negative` state which is automatically synchronized with the negative state of the dialog. + +In addition, the title can be hidden when scrolling down the content, to provide more space for reading the content itself; this can be done thanks to the `hide-on-scroll` property, which can determine whether to hide the title and up to which breakpoint. + +```html + + A describing title of the dialog + +``` + +## Interactions + +A close button is always displayed and can be used to close the dialog. Optionally, a back button can be shown with the property `back-button` (default is `false`). Note that setting an `accessibilityBackLabel` will also display a back button. + +```html + + A describing title of the dialog + +``` + +## Events + +If a back button is displayed it emits a `requestBackAction` event on click. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------------- | --------------------------- | ------- | ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backButton` | `back-button` | public | `boolean` | `false` | Whether a back button is displayed next to the title. | +| `accessibilityCloseLabel` | `accessibility-close-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the close button element. | +| `accessibilityBackLabel` | `accessibility-back-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the back button element. | +| `hideOnScroll` | `hide-on-scroll` | public | `Breakpoint \| boolean` | `false` | Whether to hide the title up to a certain breakpoint. | +| `level` | `level` | public | `SbbTitleLevel` | `'2'` | Title level | +| `visualLevel` | `visual-level` | public | `SbbTitleLevel \| undefined` | `'3'` | Visual level for the title. Optional, if not set, the value of level will be used. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `visuallyHidden` | `visually-hidden` | public | `boolean \| undefined` | | Sometimes we need a title in the markup to present a proper hierarchy to the screen readers while we do not want to let that title appear visually. In this case we set visuallyHidden to true | + +## Events + +| Name | Type | Description | Inherited From | +| ------------------- | ------------------- | ------------------------------------------ | -------------- | +| `requestBackAction` | `CustomEvent` | Emits whenever the back button is clicked. | | + +## CSS Properties + +| Name | Default | Description | +| -------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `--sbb-title-margin-block-start` | `var(--sbb-spacing-responsive-m)` | This property is inherited from `SbbTitleElement` and is not relevant to dialog title margin customization. | +| `--sbb-title-margin-block-end` | `var(--sbb-spacing-responsive-s)` | This property is inherited from `SbbTitleElement` and is not relevant to dialog title margin customization. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------ | +| | Use the unnamed slot to display the title. | diff --git a/src/components/dialog/dialog.spec.ts b/src/components/dialog/dialog.spec.ts deleted file mode 100644 index 181df3ac52..0000000000 --- a/src/components/dialog/dialog.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { html } from 'lit/static-html.js'; - -import { fixture, testA11yTreeSnapshot } from '../core/testing/private/index.js'; - -import './dialog.js'; - -describe(`sbb-dialog`, () => { - it('renders', async () => { - const root = await fixture(html``); - - expect(root).dom.to.be.equal(``); - - await expect(root).shadowDom.to.be.equalSnapshot(); - }); - - testA11yTreeSnapshot(html``); -}); diff --git a/src/components/dialog/dialog.stories.ts b/src/components/dialog/dialog.stories.ts deleted file mode 100644 index 70f66ba68a..0000000000 --- a/src/components/dialog/dialog.stories.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { withActions } from '@storybook/addon-actions/decorator'; -import { userEvent, within } from '@storybook/test'; -import type { InputType } from '@storybook/types'; -import type { - Meta, - StoryObj, - ArgTypes, - Args, - Decorator, - StoryContext, -} from '@storybook/web-components'; -import isChromatic from 'chromatic/isChromatic'; -import type { TemplateResult } from 'lit'; -import { html } from 'lit'; -import { styleMap } from 'lit/directives/style-map.js'; - -import { sbbSpread } from '../../storybook/helpers/spread.js'; -import { waitForComponentsReady } from '../../storybook/testing/wait-for-components-ready.js'; -import { waitForStablePosition } from '../../storybook/testing/wait-for-stable-position.js'; -import sampleImages from '../core/images.js'; - -import { SbbDialogElement } from './dialog.js'; -import readme from './readme.md?raw'; - -import '../button/secondary-button/index.js'; -import '../button/button/index.js'; -import '../link/block-link/index.js'; -import '../title/index.js'; -import '../form-field/index.js'; -import '../image/index.js'; -import '../action-group/index.js'; - -// Story interaction executed after the story renders -const playStory = async ({ canvasElement }: StoryContext): Promise => { - const canvas = within(canvasElement); - - await waitForComponentsReady(() => - canvas.getByTestId('dialog').shadowRoot!.querySelector('.sbb-dialog'), - ); - - await waitForStablePosition(() => canvas.getByTestId('dialog-trigger')); - - const button = canvas.getByTestId('dialog-trigger'); - await userEvent.click(button); -}; - -const titleContent: InputType = { - control: { - type: 'text', - }, -}; - -const titleLevel: InputType = { - control: { - type: 'inline-radio', - }, - options: [1, 2, 3, 4, 5, 6], -}; - -const titleBackButton: InputType = { - control: { - type: 'boolean', - }, -}; - -const negative: InputType = { - control: { - type: 'boolean', - }, -}; - -const accessibilityLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const accessibilityCloseLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const accessibilityBackLabel: InputType = { - control: { - type: 'text', - }, - table: { - category: 'Accessibility', - }, -}; - -const disableAnimation: InputType = { - control: { - type: 'boolean', - }, -}; - -const backdropAction: InputType = { - control: { - type: 'select', - }, - options: ['close', 'none'], -}; - -const basicArgTypes: ArgTypes = { - 'title-content': titleContent, - 'title-level': titleLevel, - 'title-back-button': titleBackButton, - negative, - 'accessibility-label': accessibilityLabel, - 'accessibility-close-label': accessibilityCloseLabel, - 'accessibility-back-label': accessibilityBackLabel, - 'disable-animation': disableAnimation, - 'backdrop-action': backdropAction, -}; - -const basicArgs: Args = { - 'title-content': 'A describing title of the dialog', - 'title-level': undefined, - 'title-back-button': true, - negative: false, - 'accessibility-label': undefined, - 'accessibility-close-label': undefined, - 'accessibility-back-label': undefined, - 'disable-animation': isChromatic(), - 'backdrop-action': backdropAction.options[0], -}; - -const openDialog = (_event: PointerEvent, id: string): void => { - const dialog = document.getElementById(id) as SbbDialogElement; - dialog.open(); -}; - -const triggerButton = (dialogId: string): TemplateResult => html` - openDialog(event, dialogId)} - > - Open dialog - -`; - -const actionGroup = (negative: boolean): TemplateResult => html` - - - Link - - Cancel - Confirm - -`; - -const codeStyle: Args = { - padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)', - borderRadius: 'var(--sbb-border-radius-4x)', - backgroundColor: 'var(--sbb-color-smoke-alpha-20)', -}; - -const formDetailsStyle: Args = { - marginTop: 'var(--sbb-spacing-fixed-4x)', - padding: 'var(--sbb-spacing-fixed-4x)', - borderRadius: 'var(--sbb-border-radius-8x)', - backgroundColor: 'var(--sbb-color-milk)', -}; - -const formStyle: Args = { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - gap: 'var(--sbb-spacing-fixed-4x)', -}; - -const DefaultTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-1')} - -

Dialog content

- ${actionGroup(args.negative)} -
-`; - -const SlottedTitleTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-2')} - - - - The Catcher in the Rye - -

- “What really knocks me out is a book that, when you're all done reading it, you wish the - author that wrote it was a terrific friend of yours and you could call him up on the phone - whenever you felt like it. That doesn't happen much, though.” ― J.D. Salinger, The Catcher in - the Rye -

- ${actionGroup(args.negative)} -
-`; - -const LongContentTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-3')} - - Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his face - like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo saw that - Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be clad in - elven-mail, and a star shone on his breast. They spoke together, and then suddenly it seemed to - Frodo that Arwen turned towards him, and the light of her eyes fell on him from afar and pierced - his heart. - - He stood still enchanted, while the sweet syllables of the elvish song fell like clear jewels of - blended word and melody. 'It is a song to Elbereth,'' said Bilbo. 'They will sing that, and - other songs of the Blessed Realm, many times tonight. Come on!’ —J.R.R. Tolkien, The Lord of the - Rings: The Fellowship of the Ring, “Many Meetings” ${actionGroup(args.negative)} - -`; - -const FormTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-4')} -
-
-
Your message: Hello 👋
-
Your favorite animal: Red Panda
-
-
- { - if (event.detail) { - document.getElementById('returned-value-message')!.innerHTML = - `${event.detail.returnValue.message?.value}`; - document.getElementById('returned-value-animal')!.innerHTML = - `${event.detail.returnValue.animal?.value}`; - } - }} - ${sbbSpread(args)} - > -
- Submit the form below to close the dialog box using the - close(result?: any, target?: HTMLElement) - method and returning the form values to update the details. -
-
e.preventDefault()}> - - - - - - - - - Update details -
-
-`; - -const NoFooterTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-5')} - -

- “What really knocks me out is a book that, when you're all done reading it, you wish the - author that wrote it was a terrific friend of yours and you could call him up on the phone - whenever you felt like it. That doesn't happen much, though.” ― J.D. Salinger, The Catcher in - the Rye -

-
-`; - -const FullScreenTemplate = (args: Args): TemplateResult => html` - ${triggerButton('my-dialog-6')} - - - Many Meetings - - Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his face - like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo saw that - Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be clad in - elven-mail, and a star shone on his breast. They spoke together, and then suddenly it seemed to - Frodo that Arwen turned towards him, and the light of her eyes fell on him from afar and pierced - his heart. - - He stood still enchanted, while the sweet syllables of the elvish song fell like clear jewels of - blended word and melody. 'It is a song to Elbereth,'' said Bilbo. 'They will sing that, and - other songs of the Blessed Realm, many times tonight. Come on!’ —J.R.R. Tolkien, The Lord of the - Rings: The Fellowship of the Ring, “Many Meetings” ${actionGroup(args.negative)} - -`; - -export const Default: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: basicArgs, - play: isChromatic() ? playStory : undefined, -}; - -export const Negative: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { - ...basicArgs, - negative: true, - }, - play: isChromatic() ? playStory : undefined, -}; - -export const AllowBackdropClick: StoryObj = { - render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, 'backdrop-action': backdropAction.options[1] }, - play: isChromatic() ? playStory : undefined, -}; - -export const SlottedTitle: StoryObj = { - render: SlottedTitleTemplate, - argTypes: basicArgTypes, - args: { - ...basicArgs, - 'title-content': undefined, - 'title-back-button': false, - }, - play: isChromatic() ? playStory : undefined, -}; - -export const LongContent: StoryObj = { - render: LongContentTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() ? playStory : undefined, -}; - -export const Form: StoryObj = { - render: FormTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() ? playStory : undefined, -}; - -export const NoFooter: StoryObj = { - render: NoFooterTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, - play: isChromatic() ? playStory : undefined, -}; - -export const FullScreen: StoryObj = { - render: FullScreenTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, 'title-content': undefined }, - play: isChromatic() ? playStory : undefined, -}; - -const meta: Meta = { - decorators: [ - (story) => html` -
- ${story()} -
- `, - withActions as Decorator, - ], - parameters: { - chromatic: { disableSnapshot: false }, - actions: { - handles: [ - SbbDialogElement.events.willOpen, - SbbDialogElement.events.didOpen, - SbbDialogElement.events.willClose, - SbbDialogElement.events.didClose, - SbbDialogElement.events.backClick, - ], - }, - backgrounds: { - disable: true, - }, - docs: { - story: { inline: false, iframeHeight: '600px' }, - extractComponentDescription: () => readme, - }, - layout: 'fullscreen', - }, - title: 'components/sbb-dialog', -}; - -export default meta; diff --git a/src/components/dialog/dialog/__snapshots__/dialog.spec.snap.js b/src/components/dialog/dialog/__snapshots__/dialog.spec.snap.js new file mode 100644 index 0000000000..82ba9ac94a --- /dev/null +++ b/src/components/dialog/dialog/__snapshots__/dialog.spec.snap.js @@ -0,0 +1,126 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-dialog renders an open dialog Dom"] = +` + + Title + + + Content + + +`; +/* end snapshot sbb-dialog renders an open dialog Dom */ + +snapshots["sbb-dialog renders an open dialog ShadowDom"] = +`
+
+
+ + +
+
+
+ + +`; +/* end snapshot sbb-dialog renders an open dialog ShadowDom */ + +snapshots["sbb-dialog renders an open dialog A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Title" + }, + { + "role": "button", + "name": "Close secondary window", + "focused": true + }, + { + "role": "text", + "name": "Content" + }, + { + "role": "text", + "name": "Dialog, Title " + } + ] +} +

+`; +/* end snapshot sbb-dialog renders an open dialog A11y tree Chrome */ + +snapshots["sbb-dialog renders an open dialog A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Title" + }, + { + "role": "button", + "name": "Close secondary window", + "focused": true + }, + { + "role": "text leaf", + "name": "Content" + }, + { + "role": "text leaf", + "name": "Dialog, Title " + } + ] +} +

+`; +/* end snapshot sbb-dialog renders an open dialog A11y tree Firefox */ + +snapshots["sbb-dialog renders an open dialog A11y tree Safari"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "heading", + "name": "Title" + }, + { + "role": "text", + "name": "Content" + }, + { + "role": "button", + "name": "Close secondary window", + "focused": true + }, + { + "role": "text", + "name": "Dialog, Title " + } + ] +} +

+`; +/* end snapshot sbb-dialog renders an open dialog A11y tree Safari */ + diff --git a/src/components/dialog/dialog.e2e.ts b/src/components/dialog/dialog/dialog.e2e.ts similarity index 73% rename from src/components/dialog/dialog.e2e.ts rename to src/components/dialog/dialog/dialog.e2e.ts index b2e0cb1315..cdcbbdf683 100644 --- a/src/components/dialog/dialog.e2e.ts +++ b/src/components/dialog/dialog/dialog.e2e.ts @@ -1,13 +1,16 @@ -import { assert, expect } from '@open-wc/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; import { sendKeys, setViewport } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; -import { i18nDialog } from '../core/i18n/index.js'; -import { EventSpy, waitForCondition, waitForLitRender } from '../core/testing/index.js'; -import { fixture } from '../core/testing/private/index.js'; +import { i18nDialog } from '../../core/i18n/index.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing/index.js'; import { SbbDialogElement } from './dialog.js'; -import '../title/index.js'; +import '../../button/index.js'; +import '../../icon/index.js'; +import '../dialog-title/index.js'; +import '../dialog-content/index.js'; +import '../dialog-actions/index.js'; async function openDialog(element: SbbDialogElement): Promise { const willOpen = new EventSpy(SbbDialogElement.events.willOpen); @@ -27,20 +30,18 @@ async function openDialog(element: SbbDialogElement): Promise { expect(element).to.have.attribute('data-state', 'opened'); } -describe(`sbb-dialog with ${fixture.name}`, () => { +describe('sbb-dialog', () => { let element: SbbDialogElement, ariaLiveRef: HTMLElement; beforeEach(async () => { await setViewport({ width: 900, height: 600 }); - element = await fixture( - html` - - Dialog content. -
Action group
-
- `, - { modules: ['./dialog.ts'] }, - ); + element = await fixture(html` + + Title + Dialog content + Action group + + `); ariaLiveRef = element.shadowRoot!.querySelector('sbb-screen-reader-only')!; }); @@ -201,7 +202,9 @@ describe(`sbb-dialog with ${fixture.name}`, () => { }); it('closes the dialog on close button click', async () => { - const closeButton = element.shadowRoot!.querySelector('[sbb-dialog-close]') as HTMLElement; + const closeButton = element + .querySelector('sbb-dialog-title')! + .shadowRoot!.querySelector('[sbb-dialog-close]') as HTMLElement; const willClose = new EventSpy(SbbDialogElement.events.willClose); const didClose = new EventSpy(SbbDialogElement.events.didClose); @@ -244,45 +247,20 @@ describe(`sbb-dialog with ${fixture.name}`, () => { expect(element).to.have.attribute('data-state', 'closed'); }); - it('does not have the fullscreen attribute', async () => { - await openDialog(element); - - expect(element).not.to.have.attribute('data-fullscreen'); - }); - - it('renders in fullscreen mode if no title is provided', async () => { - element = await fixture( - html` - - Dialog content. -
Action group
-
- `, - { modules: ['./dialog.ts'] }, - ); - ariaLiveRef = element.shadowRoot!.querySelector('sbb-screen-reader-only')!; - - await openDialog(element); - - await waitForCondition(() => ariaLiveRef.textContent!.trim() === `${i18nDialog.en}`); - - expect(element).to.have.attribute('data-fullscreen'); - }); - it('closes stacked dialogs one by one on ESC key pressed', async () => { - element = await fixture( - html` - - Dialog content. -
Action group
-
- - - Stacked dialog. - - `, - { modules: ['./dialog.ts'] }, - ); + element = await fixture(html` + + Title + Dialog content + Action group + + + + Stacked title + Dialog content + Action group + + `); const willOpen = new EventSpy(SbbDialogElement.events.willOpen); const didOpen = new EventSpy(SbbDialogElement.events.didOpen); @@ -291,8 +269,7 @@ describe(`sbb-dialog with ${fixture.name}`, () => { await openDialog(element); - const stackedDialog = - element.parentElement!.querySelector('#stacked-dialog')!; + const stackedDialog = document.querySelector('#stacked-dialog') as SbbDialogElement; stackedDialog.open(); await waitForLitRender(element); @@ -344,19 +321,17 @@ describe(`sbb-dialog with ${fixture.name}`, () => { it('does not close the dialog on other overlay click', async () => { await setViewport({ width: 900, height: 600 }); - element = await fixture( - html` - - Dialog content. -
Action group
- - Dialog content. -
Action group
-
+ element = await fixture(html` + + Title + Dialog content + + + Inner Dialog title + Dialog content - `, - { modules: ['./dialog.ts'] }, - ); + + `); const willOpen = new EventSpy(SbbDialogElement.events.willOpen); const didOpen = new EventSpy(SbbDialogElement.events.didOpen); const willClose = new EventSpy(SbbDialogElement.events.willClose); @@ -429,3 +404,62 @@ describe(`sbb-dialog with ${fixture.name}`, () => { expect(ariaLiveRef.textContent!.trim()).to.be.equal(`${i18nDialog.en}, Special Dialog`); }); }); + +describe('sbb-dialog with long content', () => { + let element: SbbDialogElement; + + beforeEach(async () => { + await setViewport({ width: 900, height: 300 }); + element = await fixture(html` + + Title + + Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his + face like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo + saw that Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be + clad in elven-mail, and a star shone on his breast. They spoke together, and then suddenly + it seemed to Frodo that Arwen turned towards him, and the light of her eyes fell on him + from afar and pierced his heart. He stood still enchanted, while the sweet syllables of + the elvish song fell like clear jewels of blended word and melody. 'It is a song to + Elbereth,'' said Bilbo. 'They will sing that, and other songs of the Blessed Realm, many + times tonight. Come on!’ —J.R.R. Tolkien, The Lord of the Rings: The Fellowship of the + Ring, “Many Meetings” J.R.R. Tolkien, the mastermind behind Middle-earth's enchanting + world, was born on January 3, 1892. With "The Hobbit" and "The Lord of the Rings", he + pioneered fantasy literature. Tolkien's linguistic brilliance and mythic passion converge + in a literary legacy that continues to transport readers to magical realms. + + Action group + + `); + }); + + it('renders', () => { + assert.instanceOf(element, SbbDialogElement); + }); + + it('sets the data-overflows attribute', async () => { + await openDialog(element); + + expect(element).to.have.attribute('data-state', 'opened'); + expect(element).to.have.attribute('data-overflows', ''); + }); + + it('shows/hides the dialog header on scroll', async () => { + await openDialog(element); + expect(element).not.to.have.attribute('data-hide-header'); + + const content = element.querySelector('sbb-dialog-content')!.shadowRoot!.firstElementChild!; + + // Scroll down. + content.scrollTo(0, 50); + await waitForCondition(() => element.hasAttribute('data-hide-header')); + + expect(element).to.have.attribute('data-hide-header'); + + // Scroll up. + content.scrollTo(0, 0); + await waitForCondition(() => !element.hasAttribute('data-hide-header')); + + expect(element).not.to.have.attribute('data-hide-header'); + }); +}); diff --git a/src/components/dialog/dialog.scss b/src/components/dialog/dialog/dialog.scss similarity index 62% rename from src/components/dialog/dialog.scss rename to src/components/dialog/dialog/dialog.scss index db56e3c232..1f276fa9d7 100644 --- a/src/components/dialog/dialog.scss +++ b/src/components/dialog/dialog/dialog.scss @@ -1,4 +1,4 @@ -@use '../core/styles' as sbb; +@use '../../core/styles' as sbb; // Default component properties, defined for :host. Properties which can not // travel the shadow boundary are defined through this mixin @@ -24,8 +24,9 @@ --sbb-dialog-backdrop-visibility: hidden; --sbb-dialog-backdrop-pointer-events: none; --sbb-dialog-backdrop-color: transparent; - --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s) 0; - --sbb-dialog-footer-border: var(--sbb-border-width-1x) solid var(--sbb-color-cloud); + --sbb-dialog-content-transition: transform var(--sbb-dialog-animation-duration) + var(--sbb-dialog-animation-easing); + --sbb-dialog-actions-border: var(--sbb-border-width-1x) solid var(--sbb-color-cloud); position: fixed; inset: var(--sbb-dialog-inset); @@ -59,10 +60,9 @@ } } -:host([data-fullscreen]) { - --sbb-dialog-backdrop-color: transparent; - --sbb-dialog-max-width: 100%; - --sbb-dialog-max-height: 100%; +:host([data-hide-header]) { + // Hide transition + --sbb-dialog-header-margin-block-start: calc(var(--sbb-dialog-header-height) * -1); } :host([negative]) { @@ -71,23 +71,15 @@ --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); --sbb-dialog-color: var(--sbb-color-white); --sbb-dialog-background-color: var(--sbb-color-midnight); - --sbb-dialog-footer-border: none; + --sbb-dialog-actions-border: none; } :host([disable-animation]) { --sbb-dialog-animation-duration: 0.1ms; } -:host([data-fullscreen]:not([negative])) { - --sbb-dialog-background-color: var(--sbb-color-milk); -} - -:host([data-overflows]:not([data-fullscreen])) { - --sbb-dialog-header-padding-block: var(--sbb-spacing-responsive-s); -} - -:host([data-overflows]:not([data-fullscreen], [negative])) { - --sbb-dialog-footer-border: none; +:host([data-overflows]:not([negative])) { + --sbb-dialog-actions-border: none; } :host(:not([data-state='closed'])) { @@ -132,10 +124,6 @@ color: var(--sbb-dialog-color); background-color: var(--sbb-dialog-background-color); - :host([data-fullscreen]) & { - border-radius: 0; - } - :host([data-state]:not([data-state='closed'])) & { display: block; @@ -158,10 +146,7 @@ @include sbb.mq($from: medium) { border-radius: var(--sbb-dialog-border-radius); overflow: hidden; - - :host(:not([data-fullscreen])) & { - height: fit-content; - } + height: fit-content; } } @@ -183,86 +168,6 @@ } } -.sbb-dialog__header { - display: flex; - pointer-events: none; - gap: var(--sbb-spacing-fixed-6x); - align-items: start; - justify-content: space-between; - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-dialog-header-padding-block); - background-color: var(--sbb-dialog-background-color); - z-index: var(--sbb-dialog-z-index, var(--sbb-overlay-default-z-index)); - - * { - pointer-events: all; - } - - :host([data-fullscreen]) & { - position: fixed; - width: var(--sbb-dialog-width); - background-color: transparent; - padding-inline: var(--sbb-spacing-responsive-xs); - padding-block-start: var(--sbb-spacing-responsive-xs); - } - - @include sbb.mq($from: medium) { - border-radius: var(--sbb-dialog-border-radius) var(--sbb-dialog-border-radius) 0 0; - } -} - -.sbb-dialog__title { - flex: 1; - overflow: hidden; - align-self: center; - - // Overwrite sbb-title default margin - margin: 0; - - :host(:not([data-slot-names~='title'], [title-content])) & { - display: none; - } -} - -.sbb-dialog__close { - margin-inline-start: auto; -} - -.sbb-dialog__content { - @include sbb.scrollbar-rules; - - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-dialog-padding-block); - overflow: auto; - - :host([data-fullscreen]) & { - padding-block-start: var(--sbb-spacing-fixed-20x); - padding-inline: var(--sbb-layout-base-offset-responsive); - height: 100vh; - } -} - -.sbb-dialog__footer { - padding-inline: var(--sbb-dialog-padding-inline); - padding-block: var(--sbb-spacing-responsive-s); - margin-block-start: auto; - background-color: var(--sbb-dialog-background-color); - border-block-start: var(--sbb-dialog-footer-border); - - :host(:not([data-slot-names~='title'], [title-content])) &, - :host(:not([data-slot-names~='action-group'])) & { - display: none; - } -} - -// stylelint-disable selector-not-notation -:is(.sbb-dialog__header, .sbb-dialog__footer) { - :host([data-overflows]:not([data-fullscreen], [negative])) & { - @include sbb.shadow-level-9-soft; - } -} -// stylelint-enable selector-not-notation - // It is necessary to use animations with keyframes instead of transitions in order not to alter // the default `display: block` of the modal otherwise it causes several problems, // especially for accessibility. diff --git a/src/components/dialog/dialog/dialog.spec.ts b/src/components/dialog/dialog/dialog.spec.ts new file mode 100644 index 0000000000..eb128b2445 --- /dev/null +++ b/src/components/dialog/dialog/dialog.spec.ts @@ -0,0 +1,33 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../../core/testing/index.js'; +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private/index.js'; + +import type { SbbDialogElement } from './dialog.js'; +import './dialog.js'; +import '../dialog-title/index.js'; +import '../dialog-content/index.js'; + +describe(`sbb-dialog`, () => { + describe('renders an open dialog', async () => { + let root: SbbDialogElement; + beforeEach(async () => { + root = await fixture( + html` + Title + Content + `, + ); + root.open(); + await waitForLitRender(root); + }); + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + testA11yTreeSnapshot(); + }); +}); diff --git a/src/components/dialog/dialog/dialog.stories.ts b/src/components/dialog/dialog/dialog.stories.ts new file mode 100644 index 0000000000..68663e9dda --- /dev/null +++ b/src/components/dialog/dialog/dialog.stories.ts @@ -0,0 +1,504 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import { userEvent, within } from '@storybook/test'; +import type { InputType } from '@storybook/types'; +import type { + Meta, + StoryObj, + ArgTypes, + Args, + Decorator, + StoryContext, +} from '@storybook/web-components'; +import isChromatic from 'chromatic/isChromatic'; +import type { TemplateResult } from 'lit'; +import { html, nothing } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; +import { waitForComponentsReady } from '../../../storybook/testing/wait-for-components-ready.js'; +import { waitForStablePosition } from '../../../storybook/testing/wait-for-stable-position.js'; +import { breakpoints } from '../../core/dom/breakpoint.js'; +import sampleImages from '../../core/images.js'; +import type { SbbTitleLevel } from '../../title/index.js'; +import { SbbDialogTitleElement } from '../dialog-title/index.js'; + +import { SbbDialogElement } from './dialog.js'; +import readme from './readme.md?raw'; + +import '../../button/index.js'; +import '../../link/index.js'; +import '../../form-field/index.js'; +import '../../image/index.js'; +import '../dialog-content/index.js'; +import '../dialog-actions/index.js'; + +// Story interaction executed after the story renders +const playStory = async ({ canvasElement }: StoryContext): Promise => { + const canvas = within(canvasElement); + + await waitForComponentsReady(() => + canvas.getByTestId('dialog').shadowRoot!.querySelector('.sbb-dialog'), + ); + + await waitForStablePosition(() => canvas.getByTestId('dialog-trigger')); + + const button = canvas.getByTestId('dialog-trigger'); + await userEvent.click(button); +}; + +const level: InputType = { + control: { + type: 'inline-radio', + }, + options: [1, 2, 3, 4, 5, 6], + table: { + category: 'Title', + }, +}; + +const backButton: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Title', + }, +}; + +const hideOnScroll: InputType = { + control: { + type: 'select', + }, + options: ['Deactivate hide on scroll', ...breakpoints], + table: { + category: 'Title', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const accessibilityLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const accessibilityCloseLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const accessibilityBackLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Accessibility', + }, +}; + +const disableAnimation: InputType = { + control: { + type: 'boolean', + }, +}; + +const backdropAction: InputType = { + control: { + type: 'select', + }, + options: ['close', 'none'], +}; + +const basicArgTypes: ArgTypes = { + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + negative, + 'accessibility-label': accessibilityLabel, + 'disable-animation': disableAnimation, + 'backdrop-action': backdropAction, +}; + +const basicArgs: Args = { + level: level.options[1], + backButton: true, + hideOnScroll: hideOnScroll.options[0], + accessibilityCloseLabel: 'Close dialog', + accessibilityBackLabel: 'Go back', + negative: false, + 'accessibility-label': undefined, + 'disable-animation': isChromatic(), + 'backdrop-action': backdropAction.options[0], +}; + +const openDialog = (_event: PointerEvent, id: string): void => { + const dialog = document.getElementById(id) as SbbDialogElement; + dialog.open(); +}; + +const triggerButton = (dialogId: string, triggerId?: string): TemplateResult => html` + openDialog(event, dialogId)} + > + Open dialog + +`; + +const dialogActions = (negative: boolean): TemplateResult => html` + + + Link + + Cancel + Confirm + +`; + +const codeStyle: Args = { + padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)', + borderRadius: 'var(--sbb-border-radius-4x)', + backgroundColor: 'var(--sbb-color-smoke-alpha-20)', +}; + +const formDetailsStyle: Args = { + marginTop: 'var(--sbb-spacing-fixed-4x)', + padding: 'var(--sbb-spacing-fixed-4x)', + borderRadius: 'var(--sbb-border-radius-8x)', + backgroundColor: 'var(--sbb-color-milk)', +}; + +const formStyle: Args = { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: 'var(--sbb-spacing-fixed-4x)', +}; + +const textBlockStyle: Args = { + position: 'relative', + marginBlockStart: '1rem', + padding: '1rem', + backgroundColor: 'var(--sbb-color-milk)', + border: 'var(--sbb-border-width-1x) solid var(--sbb-color-cloud)', + borderRadius: 'var(--sbb-border-radius-4x)', +}; + +const dialogTitle = ( + level: SbbTitleLevel, + backButton: boolean, + hideOnScroll: any, + accessibilityCloseLabel: string, + accessibilityBackLabel: string, +): TemplateResult => html` + A describing title of the dialog +`; + +const textBlock = (): TemplateResult => html` +
+ J.R.R. Tolkien, the mastermind behind Middle-earth's enchanting world, was born on January 3, + 1892. With "The Hobbit" and "The Lord of the Rings", he pioneered fantasy literature. Tolkien's + linguistic brilliance and mythic passion converge in a literary legacy that continues to + transport readers to magical realms. +
+`; + +const DefaultTemplate = ({ + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + ...args +}: Args): TemplateResult => html` + ${triggerButton('my-dialog-1')} + + ${dialogTitle(level, backButton, hideOnScroll, accessibilityCloseLabel, accessibilityBackLabel)} + +

Dialog content

+
+ ${dialogActions(args.negative)} +
+`; + +const LongContentTemplate = ({ + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + ...args +}: Args): TemplateResult => html` + ${triggerButton('my-dialog-2')} + + ${dialogTitle(level, backButton, hideOnScroll, accessibilityCloseLabel, accessibilityBackLabel)} + + Frodo halted for a moment, looking back. Elrond was in his chair and the fire was on his face + like summer-light upon the trees. Near him sat the Lady Arwen. To his surprise Frodo saw that + Aragorn stood beside her; his dark cloak was thrown back, and he seemed to be clad in + elven-mail, and a star shone on his breast. They spoke together, and then suddenly it seemed + to Frodo that Arwen turned towards him, and the light of her eyes fell on him from afar and + pierced his heart. + + He stood still enchanted, while the sweet syllables of the elvish song fell like clear jewels + of blended word and melody. 'It is a song to Elbereth,'' said Bilbo. 'They will sing that, and + other songs of the Blessed Realm, many times tonight. Come on!’ —J.R.R. Tolkien, The Lord of + the Rings: The Fellowship of the Ring, “Many Meetings” ${textBlock()} + + ${dialogActions(args.negative)} + +`; + +const FormTemplate = ({ + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + ...args +}: Args): TemplateResult => html` + ${triggerButton('my-dialog-3')} +
+
+
Your message: Hello 👋
+
Your favorite animal: Red Panda
+
+
+ { + if (event.detail.returnValue) { + document.getElementById('returned-value-message')!.innerHTML = + `${event.detail.returnValue.message?.value}`; + document.getElementById('returned-value-animal')!.innerHTML = + `${event.detail.returnValue.animal?.value}`; + } + }} + ${sbbSpread(args)} + > + ${dialogTitle(level, backButton, hideOnScroll, accessibilityCloseLabel, accessibilityBackLabel)} + +
+ Submit the form below to close the dialog box using the + close(result?: any, target?: HTMLElement) + method and returning the form values to update the details. +
+
e.preventDefault()}> + + + + + + + + + Update details +
+
+
+`; + +const NoFooterTemplate = ({ + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + ...args +}: Args): TemplateResult => html` + ${triggerButton('my-dialog-4')} + + ${dialogTitle(level, backButton, hideOnScroll, accessibilityCloseLabel, accessibilityBackLabel)} + +

+ “What really knocks me out is a book that, when you're all done reading it, you wish the + author that wrote it was a terrific friend of yours and you could call him up on the phone + whenever you felt like it. That doesn't happen much, though.” ― J.D. Salinger, The Catcher + in the Rye +

+
+
+`; + +const NestedTemplate = ({ + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + ...args +}: Args): TemplateResult => html` + ${triggerButton('my-dialog-5')} + + ${dialogTitle(level, backButton, hideOnScroll, accessibilityCloseLabel, accessibilityBackLabel)} + Click the button to open a nested + dialog. ${triggerButton('my-dialog-6', 'nested-trigger-id')} + + ${dialogTitle( + level, + backButton, + hideOnScroll, + accessibilityCloseLabel, + accessibilityBackLabel, + )} + Nested dialog content. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum. + + +`; + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: basicArgs, + play: isChromatic() ? playStory : undefined, +}; + +export const Negative: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { + ...basicArgs, + negative: true, + }, + play: isChromatic() ? playStory : undefined, +}; + +export const AllowBackdropClick: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, 'backdrop-action': backdropAction.options[1] }, + play: isChromatic() ? playStory : undefined, +}; + +export const LongContent: StoryObj = { + render: LongContentTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() ? playStory : undefined, +}; + +export const HiddenTitle: StoryObj = { + render: LongContentTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, hideOnScroll: hideOnScroll.options[7] }, +}; + +export const Form: StoryObj = { + render: FormTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() ? playStory : undefined, +}; + +export const NoBackButton: StoryObj = { + render: DefaultTemplate, + argTypes: basicArgTypes, + args: { + ...basicArgs, + backButton: false, + accessibilityBackLabel: undefined, + }, + play: isChromatic() ? playStory : undefined, +}; + +export const NoFooter: StoryObj = { + render: NoFooterTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() ? playStory : undefined, +}; + +export const Nested: StoryObj = { + render: NestedTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, + play: isChromatic() ? playStory : undefined, +}; + +const meta: Meta = { + decorators: [ + (story) => html` +
+ ${story()} +
+ `, + withActions as Decorator, + ], + parameters: { + chromatic: { disableSnapshot: false }, + actions: { + handles: [ + SbbDialogElement.events.willOpen, + SbbDialogElement.events.didOpen, + SbbDialogElement.events.willClose, + SbbDialogElement.events.didClose, + SbbDialogTitleElement.events.backClick, + ], + }, + backgrounds: { + disable: true, + }, + docs: { + story: { inline: false, iframeHeight: '600px' }, + extractComponentDescription: () => readme, + }, + layout: 'fullscreen', + }, + title: 'components/sbb-dialog/sbb-dialog', +}; + +export default meta; diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog/dialog.ts similarity index 53% rename from src/components/dialog/dialog.ts rename to src/components/dialog/dialog/dialog.ts index ae37b089be..5f71c7940f 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog/dialog.ts @@ -1,45 +1,49 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; -import { LitElement, nothing } from 'lit'; +import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; +import { LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; -import { html, unsafeStatic } from 'lit/static-html.js'; - -import { IS_FOCUSABLE_QUERY, SbbFocusHandler, setModalityOnNextFocus } from '../core/a11y/index.js'; -import { SbbLanguageController, SbbSlotStateController } from '../core/controllers/index.js'; -import { hostContext, SbbScrollHandler } from '../core/dom/index.js'; -import { EventEmitter } from '../core/eventing/index.js'; -import { i18nCloseDialog, i18nDialog, i18nGoBack } from '../core/i18n/index.js'; -import type { SbbOpenedClosedState } from '../core/interfaces/index.js'; -import { SbbNegativeMixin } from '../core/mixins/index.js'; -import { AgnosticResizeObserver } from '../core/observers/index.js'; -import { applyInertMechanism, removeInertMechanism } from '../core/overlay/index.js'; -import type { SbbTitleLevel } from '../title/index.js'; +import { html } from 'lit/static-html.js'; + +import { + SbbFocusHandler, + getFirstFocusableElement, + setModalityOnNextFocus, +} from '../../core/a11y/index.js'; +import { SbbLanguageController } from '../../core/controllers/index.js'; +import { SbbScrollHandler, hostContext, isBreakpoint } from '../../core/dom/index.js'; +import { EventEmitter } from '../../core/eventing/index.js'; +import { i18nDialog } from '../../core/i18n/index.js'; +import type { SbbOpenedClosedState } from '../../core/interfaces/index.js'; +import { SbbNegativeMixin } from '../../core/mixins/index.js'; +import { AgnosticResizeObserver } from '../../core/observers/index.js'; +import { applyInertMechanism, removeInertMechanism } from '../../core/overlay/index.js'; +import type { SbbScreenReaderOnlyElement } from '../../screen-reader-only/index.js'; +import type { SbbDialogActionsElement } from '../dialog-actions/index.js'; +import type { SbbDialogTitleElement } from '../dialog-title/index.js'; import style from './dialog.scss?lit&inline'; -import '../button/secondary-button/index.js'; -import '../button/transparent-button/index.js'; -import '../screen-reader-only/index.js'; -import '../title/index.js'; +import '../../screen-reader-only/index.js'; // A global collection of existing dialogs const dialogRefs: SbbDialogElement[] = []; let nextId = 0; +export type SbbDialogCloseEventDetails = { + returnValue?: any; + closeTarget?: HTMLElement; +}; + /** * It displays an interactive overlay element. * - * @slot - Use the unnamed slot to add content to the `sbb-dialog`. - * @slot title - Use this slot to provide a title. - * @slot action-group - Use this slot to display a `sbb-action-group` in the footer. + * @slot - Use the unnamed slot to provide a `sbb-dialog-title`, `sbb-dialog-content` and an optional `sbb-dialog-actions`. * @event {CustomEvent} willOpen - Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. * @event {CustomEvent} didOpen - Emits whenever the `sbb-dialog` is opened. * @event {CustomEvent} willClose - Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. - * @event {CustomEvent} didClose - Emits whenever the `sbb-dialog` is closed. - * @event {CustomEvent} requestBackAction - Emits whenever the back button is clicked. - * @cssprop [--sbb-dialog-z-index=var(--sbb-overlay-default-z-index)] - To specify a custom stack order, + * @event {CustomEvent} didClose - Emits whenever the `sbb-dialog` is closed. + * @cssprop [--sbb-dialog-z-index=var(--sbb-overlay-z-index)] - To specify a custom stack order, * the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the - * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. + * component is set to `var(--sbb-overlay-z-index)` with a value of `1000`. */ @customElement('sbb-dialog') export class SbbDialogElement extends SbbNegativeMixin(LitElement) { @@ -49,24 +53,8 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { didOpen: 'didOpen', willClose: 'willClose', didClose: 'didClose', - backClick: 'requestBackAction', } as const; - /** - * Dialog title. - */ - @property({ attribute: 'title-content', reflect: true }) public titleContent?: string; - - /** - * Level of title, will be rendered as heading tag (e.g. h1). Defaults to level 1. - */ - @property({ attribute: 'title-level' }) public titleLevel: SbbTitleLevel = '1'; - - /** - * Whether a back button is displayed next to the title. - */ - @property({ attribute: 'title-back-button', type: Boolean }) public titleBackButton = false; - /** * Backdrop click action. */ @@ -77,20 +65,6 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { */ @property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined; - /** - * This will be forwarded as aria-label to the close button element. - */ - @property({ attribute: 'accessibility-close-label' }) public accessibilityCloseLabel: - | string - | undefined; - - /** - * This will be forwarded as aria-label to the back button element. - */ - @property({ attribute: 'accessibility-back-label' }) public accessibilityBackLabel: - | string - | undefined; - /** * Whether the animation is enabled. */ @@ -107,15 +81,14 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { return this.getAttribute('data-state') as SbbOpenedClosedState; } - private get _hasTitle(): boolean { - return !!this.titleContent || this._namedSlots.slots.has('title'); - } - + // We use a timeout as a workaround to the "ResizeObserver loop completed with undelivered notifications" error. + // For more details: + // - https://github.com/WICG/resize-observer/issues/38#issuecomment-422126006 + // - https://github.com/juggle/resize-observer/issues/103#issuecomment-1711148285 private _dialogContentResizeObserver = new AgnosticResizeObserver(() => - this._setOverflowAttribute(), + setTimeout(() => this._onContentResize()), ); - - private _ariaLiveRef!: HTMLElement; + private _ariaLiveRef!: SbbScreenReaderOnlyElement; private _ariaLiveRefToggle = false; /** Emits whenever the `sbb-dialog` starts the opening transition. */ @@ -128,43 +101,48 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { private _willClose: EventEmitter = new EventEmitter(this, SbbDialogElement.events.willClose); /** Emits whenever the `sbb-dialog` is closed. */ - private _didClose: EventEmitter = new EventEmitter(this, SbbDialogElement.events.didClose); - - /** Emits whenever the back button is clicked. */ - private _backClick: EventEmitter = new EventEmitter( + private _didClose: EventEmitter = new EventEmitter( this, - SbbDialogElement.events.backClick, + SbbDialogElement.events.didClose, ); - private _dialog!: HTMLDivElement; - private _dialogWrapperElement!: HTMLElement; - private _dialogContentElement!: HTMLElement; + private _dialogTitleElement: SbbDialogTitleElement | null = null; + private _dialogTitleHeight?: number; + private _dialogContentElement: HTMLElement | null = null; + private _dialogActionsElement: SbbDialogActionsElement | null = null; private _dialogCloseElement?: HTMLElement; private _dialogController!: AbortController; - private _windowEventsController!: AbortController; + private _openDialogController!: AbortController; private _focusHandler = new SbbFocusHandler(); private _scrollHandler = new SbbScrollHandler(); private _returnValue: any; private _isPointerDownEventOnDialog: boolean = false; + private _overflows: boolean = false; + private _lastScroll = 0; private _dialogId = `sbb-dialog-${nextId++}`; // Last element which had focus before the dialog was opened. private _lastFocusedElement?: HTMLElement; private _language = new SbbLanguageController(this); - private _namedSlots = new SbbSlotStateController(this, () => - this.toggleAttribute('data-fullscreen', !this._hasTitle), - ); /** * Opens the dialog element. */ public open(): void { - if (this._state !== 'closed' || !this._dialog) { + if (this._state !== 'closed') { return; } this._lastFocusedElement = document.activeElement as HTMLElement; + // Initialize dialog elements + this._dialogTitleElement = this.querySelector('sbb-dialog-title'); + this._dialogContentElement = this.querySelector('sbb-dialog-content')?.shadowRoot! + .firstElementChild as HTMLElement; + this._dialogActionsElement = this.querySelector('sbb-dialog-actions'); + + this._syncNegative(); + if (!this._willOpen.emit()) { return; } @@ -172,7 +150,7 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { // Add this dialog to the global collection dialogRefs.push(this as SbbDialogElement); - this._setOverflowAttribute(); + this._dialogContentResizeObserver.observe(this._dialogContentElement); // Disable scrolling for content below the dialog this._scrollHandler.disableScroll(); @@ -212,6 +190,42 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { } } + private _onContentScroll(): void { + if (!this._dialogContentElement) { + return; + } + + const hasVisibleHeader = this.dataset.hideHeader === undefined; + + // Check whether hiding the header would make the scrollbar disappear + // and prevent the hiding animation if so. + if ( + hasVisibleHeader && + this._dialogContentElement.clientHeight + this._dialogTitleHeight! >= + this._dialogContentElement.scrollHeight + ) { + return; + } + + const currentScroll = this._dialogContentElement.scrollTop; + if ( + Math.round(currentScroll + this._dialogContentElement.clientHeight) >= + this._dialogContentElement.scrollHeight + ) { + return; + } + // Check whether is scrolling down or up. + if (currentScroll > 0 && this._lastScroll < currentScroll) { + // Scrolling down + this._setHideHeaderDataAttribute(true); + } else { + // Scrolling up + this._setHideHeaderDataAttribute(false); + } + // `currentScroll` can be negative, e.g. on mobile; this is not allowed. + this._lastScroll = currentScroll <= 0 ? 0 : currentScroll; + } + public override connectedCallback(): void { super.connectedCallback(); this._state ||= 'closed'; @@ -231,10 +245,36 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { } } + protected override firstUpdated(_changedProperties: PropertyValues): void { + this._ariaLiveRef = + this.shadowRoot!.querySelector('sbb-screen-reader-only')!; + + // Synchronize the negative state before the first opening to avoid a possible color flash if it is negative. + this._dialogTitleElement = this.querySelector('sbb-dialog-title')!; + this._syncNegative(); + super.firstUpdated(_changedProperties); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('negative')) { + this._syncNegative(); + } + } + + private _syncNegative(): void { + if (this._dialogTitleElement) { + this._dialogTitleElement.negative = this.negative; + } + + if (this._dialogActionsElement) { + this._dialogActionsElement.toggleAttribute('data-negative', this.negative); + } + } + public override disconnectedCallback(): void { super.disconnectedCallback(); this._dialogController?.abort(); - this._windowEventsController?.abort(); + this._openDialogController?.abort(); this._focusHandler.disconnect(); this._dialogContentResizeObserver.disconnect(); this._removeInstanceFromGlobalCollection(); @@ -245,8 +285,8 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { dialogRefs.splice(dialogRefs.indexOf(this as SbbDialogElement), 1); } - private _attachWindowEvents(): void { - this._windowEventsController = new AbortController(); + private _attachOpenDialogEvents(): void { + this._openDialogController = new AbortController(); // Remove dialog label as soon as it is not needed anymore to prevent accessing it with browse mode. window.addEventListener( 'keydown', @@ -255,11 +295,19 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { await this._onKeydownEvent(event); }, { - signal: this._windowEventsController.signal, + signal: this._openDialogController.signal, }, ); window.addEventListener('click', () => this._removeAriaLiveRefContent(), { - signal: this._windowEventsController.signal, + signal: this._openDialogController.signal, + }); + // If the content overflows, show/hide the dialog header on scroll. + this._dialogContentElement?.addEventListener('scroll', () => this._onContentScroll(), { + passive: true, + signal: this._openDialogController.signal, + }); + window.addEventListener('resize', () => this._setHideHeaderDataAttribute(false), { + signal: this._openDialogController.signal, }); } @@ -294,16 +342,23 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { // Close the dialog on click of any element that has the 'sbb-dialog-close' attribute. private _closeOnSbbDialogCloseClick(event: Event): void { - const target = event.target as HTMLElement; - - if (target.hasAttribute('sbb-dialog-close') && !target.hasAttribute('disabled')) { - // Check if the target is a submission element within a form and return the form, if present - const closestForm = - target.getAttribute('type') === 'submit' - ? (hostContext('form', target) as HTMLFormElement) - : undefined; - this.close(closestForm, target); + const dialogCloseElement = event + .composedPath() + .filter((e): e is HTMLElement => e instanceof window.HTMLElement) + .find( + (target) => target.hasAttribute('sbb-dialog-close') && !target.hasAttribute('disabled'), + ); + + if (!dialogCloseElement) { + return; } + + // Check if the target is a submission element within a form and return the form, if present + const closestForm = + dialogCloseElement.getAttribute('type') === 'submit' + ? (hostContext('form', dialogCloseElement) as HTMLFormElement) + : undefined; + dialogRefs[dialogRefs.length - 1].close(closestForm, dialogCloseElement); } // Wait for dialog transition to complete. @@ -314,29 +369,29 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { this._state = 'opened'; this._didOpen.emit(); applyInertMechanism(this); + this._attachOpenDialogEvents(); this._setDialogFocus(); // Use timeout to read label after focused element setTimeout(() => this._setAriaLiveRefContent()); this._focusHandler.trap(this); - this._dialogContentResizeObserver.observe(this._dialogContentElement); - this._attachWindowEvents(); } else if (event.animationName === 'close' && this._state === 'closing') { + this._setHideHeaderDataAttribute(false); + this._dialogContentElement?.scrollTo(0, 0); this._state = 'closed'; - this._dialogWrapperElement.querySelector('.sbb-dialog__content')?.scrollTo(0, 0); removeInertMechanism(); setModalityOnNextFocus(this._lastFocusedElement); // Manually focus last focused element this._lastFocusedElement?.focus(); - this._didClose.emit({ - returnValue: this._returnValue, - closeTarget: this._dialogCloseElement, - }); - this._windowEventsController?.abort(); + this._openDialogController?.abort(); this._focusHandler.disconnect(); this._dialogContentResizeObserver.disconnect(); this._removeInstanceFromGlobalCollection(); // Enable scrolling for content below the dialog if no dialog is open !dialogRefs.length && this._scrollHandler.enableScroll(); + this._didClose.emit({ + returnValue: this._returnValue, + closeTarget: this._dialogCloseElement, + }); } } @@ -344,9 +399,7 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { this._ariaLiveRefToggle = !this._ariaLiveRefToggle; // Take accessibility label or current string in title section - const label = - this.accessibilityLabel || - (this.shadowRoot!.querySelector('.sbb-dialog__title') as HTMLElement)?.innerText.trim(); + const label = this.accessibilityLabel || this._dialogTitleElement?.innerText.trim(); // If the text content remains the same, on VoiceOver the aria-live region is not announced a second time. // In order to support reading on every opening, we toggle an invisible space. @@ -361,99 +414,61 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { // Set focus on the first focusable element. private _setDialogFocus(): void { - const firstFocusable = this.shadowRoot!.querySelector(IS_FOCUSABLE_QUERY) as HTMLElement; + const firstFocusable = getFirstFocusableElement( + Array.from(this.children).filter((e): e is HTMLElement => e instanceof window.HTMLElement), + ); setModalityOnNextFocus(firstFocusable); - firstFocusable.focus(); + firstFocusable?.focus(); } - private _setOverflowAttribute(): void { - this.toggleAttribute( - 'data-overflows', - this._dialogContentElement.scrollHeight > this._dialogContentElement.clientHeight, - ); + private _setDialogHeaderHeight(): void { + this._dialogTitleHeight = this._dialogTitleElement?.shadowRoot!.firstElementChild!.clientHeight; + this.style.setProperty('--sbb-dialog-header-height', `${this._dialogTitleHeight}px`); } - protected override render(): TemplateResult { - const TAG_NAME = this.negative ? 'sbb-transparent-button' : 'sbb-secondary-button'; - - /* eslint-disable lit/binding-positions */ - const closeButton = html` - <${unsafeStatic(TAG_NAME)} - class="sbb-dialog__close" - aria-label=${this.accessibilityCloseLabel || i18nCloseDialog[this._language.current]} - ?negative=${this.negative} - size="m" - type="button" - icon-name="cross-small" - sbb-dialog-close - > - `; + private _onContentResize(): void { + this._setDialogHeaderHeight(); + // Check whether the content overflows and set the `overflows` attribute. + this._overflows = this._dialogContentElement + ? this._dialogContentElement?.scrollHeight > this._dialogContentElement?.clientHeight + : false; + this._setOverflowsDataAttribute(); + } - const backButton = html` - <${unsafeStatic(TAG_NAME)} - class="sbb-dialog__back" - aria-label=${this.accessibilityBackLabel || i18nGoBack[this._language.current]} - ?negative=${this.negative} - size="m" - type="button" - icon-name="chevron-small-left-small" - @click=${() => this._backClick.emit()} - > - `; - /* eslint-enable lit/binding-positions */ - - const dialogHeader = html` -
- ${this.titleBackButton ? backButton : nothing} - - ${this.titleContent} - - ${closeButton} -
- `; + private _setHideHeaderDataAttribute(value: boolean): void { + const hideOnScroll = this._dialogTitleElement?.hideOnScroll ?? false; + const hideHeader = + typeof hideOnScroll === 'boolean' + ? hideOnScroll + : isBreakpoint('zero', hideOnScroll, { includeMaxBreakpoint: true }); + this.toggleAttribute('data-hide-header', !hideHeader ? false : value); + this._dialogTitleElement && + this._dialogTitleElement.toggleAttribute('data-hide-header', !hideHeader ? false : value); + } + + private _setOverflowsDataAttribute(): void { + this.toggleAttribute('data-overflows', this._overflows); + this._dialogTitleElement?.toggleAttribute('data-overflows', this._overflows); + this._dialogActionsElement?.toggleAttribute('data-overflows', this._overflows); + } + protected override render(): TemplateResult { return html`
this._onDialogAnimationEnd(event)} class="sbb-dialog" id=${this._dialogId} - ${ref((dialogRef?: Element) => (this._dialog = dialogRef as HTMLDivElement))} >
this._closeOnSbbDialogCloseClick(event)} class="sbb-dialog__wrapper" - ${ref( - (dialogWrapperRef?: Element) => - (this._dialogWrapperElement = dialogWrapperRef as HTMLElement), - )} > - ${dialogHeader} -
- (this._dialogContentElement = dialogContent as HTMLElement), - )} - > - -
- +
- (this._ariaLiveRef = el as HTMLElement))} - > + `; } } diff --git a/src/components/dialog/dialog/index.ts b/src/components/dialog/dialog/index.ts new file mode 100644 index 0000000000..04aed2f9ab --- /dev/null +++ b/src/components/dialog/dialog/index.ts @@ -0,0 +1 @@ +export * from './dialog.js'; diff --git a/src/components/dialog/dialog/readme.md b/src/components/dialog/dialog/readme.md new file mode 100644 index 0000000000..e5d7d64233 --- /dev/null +++ b/src/components/dialog/dialog/readme.md @@ -0,0 +1,126 @@ +The `sbb-dialog` component provides a way to present content on top of the app's content. +It offers the following features: + +- creates a backdrop for disabling interaction below the modal; +- disables scrolling of the page content while open; +- manages focus properly by setting it on the first focusable element; +- can host a [sbb-dialog-actions](/docs/components-sbb-dialog-actions--docs) component in the footer; +- has a close button, which is always visible; +- can display a back button next to the title; +- adds the appropriate ARIA roles automatically. + +```html + + Title + Dialog content. + +``` + +## Slots + +There are three slots: `title`, `content` and `actions`, which can respectively be used to provide an `sbb-dialog-title`, `sbb-dialog-content` and an `sbb-dialog-actions`. + +```html + + Title + Dialog content. + + Link + Cancel + Confirm + + +``` + +## Interactions + +In order to show the dialog, you need to call the `open(event?: PointerEvent)` method on the `sbb-dialog` component. +It is necessary to pass the event object to the `open()` method to allow the dialog to detect +whether it has been opened by click or keyboard, so that the focus can be better handled. + +```html + + + + Title + Dialog content. + + + +``` + +To dismiss the dialog, you need to get a reference to the `sbb-dialog` element and call +the `close(result?: any, target?: HTMLElement)` method, which will close the dialog element and +emit a close event with an optional result as a payload. + +The component can also be dismissed by clicking on the close button, clicking on the backdrop, pressing the `Esc` key, +or, if an element within the `sbb-dialog` has the `sbb-dialog-close` attribute, by clicking on it. + +You can also set the property `backButton` on the `sbb-dialog-title` component to display the back button in the title section which will emit the event `requestBackAction` when clicked. + +## Style + +It's possible to display the component in `negative` variant using the self-named property. + +```html + + Title + Dialog content. + +``` + +## Accessibility + +When using a button to trigger the dialog, ensure to manage the appropriate ARIA attributes on the button element itself. This includes: `aria-haspopup="dialog"` that signals to assistive technologies that the button controls a dialog element, +`aria-controls="dialog-id"` that connects the button to the dialog by referencing the dialog's ID. Consider using `aria-expanded` to indicate the dialog's current state (open or closed). + +The `sbb-dialog` component may visually hide the title thanks to the `hideOnScroll` property of the [sbb-dialog-title](/docs/components-sbb-dialog-title--docs) to create more space for content, this is useful especially on smaller screens. Screen readers and other assistive technologies will still have access to the title information for context. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | --------------------- | ------- | --------------------- | --------- | -------------------------------------------------------------------- | +| `backdropAction` | `backdrop-action` | public | `'close' \| 'none'` | `'close'` | Backdrop click action. | +| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the relevant nested element. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | -------------------------- | ---------------------------------- | ------ | -------------- | +| `open` | public | Opens the dialog element. | | `void` | | +| `close` | public | Closes the dialog element. | `result: any, target: HTMLElement` | `any` | | + +## Events + +| Name | Type | Description | Inherited From | +| ----------- | ----------------------------------------- | ------------------------------------------------------------------------------- | -------------- | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. | | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` is opened. | | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. | | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-dialog` is closed. | | + +## CSS Properties + +| Name | Default | Description | +| ---------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--sbb-dialog-z-index` | `var(--sbb-overlay-z-index)` | To specify a custom stack order, the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the component is set to `var(--sbb-overlay-z-index)` with a value of `1000`. | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to provide a `sbb-dialog-title`, `sbb-dialog-content` and an optional `sbb-dialog-actions`. | diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts index 04aed2f9ab..c281d3c0f6 100644 --- a/src/components/dialog/index.ts +++ b/src/components/dialog/index.ts @@ -1 +1,4 @@ -export * from './dialog.js'; +export * from './dialog/index.js'; +export * from './dialog-title/index.js'; +export * from './dialog-content/index.js'; +export * from './dialog-actions/index.js'; diff --git a/src/components/dialog/readme.md b/src/components/dialog/readme.md deleted file mode 100644 index 98e4732711..0000000000 --- a/src/components/dialog/readme.md +++ /dev/null @@ -1,122 +0,0 @@ -The `sbb-dialog` component provides a way to present content on top of the app's content. -It offers the following features: - -- creates a backdrop for disabling interaction below the modal; -- disables scrolling of the page content while open; -- manages focus properly by setting it on the first focusable element; -- can have a header and a footer, both of which are optional; -- can host a [sbb-action-group](/docs/components-sbb-action-group--docs) component in the footer; -- has a close button, which is always visible; -- can display a back button next to the title; -- adds the appropriate ARIA roles automatically. - -```html - Dialog content. -``` - -## Slots - -The content is projected in an unnamed slot, while the dialog's title can be provided via the `titleContent` property or via slot `name="title"`. -It's also possible to display buttons in the component's footer using the `action-group` slot with the `sbb-action-group` component. - -**NOTE**: - -- The component will automatically set size `m` on slotted `sbb-action-group`; -- If the title is not present, the footer will not be displayed even if provided; -- If the title is not present, the dialog will be displayed in fullscreen mode with the close button in the content section along with the back button - (if visible, see [next paragraph](#interactions)). - -```html - Dialog content. - - - My dialog title - Dialog content. - - Abort - Confirm - - -``` - -## Interactions - -In order to show the dialog, you need to call the `open(event?: PointerEvent)` method on the `sbb-dialog` component. -It is necessary to pass the event object to the `open()` method to allow the dialog to detect -whether it has been opened by click or keyboard, so that the focus can be better handled. - -```html - - - Dialog content. -
...
-
- - -``` - -To dismiss the dialog, you need to get a reference to the `sbb-dialog` element and call -the `close(result?: any, target?: HTMLElement)` method, which will close the dialog element and -emit a close event with an optional result as a payload. - -The component can also be dismissed by clicking on the close button, clicking on the backdrop, pressing the `Esc` key, -or, if an element within the `sbb-dialog` has the `sbb-dialog-close` attribute, by clicking on it. - -You can also set the property `titleBackButton` to display the back button in the title section -(or content section, if title is omitted) which will emit the event `requestBackAction` when clicked. - -## Style - -It's possible to display the component in `negative` variant using the self-named property. - - - -## Properties - -| Name | Attribute | Privacy | Type | Default | Description | -| ------------------------- | --------------------------- | ------- | ---------------------------- | --------- | ------------------------------------------------------------------------------- | -| `titleContent` | `title-content` | public | `string \| undefined` | | Dialog title. | -| `titleLevel` | `title-level` | public | `SbbTitleLevel` | `'1'` | Level of title, will be rendered as heading tag (e.g. h1). Defaults to level 1. | -| `titleBackButton` | `title-back-button` | public | `boolean` | `false` | Whether a back button is displayed next to the title. | -| `backdropAction` | `backdrop-action` | public | `'close' \| 'none'` | `'close'` | Backdrop click action. | -| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the relevant nested element. | -| `accessibilityCloseLabel` | `accessibility-close-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the close button element. | -| `accessibilityBackLabel` | `accessibility-back-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the back button element. | -| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is enabled. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | - -## Methods - -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | -------------------------- | ---------------------------------- | ------ | -------------- | -| `open` | public | Opens the dialog element. | | `void` | | -| `close` | public | Closes the dialog element. | `result: any, target: HTMLElement` | `any` | | - -## Events - -| Name | Type | Description | Inherited From | -| ------------------- | ------------------- | ------------------------------------------------------------------------------- | -------------- | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. | | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-dialog` is closed. | | -| `requestBackAction` | `CustomEvent` | Emits whenever the back button is clicked. | | - -## CSS Properties - -| Name | Default | Description | -| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--sbb-dialog-z-index` | `var(--sbb-overlay-default-z-index)` | To specify a custom stack order, the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. | - -## Slots - -| Name | Description | -| -------------- | ------------------------------------------------------------ | -| | Use the unnamed slot to add content to the `sbb-dialog`. | -| `title` | Use this slot to provide a title. | -| `action-group` | Use this slot to display a `sbb-action-group` in the footer. | diff --git a/src/storybook/pages/home/home--logged-in.stories.ts b/src/storybook/pages/home/home--logged-in.stories.ts index 730524a370..fca4a43c9a 100644 --- a/src/storybook/pages/home/home--logged-in.stories.ts +++ b/src/storybook/pages/home/home--logged-in.stories.ts @@ -189,24 +189,28 @@ const Template = (args: Args): TemplateResult => html` All purchased tickets - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure - dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. -

+ + My Dialog - - (document.getElementById('my-stacked-dialog') as SbbDialogElement).open()} - > - Open stacked dialog - + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. +

+ + (document.getElementById('my-stacked-dialog') as SbbDialogElement).open()} + > + Open stacked dialog + +
- html` Cancel Button - +
- - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. + + Stacked Dialog + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +