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
+ >${unsafeStatic(TAG_NAME)}>
+ `;
+
+ 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()}
+ >${unsafeStatic(TAG_NAME)}>
+ `;
+ /* eslint-enable lit/binding-positions */
+
+ return html`
+
+ `;
+ }
+}
+
+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.
-
-
-
-`;
-
-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.
+
+
+
+
+`;
+
+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
- >${unsafeStatic(TAG_NAME)}>
- `;
+ 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()}
- >${unsafeStatic(TAG_NAME)}>
- `;
- /* eslint-enable lit/binding-positions */
-
- const dialogHeader = html`
-
- `;
+ 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.
+