From 2ad13f448d7f0b0f77855dd09cd26fe6554375bf Mon Sep 17 00:00:00 2001 From: Mario Castigliano Date: Fri, 14 Jun 2024 11:40:30 +0200 Subject: [PATCH] feat(sbb-checkbox, sbb-radio-button): split into regular and panel variants (#2552) Closes #2395 BREAKING CHANGE: `sbb-selection-panel` has been renamed to `sbb-selection-expansion-panel`. The `sbb-checkbox` and `sbb-radio-button` components cannot be used anymore with `sbb-selection-expansion-panel` (does not apply for cases where they are slotted inside the `content` slot). As a replacement, we introduce the new components `sbb-checkbox-panel` and `sbb-radio-button-panel`, which could also be used standalone in cases where there is no content. `sbb-checkbox-group` and `sbb-radio-button-group` also support the panel variants. How to migrate? - Rename usages of `sbb-selection-panel` to `sbb-selection-expansion-panel`. - Inside the `sbb-selection-expansion-panel`, replace `sbb-checkbox` with `sbb-checkbox-panel` and `sbb-radio-button` with `sbb-radio-button-panel` (does not apply for cases where they are slotted inside the `content` slot of the `sbb-selection-expansion-panel`) - In cases where there was no content (slot), don't use `sbb-selection-panel`/`sbb-selection-expansion-panel` anymore, but directly use `sbb-checkbox-panel` or `sbb-radio-button-panel`. --------- Co-authored-by: Davide Mininni <101575400+DavideMininni-Fincons@users.noreply.github.com> Co-authored-by: Jeremias Peier --- src/elements/card/card-badge/readme.md | 4 +- src/elements/checkbox.ts | 2 + .../checkbox-group/checkbox-group.scss | 21 +- .../checkbox-group/checkbox-group.stories.ts | 133 ++- .../checkbox/checkbox-group/checkbox-group.ts | 32 +- .../checkbox/checkbox-group/readme.md | 26 +- src/elements/checkbox/checkbox-panel.ts | 1 + .../checkbox-panel.snapshot.spec.snap.js | 254 +++++ .../checkbox-panel.snapshot.spec.ts | 101 ++ .../checkbox-panel/checkbox-panel.spec.ts | 24 + .../checkbox-panel/checkbox-panel.ssr.spec.ts | 20 + .../checkbox-panel/checkbox-panel.stories.ts | 218 ++++ .../checkbox/checkbox-panel/checkbox-panel.ts | 124 +++ .../checkbox/checkbox-panel/readme.md | 99 ++ .../checkbox.snapshot.spec.snap.js | 50 +- src/elements/checkbox/checkbox/checkbox.scss | 142 +-- .../checkbox/checkbox/checkbox.spec.ts | 979 +---------------- src/elements/checkbox/checkbox/checkbox.ts | 188 +--- src/elements/checkbox/checkbox/readme.md | 12 +- src/elements/checkbox/common.ts | 3 + .../checkbox/common/checkbox-common.scss | 60 ++ .../checkbox/common/checkbox-common.spec.ts | 997 ++++++++++++++++++ .../checkbox/common/checkbox-common.ts | 85 ++ src/elements/core/mixins.ts | 3 + src/elements/core/mixins/panel-common.scss | 115 ++ src/elements/core/mixins/panel-mixin.ts | 53 + src/elements/radio-button.ts | 2 + src/elements/radio-button/common.ts | 3 + .../common/radio-button-common.scss | 120 +++ .../common/radio-button-common.ts | 203 ++++ .../radio-button-group.snapshot.spec.snap.js | 16 +- .../radio-button-group.scss | 21 +- .../radio-button-group.snapshot.spec.ts | 22 +- .../radio-button-group.spec.ts | 378 ++++--- .../radio-button-group.stories.ts | 59 +- .../radio-button-group/radio-button-group.ts | 52 +- .../radio-button/radio-button-group/readme.md | 24 +- .../radio-button/radio-button-panel.ts | 1 + .../radio-button-panel.snapshot.spec.snap.js | 167 +++ .../radio-button/radio-button-panel/index.ts | 1 + .../radio-button-panel.snapshot.spec.ts | 61 ++ .../radio-button-panel.spec.ts | 70 ++ .../radio-button-panel.ssr.spec.ts | 23 + .../radio-button-panel.stories.ts | 165 +++ .../radio-button-panel/radio-button-panel.ts | 87 ++ .../radio-button/radio-button-panel/readme.md | 76 ++ .../radio-button/radio-button.scss | 160 +-- .../radio-button/radio-button/radio-button.ts | 252 +---- .../radio-button/radio-button/readme.md | 14 +- src/elements/selection-expansion-panel.ts | 1 + ...tion-expansion-panel.snapshot.spec.snap.js | 95 ++ .../readme.md | 52 +- .../selection-expansion-panel.scss | 163 +++ ...selection-expansion-panel.snapshot.spec.ts | 40 + .../selection-expansion-panel.spec.ts} | 387 ++++--- .../selection-expansion-panel.ssr.spec.ts | 27 + .../selection-expansion-panel.stories.ts} | 304 +++--- .../selection-expansion-panel.ts} | 91 +- src/elements/selection-panel.ts | 1 - .../selection-panel.snapshot.spec.snap.js | 135 --- .../selection-panel/selection-panel.scss | 199 ---- .../selection-panel.snapshot.spec.ts | 43 - 62 files changed, 4395 insertions(+), 2866 deletions(-) create mode 100644 src/elements/checkbox/checkbox-panel.ts create mode 100644 src/elements/checkbox/checkbox-panel/__snapshots__/checkbox-panel.snapshot.spec.snap.js create mode 100644 src/elements/checkbox/checkbox-panel/checkbox-panel.snapshot.spec.ts create mode 100644 src/elements/checkbox/checkbox-panel/checkbox-panel.spec.ts create mode 100644 src/elements/checkbox/checkbox-panel/checkbox-panel.ssr.spec.ts create mode 100644 src/elements/checkbox/checkbox-panel/checkbox-panel.stories.ts create mode 100644 src/elements/checkbox/checkbox-panel/checkbox-panel.ts create mode 100644 src/elements/checkbox/checkbox-panel/readme.md create mode 100644 src/elements/checkbox/common.ts create mode 100644 src/elements/checkbox/common/checkbox-common.scss create mode 100644 src/elements/checkbox/common/checkbox-common.spec.ts create mode 100644 src/elements/checkbox/common/checkbox-common.ts create mode 100644 src/elements/core/mixins/panel-common.scss create mode 100644 src/elements/core/mixins/panel-mixin.ts create mode 100644 src/elements/radio-button/common.ts create mode 100644 src/elements/radio-button/common/radio-button-common.scss create mode 100644 src/elements/radio-button/common/radio-button-common.ts create mode 100644 src/elements/radio-button/radio-button-panel.ts create mode 100644 src/elements/radio-button/radio-button-panel/__snapshots__/radio-button-panel.snapshot.spec.snap.js create mode 100644 src/elements/radio-button/radio-button-panel/index.ts create mode 100644 src/elements/radio-button/radio-button-panel/radio-button-panel.snapshot.spec.ts create mode 100644 src/elements/radio-button/radio-button-panel/radio-button-panel.spec.ts create mode 100644 src/elements/radio-button/radio-button-panel/radio-button-panel.ssr.spec.ts create mode 100644 src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts create mode 100644 src/elements/radio-button/radio-button-panel/radio-button-panel.ts create mode 100644 src/elements/radio-button/radio-button-panel/readme.md create mode 100644 src/elements/selection-expansion-panel.ts create mode 100644 src/elements/selection-expansion-panel/__snapshots__/selection-expansion-panel.snapshot.spec.snap.js rename src/elements/{selection-panel => selection-expansion-panel}/readme.md (65%) create mode 100644 src/elements/selection-expansion-panel/selection-expansion-panel.scss create mode 100644 src/elements/selection-expansion-panel/selection-expansion-panel.snapshot.spec.ts rename src/elements/{selection-panel/selection-panel.spec.ts => selection-expansion-panel/selection-expansion-panel.spec.ts} (62%) create mode 100644 src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts rename src/elements/{selection-panel/selection-panel.stories.ts => selection-expansion-panel/selection-expansion-panel.stories.ts} (64%) rename src/elements/{selection-panel/selection-panel.ts => selection-expansion-panel/selection-expansion-panel.ts} (73%) delete mode 100644 src/elements/selection-panel.ts delete mode 100644 src/elements/selection-panel/__snapshots__/selection-panel.snapshot.spec.snap.js delete mode 100644 src/elements/selection-panel/selection-panel.scss delete mode 100644 src/elements/selection-panel/selection-panel.snapshot.spec.ts diff --git a/src/elements/card/card-badge/readme.md b/src/elements/card/card-badge/readme.md index 2d9b41d33b..fc950206dc 100644 --- a/src/elements/card/card-badge/readme.md +++ b/src/elements/card/card-badge/readme.md @@ -1,6 +1,6 @@ The `sbb-card-badge` can contain some information like prices or discounts, -and can be used in [sbb-card](/docs/elements-sbb-card-sbb-card--docs) or -[sbb-selection-panel](/docs/elements-sbb-selection-panel--docs). +and can be used in [sbb-card](/docs/components-sbb-card-sbb-card--docs) or +[sbb-selection-expansion-panel](/docs/components-sbb-selection-expansion-panel--docs). To achieve the correct spacing between elements inside the card badge, we recommend to use `span`-elements. All content parts are presented with a predefined gap in between. diff --git a/src/elements/checkbox.ts b/src/elements/checkbox.ts index 9aabfd750c..5d6a31dca6 100644 --- a/src/elements/checkbox.ts +++ b/src/elements/checkbox.ts @@ -1,2 +1,4 @@ export * from './checkbox/checkbox.js'; export * from './checkbox/checkbox-group.js'; +export * from './checkbox/checkbox-panel.js'; +export * from './checkbox/common.js'; diff --git a/src/elements/checkbox/checkbox-group/checkbox-group.scss b/src/elements/checkbox/checkbox-group/checkbox-group.scss index 42af7b6b2c..4363e6089f 100644 --- a/src/elements/checkbox/checkbox-group/checkbox-group.scss +++ b/src/elements/checkbox/checkbox-group/checkbox-group.scss @@ -23,13 +23,21 @@ $breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; --sbb-checkbox-group-orientation: column; --sbb-checkbox-group-width: 100%; --sbb-checkbox-group-checkbox-width: 100%; + + ::slotted(sbb-checkbox-panel) { + width: 100%; + } } -:host([data-has-selection-panel]) { +:host([data-has-panel]) { --sbb-checkbox-group-width: 100%; + + ::slotted(sbb-checkbox-panel) { + flex: auto; + } } -:host([data-has-selection-panel][orientation='vertical']) { +:host([data-has-panel][orientation='vertical']) { --sbb-checkbox-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); } @@ -38,11 +46,14 @@ $breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; // horizontal-from overrides orientation vertical :host([orientation='vertical'][horizontal-from='#{$breakpoint}']) { @include horizontal-orientation; + + // We need to unset the 100% width of the vertical mode if it starts to be horizontal + ::slotted(sbb-checkbox-panel) { + width: initial; + } } - :host( - [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-panel]) - ) { + :host([orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-panel])) { --sbb-checkbox-group-width: max-content; } } diff --git a/src/elements/checkbox/checkbox-group/checkbox-group.stories.ts b/src/elements/checkbox/checkbox-group/checkbox-group.stories.ts index ffe13c0e26..0c431500ba 100644 --- a/src/elements/checkbox/checkbox-group/checkbox-group.stories.ts +++ b/src/elements/checkbox/checkbox-group/checkbox-group.stories.ts @@ -1,9 +1,9 @@ import { withActions } from '@storybook/addon-actions/decorator'; import type { InputType } from '@storybook/types'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import type { ArgTypes, Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; import type { TemplateResult } from 'lit'; import { html, nothing } from 'lit'; -import { styleMap } from 'lit/directives/style-map.js'; +import { styleMap, type StyleInfo } from 'lit/directives/style-map.js'; import { sbbSpread } from '../../../storybook/helpers/spread.js'; import type { SbbCheckboxElement } from '../checkbox.js'; @@ -12,7 +12,10 @@ import readme from './readme.md?raw'; import './checkbox-group.js'; import '../checkbox.js'; +import '../checkbox-panel.js'; import '../../form-error.js'; +import '../../icon.js'; +import '../../card/card-badge.js'; const longLabelText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et @@ -20,11 +23,29 @@ lacus sollicitudin, quis malesuada lorem vehicula. Suspendisse at augue quis tel velit, varius nec est ac, mollis efficitur lorem. Quisque non nisl eget massa interdum tempus. Praesent vel feugiat metus.`; +const suffixStyle: Readonly = { + display: 'flex', + alignItems: 'center', +}; + +const cardBadge = (): TemplateResult => html`%`; + +const suffixAndSubtext = (): TemplateResult => html` + Subtext + + + + CHF 40.00 + + + ${cardBadge()} +`; + const checkboxes = ( checked: boolean, disabledSingle: boolean, iconName: string, - iconPlacement: string, + iconPlacement: 'start' | 'end', label: string, ): TemplateResult => html` `; +const checkboxPanels = ( + checked: boolean, + disabledSingle: boolean, + label: string, +): TemplateResult => html` + + ${label} 1 ${suffixAndSubtext()} + + ${label} 2 ${suffixAndSubtext()} + + ${label} 3 ${suffixAndSubtext()} +`; + const DefaultTemplate = ({ checked, disabledSingle, @@ -61,6 +96,12 @@ const DefaultTemplate = ({ `; +const PanelTemplate = ({ checked, disabledSingle, label, ...args }: Args): TemplateResult => html` + + ${checkboxPanels(checked, disabledSingle, label)} + +`; + const ErrorMessageTemplate = ({ checked, disabledSingle, @@ -254,6 +295,10 @@ const basicArgTypes: ArgTypes = { label, checked, disabledSingle, +}; + +const checkboxArgTypes: ArgTypes = { + ...basicArgTypes, iconName, iconPlacement, }; @@ -267,12 +312,16 @@ const basicArgs: Args = { label: 'Label', checked: true, disabledSingle: false, +}; + +const checkboxArgs: Args = { + ...basicArgs, iconName: undefined, iconPlacement: undefined, }; -const basicArgsVertical = { - ...basicArgs, +const checkboxArgsVertical = { + ...checkboxArgs, orientation: orientation.options![1], }; @@ -288,86 +337,104 @@ const iconEnd: Args = { export const horizontal: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs }, }; export const vertical: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical }, }; export const verticalToHorizontal: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, 'horizontal-from': 'medium' }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, 'horizontal-from': 'medium' }, }; export const horizontalSizeM: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, size: 'm' }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs, size: 'm' }, }; export const horizontalDisabled: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, disabled: true, disabledSingle: true }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs, disabled: true, disabledSingle: true }, }; export const verticalDisabled: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, disabled: true, disabledSingle: true }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, disabled: true, disabledSingle: true }, }; export const horizontalIconStart: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, ...iconStart }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs, ...iconStart }, }; export const verticalIconStart: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconStart }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, ...iconStart }, }; export const horizontalIconEnd: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, ...iconEnd }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs, ...iconEnd }, }; export const verticalIconEnd: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconEnd }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, ...iconEnd }, }; export const verticalIconEndLongLabel: StoryObj = { render: DefaultTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, ...iconEnd, label: longLabelText }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, ...iconEnd, label: longLabelText }, }; export const horizontalWithSbbFormError: StoryObj = { render: ErrorMessageTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, required: true }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgs, required: true }, }; export const verticalWithSbbFormError: StoryObj = { render: ErrorMessageTemplate, - argTypes: basicArgTypes, - args: { ...basicArgsVertical, required: true }, + argTypes: checkboxArgTypes, + args: { ...checkboxArgsVertical, required: true }, }; export const indeterminateGroup: StoryObj = { render: IndeterminateGroupTemplate, - argTypes: { ...basicArgTypes }, - args: { ...basicArgsVertical, checked: undefined }, + argTypes: { ...checkboxArgTypes }, + args: { ...checkboxArgsVertical, checked: undefined }, +}; + +export const horizontalPanel: StoryObj = { + render: PanelTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs }, +}; + +export const verticalPanel: StoryObj = { + render: PanelTemplate, + argTypes: basicArgTypes, + args: { ...basicArgs, orientation: orientation.options![1] }, +}; + +export const verticalToHorizontalPanel: StoryObj = { + render: PanelTemplate, + argTypes: checkboxArgTypes, + args: { ...basicArgs, orientation: orientation.options![1], 'horizontal-from': 'medium' }, }; const meta: Meta = { diff --git a/src/elements/checkbox/checkbox-group/checkbox-group.ts b/src/elements/checkbox/checkbox-group/checkbox-group.ts index 65b102ea4a..72f5baf99b 100644 --- a/src/elements/checkbox/checkbox-group/checkbox-group.ts +++ b/src/elements/checkbox/checkbox-group/checkbox-group.ts @@ -1,12 +1,14 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { getNextElementIndex, interactivityChecker, isArrowKeyPressed } from '../../core/a11y.js'; import { SbbConnectedAbortController, SbbSlotStateController } from '../../core/controllers.js'; import type { SbbHorizontalFrom, SbbOrientation } from '../../core/interfaces.js'; import { SbbDisabledMixin } from '../../core/mixins.js'; -import type { SbbCheckboxElement, SbbCheckboxSize } from '../checkbox.js'; +import type { SbbCheckboxPanelElement } from '../checkbox-panel.js'; +import type { SbbCheckboxElement } from '../checkbox.js'; +import type { SbbCheckboxSize } from '../common.js'; import style from './checkbox-group.scss?lit&inline'; @@ -35,9 +37,11 @@ export class SbbCheckboxGroupElement extends SbbDisabledMixin(LitElement) { public orientation: SbbOrientation = 'horizontal'; /** List of contained checkbox elements. */ - public get checkboxes(): SbbCheckboxElement[] { - return Array.from(this.querySelectorAll?.('sbb-checkbox') ?? []).filter( - (el: SbbCheckboxElement) => el.closest('sbb-checkbox-group') === this, + public get checkboxes(): (SbbCheckboxElement | SbbCheckboxPanelElement)[] { + return <(SbbCheckboxElement | SbbCheckboxPanelElement)[]>( + Array.from(this.querySelectorAll?.('sbb-checkbox, sbb-checkbox-panel') ?? []).filter( + (el) => el.closest('sbb-checkbox-group') === this, + ) ); } @@ -52,7 +56,10 @@ export class SbbCheckboxGroupElement extends SbbDisabledMixin(LitElement) { super.connectedCallback(); const signal = this._abort.signal; this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); - this.toggleAttribute('data-has-selection-panel', !!this.querySelector?.('sbb-selection-panel')); + this.toggleAttribute( + 'data-has-panel', + !!this.querySelector?.('sbb-selection-expansion-panel, sbb-checkbox-panel'), + ); } protected override willUpdate(changedProperties: PropertyValues): void { @@ -70,24 +77,25 @@ export class SbbCheckboxGroupElement extends SbbDisabledMixin(LitElement) { } private _handleKeyDown(evt: KeyboardEvent): void { - const enabledCheckboxes: SbbCheckboxElement[] = this.checkboxes.filter( - (checkbox: SbbCheckboxElement) => - !checkbox.disabled && interactivityChecker.isVisible(checkbox), - ); + const enabledCheckboxes: (SbbCheckboxElement | SbbCheckboxPanelElement)[] = + this.checkboxes.filter( + (checkbox: SbbCheckboxElement | SbbCheckboxPanelElement) => + !checkbox.disabled && interactivityChecker.isVisible(checkbox as HTMLElement), + ); if ( !enabledCheckboxes || // don't trap nested handling ((evt.target as HTMLElement) !== this && (evt.target as HTMLElement).parentElement !== this && - (evt.target as HTMLElement).parentElement!.nodeName !== 'SBB-SELECTION-PANEL') + (evt.target as HTMLElement).parentElement!.localName !== 'sbb-selection-expansion-panel') ) { return; } if (isArrowKeyPressed(evt)) { const current: number = enabledCheckboxes.findIndex( - (e: SbbCheckboxElement) => e === evt.target, + (e: SbbCheckboxElement | SbbCheckboxPanelElement) => e === evt.target, ); const nextIndex: number = getNextElementIndex(evt, current, enabledCheckboxes.length); enabledCheckboxes[nextIndex]?.focus(); diff --git a/src/elements/checkbox/checkbox-group/readme.md b/src/elements/checkbox/checkbox-group/readme.md index 0887a2167f..558bec1bd3 100644 --- a/src/elements/checkbox/checkbox-group/readme.md +++ b/src/elements/checkbox/checkbox-group/readme.md @@ -1,6 +1,6 @@ -The `sbb-checkbox-group` component is used as a container for one or multiple -[sbb-checkbox](/docs/elements-sbb-checkbox-sbb-checkbox--docs) components, -or, alternatively, for a collection of [sbb-selection-panel](/docs/elements-sbb-selection-panel--docs). +The `sbb-checkbox-group` component is used as a container for a collection of either +[sbb-checkbox](/docs/elements-sbb-checkbox-sbb-checkbox--docs)s, [sbb-checkbox-panel](/docs/elements-sbb-checkbox-sbb-checkbox-panel--docs)s, +or [sbb-selection-expansion-panel](/docs/elements-sbb-selection-expansion-panel--docs). ```html @@ -10,7 +10,7 @@ or, alternatively, for a collection of [sbb-selection-panel](/docs/elements-sbb- - + Value @@ -19,7 +19,7 @@ or, alternatively, for a collection of [sbb-selection-panel](/docs/elements-sbb- 40.00 - + ``` @@ -73,14 +73,14 @@ Two values are available, `s` and `m`, which is the default ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ---------------- | ----------------- | ------- | -------------------------------- | -------------- | ------------------------------------------------------------------------------ | -| `checkboxes` | - | public | `SbbCheckboxElement[]` | | List of contained checkbox elements. | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. | -| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Indicates the orientation of the checkboxes inside the ``. | -| `required` | `required` | public | `boolean` | `false` | Whether the checkbox group is required. | -| `size` | `size` | public | `SbbCheckboxSize` | `'m'` | Size variant, either m or s. | +| Name | Attribute | Privacy | Type | Default | Description | +| ---------------- | ----------------- | ------- | --------------------------------------------------- | -------------- | ------------------------------------------------------------------------------ | +| `checkboxes` | - | public | `(SbbCheckboxElement \| SbbCheckboxPanelElement)[]` | | List of contained checkbox elements. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. | +| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Indicates the orientation of the checkboxes inside the ``. | +| `required` | `required` | public | `boolean` | `false` | Whether the checkbox group is required. | +| `size` | `size` | public | `SbbCheckboxSize` | `'m'` | Size variant, either m or s. | ## Slots diff --git a/src/elements/checkbox/checkbox-panel.ts b/src/elements/checkbox/checkbox-panel.ts new file mode 100644 index 0000000000..e8c5fd8a3f --- /dev/null +++ b/src/elements/checkbox/checkbox-panel.ts @@ -0,0 +1 @@ +export * from './checkbox-panel/checkbox-panel.js'; diff --git a/src/elements/checkbox/checkbox-panel/__snapshots__/checkbox-panel.snapshot.spec.snap.js b/src/elements/checkbox/checkbox-panel/__snapshots__/checkbox-panel.snapshot.spec.snap.js new file mode 100644 index 0000000000..4935c50264 --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/__snapshots__/checkbox-panel.snapshot.spec.snap.js @@ -0,0 +1,254 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-checkbox-panel should render unchecked DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-checkbox-panel should render unchecked DOM */ + +snapshots["sbb-checkbox-panel should render unchecked Shadow DOM"] = +` +
+ + +
+ + + + + + + + + + + + + + + + + + +
+`; +/* end snapshot sbb-checkbox-panel should render unchecked Shadow DOM */ + +snapshots["sbb-checkbox-panel should render checked DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-checkbox-panel should render checked DOM */ + +snapshots["sbb-checkbox-panel should render checked Shadow DOM"] = +` +
+ + +
+ + + + + + + + + + + + + + + + + + +
+`; +/* end snapshot sbb-checkbox-panel should render checked Shadow DOM */ + +snapshots["sbb-checkbox-panel should render indeterminate DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-checkbox-panel should render indeterminate DOM */ + +snapshots["sbb-checkbox-panel should render indeterminate Shadow DOM"] = +` +
+ + +
+ + + + + + + + + + + + + + + + + + +
+`; +/* end snapshot sbb-checkbox-panel should render indeterminate Shadow DOM */ + +snapshots["sbb-checkbox-panel should render unchecked disabled DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-checkbox-panel should render unchecked disabled DOM */ + +snapshots["sbb-checkbox-panel should render unchecked disabled Shadow DOM"] = +` +
+ + +
+ + + + + + + + + + + + + + + + + + +
+`; +/* end snapshot sbb-checkbox-panel should render unchecked disabled Shadow DOM */ + +snapshots["sbb-checkbox-panel Unchecked - A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "​ Label" + } + ] +} +

+`; +/* end snapshot sbb-checkbox-panel Unchecked - A11y tree Firefox */ + +snapshots["sbb-checkbox-panel Unchecked - A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "​ Label", + "checked": false + } + ] +} +

+`; +/* end snapshot sbb-checkbox-panel Unchecked - A11y tree Chrome */ + +snapshots["sbb-checkbox-panel Checked - A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "​ Label", + "checked": true + } + ] +} +

+`; +/* end snapshot sbb-checkbox-panel Checked - A11y tree Chrome */ + +snapshots["sbb-checkbox-panel Checked - A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "​ Label", + "checked": true + } + ] +} +

+`; +/* end snapshot sbb-checkbox-panel Checked - A11y tree Firefox */ + diff --git a/src/elements/checkbox/checkbox-panel/checkbox-panel.snapshot.spec.ts b/src/elements/checkbox/checkbox-panel/checkbox-panel.snapshot.spec.ts new file mode 100644 index 0000000000..6d729e4e55 --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/checkbox-panel.snapshot.spec.ts @@ -0,0 +1,101 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import { SbbCheckboxPanelElement } from './checkbox-panel.js'; + +describe('sbb-checkbox-panel', () => { + let element: SbbCheckboxPanelElement; + + describe('should render unchecked', async () => { + beforeEach(async () => { + element = (await fixture( + html`Label + Subtext + Suffix + `, + )) as SbbCheckboxPanelElement; + assert.instanceOf(element, SbbCheckboxPanelElement); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('should render checked', async () => { + beforeEach(async () => { + element = (await fixture( + html`Label + Subtext + Suffix + `, + )) as SbbCheckboxPanelElement; + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('should render indeterminate', async () => { + beforeEach(async () => { + element = (await fixture( + html`Label + Subtext + Suffix + `, + )) as SbbCheckboxPanelElement; + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('should render unchecked disabled', async () => { + beforeEach(async () => { + element = (await fixture( + html`Label + Subtext + Suffix + `, + )) as SbbCheckboxPanelElement; + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + testA11yTreeSnapshot( + html`Label`, + 'Unchecked - A11y tree', + ); + + testA11yTreeSnapshot( + html`Label`, + 'Checked - A11y tree', + ); +}); diff --git a/src/elements/checkbox/checkbox-panel/checkbox-panel.spec.ts b/src/elements/checkbox/checkbox-panel/checkbox-panel.spec.ts new file mode 100644 index 0000000000..c9ab9b88a5 --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/checkbox-panel.spec.ts @@ -0,0 +1,24 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbCheckboxPanelElement } from './checkbox-panel.js'; + +describe(`sbb-checkbox-panel`, () => { + describe('general', () => { + let element: SbbCheckboxPanelElement; + + beforeEach(async () => { + element = await fixture( + html`Label`, + ); + }); + + it('should render', async () => { + assert.instanceOf(element, SbbCheckboxPanelElement); + }); + }); + + // All the functionalities of sbb-checkbox-panel are tested in checkbox-common.e2e.ts file +}); diff --git a/src/elements/checkbox/checkbox-panel/checkbox-panel.ssr.spec.ts b/src/elements/checkbox/checkbox-panel/checkbox-panel.ssr.spec.ts new file mode 100644 index 0000000000..2c4a020d0f --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/checkbox-panel.ssr.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbCheckboxPanelElement } from './checkbox-panel.js'; + +describe(`sbb-checkbox-panel ${fixture.name}`, () => { + let root: SbbCheckboxPanelElement; + + beforeEach(async () => { + root = await fixture(html`Value label`, { + modules: ['./checkbox-panel.js'], + }); + }); + + it('renders', () => { + assert.instanceOf(root, SbbCheckboxPanelElement); + }); +}); diff --git a/src/elements/checkbox/checkbox-panel/checkbox-panel.stories.ts b/src/elements/checkbox/checkbox-panel/checkbox-panel.stories.ts new file mode 100644 index 0000000000..79d84968e9 --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/checkbox-panel.stories.ts @@ -0,0 +1,218 @@ +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 { html, type TemplateResult } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import '../../button.js'; +import '../../card.js'; +import '../../icon.js'; +import './checkbox-panel.js'; + +import readme from './readme.md?raw'; + +const checked: InputType = { + control: { + type: 'boolean', + }, +}; + +const indeterminate: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const label: InputType = { + control: { + type: 'text', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const name: InputType = { + control: { + type: 'text', + }, +}; + +const color: InputType = { + control: { + type: 'inline-radio', + }, + options: ['white', 'milk'], +}; + +const borderless: InputType = { + control: { + type: 'boolean', + }, +}; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + checked, + indeterminate, + disabled, + label, + value, + name, + 'aria-label': ariaLabel, + color, + borderless, + size, +}; + +const defaultArgs: Args = { + checked: false, + indeterminate: false, + disabled: false, + label: 'Label', + value: 'Value', + name: 'name', + 'aria-label': undefined, + color: color.options![0], + borderless: false, + size: size.options![0], +}; + +const cardBadge = (): TemplateResult => html`%`; + +const Template = ({ label, checked, ...args }: Args): TemplateResult => + html` + ${label} + Subtext + + + + + CHF 40.00 + + + + ${cardBadge()} + `; + +const TemplateWithForm = (args: Args): TemplateResult => html` +
{ + e.preventDefault(); + const form = (e.target as HTMLFormElement)!; + form.querySelector('#form-data')!.innerHTML = JSON.stringify( + Object.fromEntries(new FormData(form)), + ); + }} + > +
+  fieldset  + ${Template(args)} +
+ +
+  disabled fieldset  + ${Template({ ...args, name: 'disabled' })} +
+
+ Reset + Submit +
+

Form-Data after click submit:

+ +
+`; + +export const DefaultUnchecked: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const DefaultChecked: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true }, +}; +export const DefaultIndeterminate: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, indeterminate: true }, +}; + +export const SizeS: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options![1] }, +}; + +export const Milk: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, color: color.options![1] }, +}; + +export const disabledChecked: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true, checked: true }, +}; +export const disabledUnchecked: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; +export const disabledIndeterminate: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true, indeterminate: true }, +}; + +export const withForm: StoryObj = { + render: TemplateWithForm, + argTypes: defaultArgTypes, + args: defaultArgs, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: ['change', 'input'], + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-checkbox/sbb-checkbox-panel', +}; + +export default meta; diff --git a/src/elements/checkbox/checkbox-panel/checkbox-panel.ts b/src/elements/checkbox/checkbox-panel/checkbox-panel.ts new file mode 100644 index 0000000000..6e899d62d7 --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/checkbox-panel.ts @@ -0,0 +1,124 @@ +import { + type CSSResultGroup, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbSlotStateController } from '../../core/controllers.js'; +import { EventEmitter } from '../../core/eventing.js'; +import type { + SbbCheckedStateChange, + SbbDisabledStateChange, + SbbStateChange, +} from '../../core/interfaces/types.js'; +import { panelCommonStyle, SbbPanelMixin, SbbUpdateSchedulerMixin } from '../../core/mixins.js'; +import { checkboxCommonStyle, SbbCheckboxCommonElementMixin } from '../common.js'; + +import '../../screen-reader-only.js'; +import '../../visual-checkbox.js'; + +export type SbbCheckboxPanelStateChange = Extract< + SbbStateChange, + SbbDisabledStateChange | SbbCheckedStateChange +>; + +/** + * It displays a checkbox enhanced with selection panel design. + * + * @slot - Use the unnamed slot to add content to the `sbb-checkbox`. + * @slot subtext - Slot used to render a subtext under the label (only visible within a selection panel). + * @slot suffix - Slot used to render additional content after the label (only visible within a selection panel). + * @slot badge - Use this slot to provide a `sbb-card-badge` (optional). + * @event {CustomEvent} didChange - Deprecated. used for React. Will probably be removed once React 19 is available. + * @event {Event} change - Event fired on change. + * @event {InputEvent} input - Event fired on input. + */ +@customElement('sbb-checkbox-panel') +export class SbbCheckboxPanelElement extends SbbPanelMixin( + SbbCheckboxCommonElementMixin(SbbUpdateSchedulerMixin(LitElement)), +) { + public static override styles: CSSResultGroup = [checkboxCommonStyle, panelCommonStyle]; + + // FIXME using ...super.events requires: https://github.com/sbb-design-systems/lyne-components/issues/2600 + public static readonly events = { + didChange: 'didChange', + stateChange: 'stateChange', + panelConnected: 'panelConnected', + } as const; + + /** + * @internal + * Internal event that emits whenever the state of the checkbox + * in relation to the parent selection panel changes. + */ + protected stateChange: EventEmitter = new EventEmitter( + this, + SbbCheckboxPanelElement.events.stateChange, + { bubbles: true }, + ); + + public constructor() { + super(); + new SbbSlotStateController(this); + } + + protected override async willUpdate(changedProperties: PropertyValues): Promise { + super.willUpdate(changedProperties); + + if (changedProperties.has('checked')) { + // As SbbFormAssociatedCheckboxMixin does not reflect checked property, we add a data-checked. + this.toggleAttribute('data-checked', this.checked); + + if (this.checked !== changedProperties.get('checked')!) { + this.stateChange.emit({ type: 'checked', checked: this.checked }); + } + } + if (changedProperties.has('disabled')) { + if (this.disabled !== changedProperties.get('disabled')!) { + this.stateChange.emit({ type: 'disabled', disabled: this.disabled }); + } + } + } + + protected override render(): TemplateResult { + return html` + +
+ +
+ + + + + + + + + + + + + ${this.expansionState + ? html`${this.expansionState}` + : nothing} + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-checkbox-panel': SbbCheckboxPanelElement; + } +} diff --git a/src/elements/checkbox/checkbox-panel/readme.md b/src/elements/checkbox/checkbox-panel/readme.md new file mode 100644 index 0000000000..80cb20c72a --- /dev/null +++ b/src/elements/checkbox/checkbox-panel/readme.md @@ -0,0 +1,99 @@ +The `sbb-checkbox-panel` component provides the same functionality as a native `` enhanced with the selection panel design and functionalities. + +## Slots + +It is possible to provide a label via an unnamed slot; +additionally the slots named `subtext` can be used to provide a subtext and +the slot named `suffix` can be used to provide suffix items. +If you use a , the slot `badge` is automatically assigned. + +```html + + % + Label + Subtext + Suffix + +``` + +## States + +The component could be checked or not depending on the value of the `checked` attribute. + +```html +Checked state +``` + +It has a third state too, which is set if the `indeterminate` property is true. +This is useful when multiple dependent checkbox-panels are used +(e.g., a parent which is checked only if all the children are checked, otherwise is in indeterminate state). +Clicking on a `sbb-checkbox-panel` in this state sets `checked` to `true` and `indeterminate` to false. + +```html +Indeterminate state +``` + +The component can be disabled by using the `disabled` property. + +```html +Disabled +``` + +## Style + +The component's label can be displayed in bold using the `sbb-text--bold` class on a wrapper tag: + +```html + + Bold label + +``` + +## Events + +Consumers can listen to the native `change` event on the `sbb-checkbox-panel` component to intercept the input's change; +the current state can be read from `event.target.checked`, while the value from `event.target.value`. + +## Accessibility + +The component provides the same accessibility features as the native checkbox. + +Always provide an accessible label via `aria-label` for checkboxes without descriptive text content. +If you don't want the label to appear next to the checkbox, you can use `aria-label` to specify an appropriate label. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| --------------- | --------------- | ------- | --------------------------------- | --------- | ----------------------------------------------------------- | +| `borderless` | `borderless` | public | `boolean` | `false` | Whether the unselected panel has a border. | +| `checked` | `checked` | public | `boolean` | `false` | Whether the checkbox is checked. | +| `color` | `color` | public | `'white' \| 'milk'` | `'white'` | The background color of the panel. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. | +| `group` | - | public | `SbbCheckboxGroupElement \| null` | `null` | Reference to the connected checkbox group. | +| `indeterminate` | `indeterminate` | public | `boolean` | `false` | Whether the checkbox is indeterminate. | +| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | +| `required` | `required` | public | `boolean` | `false` | Whether the component is required. | +| `size` | `size` | public | `SbbCheckboxSize` | `'m'` | Label size variant, either m or s. | +| `value` | `value` | public | `string \| null` | `null` | Value of the form element. | + +## Events + +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | -------------------------------------------------------------------------------- | -------------- | +| `change` | `Event` | Event fired on change. | | +| `didChange` | `CustomEvent` | Deprecated. used for React. Will probably be removed once React 19 is available. | | +| `input` | `InputEvent` | Event fired on input. | | + +## Slots + +| Name | Description | +| --------- | ----------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the `sbb-checkbox`. | +| `badge` | Use this slot to provide a `sbb-card-badge` (optional). | +| `subtext` | Slot used to render a subtext under the label (only visible within a selection panel). | +| `suffix` | Slot used to render additional content after the label (only visible within a selection panel). | diff --git a/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js b/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js index bfa71fa0d6..241cea22e2 100644 --- a/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js +++ b/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js @@ -24,18 +24,12 @@ snapshots["sbb-checkbox should render unchecked Shadow DOM"] = - + - - - - - - `; @@ -65,18 +59,12 @@ snapshots["sbb-checkbox should render checked Shadow DOM"] = - + - - - - - - `; @@ -106,18 +94,12 @@ snapshots["sbb-checkbox should render indeterminate Shadow DOM"] = - + - - - - - - `; @@ -147,18 +129,12 @@ snapshots["sbb-checkbox should render unchecked disabled Shadow DOM"] = - + - - - - - - `; @@ -181,38 +157,38 @@ snapshots["sbb-checkbox Unchecked - A11y tree Chrome"] = `; /* end snapshot sbb-checkbox Unchecked - A11y tree Chrome */ -snapshots["sbb-checkbox Checked - A11y tree Chrome"] = +snapshots["sbb-checkbox Unchecked - A11y tree Firefox"] = `

{ - "role": "WebArea", + "role": "document", "name": "", "children": [ { "role": "checkbox", - "name": "​ Label", - "checked": true + "name": "​ Label" } ] }

`; -/* end snapshot sbb-checkbox Checked - A11y tree Chrome */ +/* end snapshot sbb-checkbox Unchecked - A11y tree Firefox */ -snapshots["sbb-checkbox Unchecked - A11y tree Firefox"] = +snapshots["sbb-checkbox Checked - A11y tree Chrome"] = `

{ - "role": "document", + "role": "WebArea", "name": "", "children": [ { "role": "checkbox", - "name": "​ Label" + "name": "​ Label", + "checked": true } ] }

`; -/* end snapshot sbb-checkbox Unchecked - A11y tree Firefox */ +/* end snapshot sbb-checkbox Checked - A11y tree Chrome */ snapshots["sbb-checkbox Checked - A11y tree Firefox"] = `

diff --git a/src/elements/checkbox/checkbox/checkbox.scss b/src/elements/checkbox/checkbox/checkbox.scss index 46e6702eb6..00b6471042 100644 --- a/src/elements/checkbox/checkbox/checkbox.scss +++ b/src/elements/checkbox/checkbox/checkbox.scss @@ -1,154 +1,36 @@ @use '../../core/styles' as sbb; -// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. -@include sbb.box-sizing; - :host { - --sbb-checkbox-dimension: var(--sbb-size-icon-ui-small); - --sbb-checkbox-label-color: var(--sbb-color-charcoal); - --sbb-checkbox-label-icon-color: var(--sbb-color-charcoal); - --sbb-checkbox-cursor: pointer; - --sbb-checkbox-suffix-color: var(--sbb-color-charcoal); - --sbb-checkbox-subtext-color: var(--sbb-color-granite); - --sbb-checkbox-label-gap: var(--sbb-spacing-fixed-2x); - - display: inline-block; - // Use !important here to not interfere with Firefox focus ring definition // which appears in normalize css of several frameworks. outline: none !important; + display: inline-block; } -:host(:disabled) { - --sbb-checkbox-cursor: default; - --sbb-checkbox-label-color: var(--sbb-color-granite); -} - -:host([data-is-selection-panel-input]) { - --sbb-checkbox-label-gap: 0; -} +.sbb-checkbox__label--icon { + color: var(--sbb-checkbox-label-icon-color); -:is(slot[name='subtext'], slot[name='suffix']) { - :host(:not([data-is-inside-selection-panel])) & { - display: none; + :host([icon-placement='end']) & { + margin-inline-start: auto; } -} - -slot[name='suffix'] { - color: var(--sbb-checkbox-suffix-color); -} - -slot[name='subtext'] { - display: block; - color: var(--sbb-checkbox-subtext-color); - padding-inline-start: var(--sbb-spacing-fixed-8x); - :host(:not([data-slot-names~='subtext'])) & { + :host( + /** No icon */ + :not([icon-name], [data-slot-names~="icon"])) & { display: none; } } .sbb-checkbox-wrapper { - display: flex; - - @include sbb.zero-width-space; - // Hide focus outline when focus origin is mouse or touch. This is being used as a workaround in various components. - :host( - :focus-visible:not( - [data-focus-origin='mouse'], - [data-focus-origin='touch'], - [data-is-selection-panel-input] - ) - ) - & { + :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { @include sbb.focus-outline; border-radius: calc(var(--sbb-border-radius-4x) - var(--sbb-focus-outline-offset)); } } -.sbb-checkbox { - @include sbb.text-s--regular; - - position: relative; - display: block; - width: 100%; - cursor: var(--sbb-checkbox-cursor); - user-select: none; - -webkit-tap-highlight-color: transparent; - - :host([size='m']) & { - @include sbb.text-m--regular; - } -} - -.sbb-checkbox__inner { - display: flex; - align-items: start; - gap: var(--sbb-spacing-fixed-2x); - - // Change the focus outline when the input is placed inside of a selection panel - // as the main input element. - :host( - [data-is-selection-panel-input]:focus-visible:not( - [data-focus-origin='mouse'], - [data-focus-origin='touch'] - ) - ) - & { - &::before { - content: ''; - position: absolute; - display: block; - inset-block: calc( - (var(--sbb-spacing-responsive-xs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - inset-inline: calc( - (var(--sbb-spacing-responsive-xxs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - border: var(--sbb-focus-outline-color) solid var(--sbb-focus-outline-width); - border-radius: calc(var(--sbb-border-radius-4x) + var(--sbb-focus-outline-offset)); - } - } -} - -.sbb-checkbox__aligner, -.sbb-checkbox__label--icon { - display: flex; - align-items: center; - height: calc(1em * var(--sbb-typo-line-height-body-text)); -} - -.sbb-checkbox__label--icon { - color: var(--sbb-checkbox-label-icon-color); - - :host([icon-placement='end']) & { - margin-inline-start: auto; - } - - :host( - :is( - /** No icon */ - :not([icon-name], [data-slot-names~="icon"]), - /** In selection panel */ - [data-is-selection-panel-input] - ) - ) & { - display: none; - } -} - .sbb-checkbox__label { - display: flex; - gap: var(--sbb-checkbox-label-gap); - color: var(--sbb-checkbox-label-color); - - // Fix for Chrome and Safari, they approximate 23.8px to 23px for line-height - line-height: max((1em * var(--sbb-typo-line-height-body-text)), var(--sbb-checkbox-dimension)); - :host([icon-placement='start']) & { flex-direction: row-reverse; justify-content: flex-end; @@ -159,9 +41,3 @@ slot[name='subtext'] { flex-grow: 1; } } - -.sbb-checkbox__expanded-label { - :host(:not([data-is-selection-panel-input][data-has-selection-panel-label])) & { - display: none; - } -} diff --git a/src/elements/checkbox/checkbox/checkbox.spec.ts b/src/elements/checkbox/checkbox/checkbox.spec.ts index dc3c3a496e..ae0f68e9f0 100644 --- a/src/elements/checkbox/checkbox/checkbox.spec.ts +++ b/src/elements/checkbox/checkbox/checkbox.spec.ts @@ -1,23 +1,11 @@ -import { assert, expect } from '@open-wc/testing'; -import { a11ySnapshot, sendKeys } from '@web/test-runner-commands'; +import { assert } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; -import type { Context } from 'mocha'; -import { isChromium, isFirefox } from '../../core/dom.js'; import { fixture } from '../../core/testing/private.js'; -import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; -import type { SbbVisualCheckboxElement } from '../../visual-checkbox.js'; import { SbbCheckboxElement } from './checkbox.js'; -interface CheckboxAccessibilitySnapshot { - checked: boolean; - role: string; - disabled: boolean; - required: boolean; -} - -describe(`sbb-checkbox`, () => { +describe(`sbb-checkbox with`, () => { describe('general', () => { let element: SbbCheckboxElement; @@ -28,968 +16,7 @@ describe(`sbb-checkbox`, () => { it('should render', async () => { assert.instanceOf(element, SbbCheckboxElement); }); - - it('should not render accessibility label containing expanded state', async () => { - expect( - getComputedStyle( - element.shadowRoot!.querySelector('.sbb-checkbox__expanded-label')!, - ).getPropertyValue('display'), - ).to.be.equal('none'); - }); - - describe('events', () => { - it('emit event on click', async () => { - expect(element).not.to.have.attribute('checked'); - const changeSpy = new EventSpy('change'); - - element.click(); - await waitForLitRender(element); - - expect(changeSpy.count).to.be.greaterThan(0); - expect(element).not.to.have.attribute('checked'); - expect(element.checked).to.equal(true); - }); - - it('emit event on keypress', async () => { - const changeSpy = new EventSpy('change'); - - element.focus(); - await sendKeys({ press: 'Space' }); - - await waitForCondition(() => changeSpy.count === 1); - expect(changeSpy.count).to.be.greaterThan(0); - }); - }); - - it('should prevent scrolling on space bar press', async () => { - const root = await fixture( - html`

-
- -
-
`, - ); - element = root.querySelector('sbb-checkbox')!; - - expect(element.checked).to.be.false; - expect(root.scrollTop).to.be.equal(0); - - element.focus(); - await sendKeys({ press: ' ' }); - await waitForLitRender(element); - - await waitForCondition(() => element.checked); - expect(root.scrollTop).to.be.equal(0); - }); - - it('should reflect aria-required false', async () => { - const snapshot = (await a11ySnapshot({ - selector: 'sbb-checkbox', - })) as unknown as CheckboxAccessibilitySnapshot; - - expect(snapshot.required).to.be.undefined; - }); - - it('should reflect accessibility tree setting required attribute to true', async function (this: Context) { - // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. - this.retries(3); - - element.toggleAttribute('required', true); - await waitForLitRender(element); - - const snapshot = (await a11ySnapshot({ - selector: 'sbb-checkbox', - })) as unknown as CheckboxAccessibilitySnapshot; - - // TODO: Recheck if it is working in Chromium - if (!isChromium) { - expect(snapshot.required).to.be.true; - } - }); - - it('should reflect accessibility tree setting required attribute to false', async function (this: Context) { - // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. - this.retries(3); - - element.toggleAttribute('required', true); - await waitForLitRender(element); - - element.removeAttribute('required'); - await waitForLitRender(element); - - const snapshot = (await a11ySnapshot({ - selector: 'sbb-checkbox', - })) as unknown as CheckboxAccessibilitySnapshot; - - expect(snapshot.required).not.to.be.ok; - }); - - it('should reflect accessibility tree setting required property to true', async function (this: Context) { - // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. - this.retries(3); - - element.required = true; - await waitForLitRender(element); - - const snapshot = (await a11ySnapshot({ - selector: 'sbb-checkbox', - })) as unknown as CheckboxAccessibilitySnapshot; - - // TODO: Recheck if it is working in Chromium - if (!isChromium) { - expect(snapshot.required).to.be.true; - } - }); - - it('should reflect accessibility tree setting required property to false', async function (this: Context) { - // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. - this.retries(3); - - element.required = true; - await waitForLitRender(element); - - element.required = false; - await waitForLitRender(element); - - const snapshot = (await a11ySnapshot({ - selector: 'sbb-checkbox', - })) as unknown as CheckboxAccessibilitySnapshot; - - expect(snapshot.required).not.to.be.ok; - }); - - it('should should restore form state on formStateRestoreCallback()', async () => { - // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. - element.formStateRestoreCallback('true', 'restore'); - await waitForLitRender(element); - - expect(element.checked).to.be.true; - - element.formStateRestoreCallback('false', 'restore'); - await waitForLitRender(element); - - expect(element.checked).to.be.false; - }); - - it('should ignore interaction when disabled', async () => { - const inputSpy = new EventSpy('input', element); - const changeSpy = new EventSpy('change', element); - element.disabled = true; - await waitForLitRender(element); - - element.focus(); - element.click(); - await sendKeys({ up: 'Space' }); - - expect(inputSpy.count).to.be.equal(0); - expect(changeSpy.count).to.be.equal(0); - expect(element.checked).to.be.false; - }); }); - describe('comparing with native checkbox', () => { - let element: HTMLInputElement | SbbCheckboxElement, - form: HTMLFormElement, - fieldset: HTMLFieldSetElement, - formResetButton: HTMLButtonElement, - inputSpy: EventSpy, - changeSpy: EventSpy; - - interface CheckboxAssertionState { - checkedAttribute: boolean; - checkedProperty: boolean; - indeterminateProperty: boolean; - ariaChecked: boolean | 'mixed'; - inputEventCount: number; - changeEventCount: number; - } - - const assertState = async (assertions: CheckboxAssertionState): Promise => { - if (assertions.checkedAttribute) { - expect(element).to.have.attribute('checked'); - } else { - expect(element).not.to.have.attribute('checked'); - } - expect(element.checked, `checked property`).to.be.equal(assertions.checkedProperty); - expect(element.indeterminate, `indeterminate property`).to.be.equal( - assertions.indeterminateProperty, - ); - - const snapshot = (await a11ySnapshot({ - selector: element.localName, - })) as unknown as CheckboxAccessibilitySnapshot; - - expect(snapshot.role).to.equal('checkbox'); - - expect(snapshot.checked, `ariaChecked in ${JSON.stringify(snapshot)}`).to.be.equal( - isFirefox && assertions.ariaChecked === false ? undefined : assertions.ariaChecked, - ); - - expect(inputSpy.count, `'input' event`).to.be.equal(assertions.inputEventCount); - expect(changeSpy.count, `'change' event`).to.be.equal(assertions.changeEventCount); - - // Form state should always correspond to checked property. - const formData = new FormData(form); - expect(formData.get(element.localName)).to.be.equal( - assertions.checkedProperty ? element.localName : null, - ); - }; - - ['input', 'sbb-checkbox'].forEach((selector) => { - describe(selector, () => { - describe('with initially unchecked attribute', () => { - beforeEach(async () => { - form = await fixture( - html`
-
- Label - -
- -
`, - ); - - element = form.querySelector(selector)!; - fieldset = form.querySelector('fieldset')!; - formResetButton = form.querySelector(`button[type='reset']`)!; - inputSpy = new EventSpy('input', element); - changeSpy = new EventSpy('change', element); - }); - - it('should not have checked attribute initially', async () => { - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect state after clicking', async () => { - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 1, - changeEventCount: 1, - }); - - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 2, - changeEventCount: 2, - }); - }); - - it('should reflect state after programmatic change', async () => { - element.checked = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = false; - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should no longer interpret attribute after programmatic change', async () => { - element.checked = true; - await waitForLitRender(form); - - element.checked = false; - await waitForLitRender(form); - - element.toggleAttribute('checked', true); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should interpret attribute after programmatic change and reset', async () => { - // Simulate programmatic change (according to previous test, now attribute mutation is blocked) - element.checked = true; - await waitForLitRender(form); - - // When resetting the form - formResetButton.click(); - await waitForLitRender(form); - - // State should be reset - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - // When performing - element.toggleAttribute('checked', true); - await waitForLitRender(form); - - // Attribute should be considered - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - // When we are manipulating again - element.checked = false; - await waitForLitRender(form); - - // Attribute mutation should be blocked again - element.toggleAttribute('checked', true); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect state after adding attribute', async () => { - element.toggleAttribute('checked', true); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.removeAttribute('checked'); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect indeterminate state', async () => { - element.indeterminate = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = false; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 1, - changeEventCount: 1, - }); - - element.indeterminate = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: true, - inputEventCount: 1, - changeEventCount: 1, - }); - }); - - it('should reset form controls by resetting programmatically', async () => { - element.checked = true; - await waitForLitRender(form); - - form.reset(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reset form controls by reset button click', async () => { - element.checked = true; - await waitForLitRender(form); - - formResetButton.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should find connected form', () => { - expect(element.form).to.be.equal(form); - }); - - describe('disabled state', () => { - interface DisabledCheckboxAssertionState { - disabledAttribute: boolean; - disabledProperty: boolean; - ariaDisabled: true | undefined; - disabledSelector: boolean; - focusable: boolean; - } - - const assertDisabledState = async ( - assertions: DisabledCheckboxAssertionState, - ): Promise => { - expect(element.disabled, 'disabled property').to.be.equal( - assertions.disabledProperty, - ); - - if (assertions.disabledAttribute) { - expect(element).to.have.attribute('disabled'); - } else { - expect(element).not.to.have.attribute('disabled'); - } - - const disabledElements = Array.from(form.querySelectorAll(':disabled')); - - expect(disabledElements.includes(element), ':disabled selector').to.be.equal( - assertions.disabledSelector, - ); - - const snapshot = (await a11ySnapshot({ - selector: element.localName, - })) as unknown as CheckboxAccessibilitySnapshot; - expect(snapshot.disabled, `ariaDisabled in ${JSON.stringify(snapshot)}`).to.be.equal( - assertions.ariaDisabled, - ); - - element.focus(); - if (assertions.focusable) { - expect(document.activeElement).to.be.equal(element); - } else { - expect(document.activeElement).not.to.be.equal(element); - } - }; - - it('should be disabled if fieldset was disabled by attribute', async () => { - fieldset.toggleAttribute('disabled', true); - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: true, - ariaDisabled: true, - focusable: false, - }); - - if (selector === 'sbb-checkbox') { - expect( - element.shadowRoot!.querySelector( - 'sbb-visual-checkbox', - )!.disabled, - ).to.be.true; - } - }); - - it('should be disabled if fieldset was disabled by property', async () => { - fieldset.disabled = true; - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: true, - ariaDisabled: true, - focusable: false, - }); - - if (selector === 'sbb-checkbox') { - expect( - element.shadowRoot!.querySelector( - 'sbb-visual-checkbox', - )!.disabled, - ).to.be.true; - } - }); - - it('should be enabled if fieldset was enabled by attribute', async () => { - fieldset.toggleAttribute('disabled', true); - await waitForLitRender(form); - - fieldset.removeAttribute('disabled'); - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: false, - ariaDisabled: undefined, - focusable: true, - }); - - if (selector === 'sbb-checkbox') { - expect( - element.shadowRoot!.querySelector( - 'sbb-visual-checkbox', - )!.disabled, - ).to.be.false; - } - }); - - it('should be enabled if fieldset was enabled by property', async () => { - fieldset.disabled = true; - await waitForLitRender(form); - - fieldset.disabled = false; - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: false, - ariaDisabled: undefined, - focusable: true, - }); - if (selector === 'sbb-checkbox') { - expect( - element.shadowRoot!.querySelector( - 'sbb-visual-checkbox', - )!.disabled, - ).to.be.false; - } - }); - - it('should be disabled by attribute', async () => { - element.toggleAttribute('disabled', true); - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: true, - disabledAttribute: true, - disabledSelector: true, - ariaDisabled: true, - focusable: false, - }); - - element.removeAttribute('disabled'); - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: false, - ariaDisabled: undefined, - focusable: true, - }); - }); - - it('should be disabled by property', async () => { - element.disabled = true; - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: true, - disabledAttribute: true, - disabledSelector: true, - ariaDisabled: true, - focusable: false, - }); - - element.disabled = false; - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: false, - ariaDisabled: undefined, - focusable: true, - }); - }); - - it('should sync disabled attribute after re-enabling by property', async () => { - element.toggleAttribute('disabled', true); - await waitForLitRender(form); - - element.disabled = false; - await waitForLitRender(form); - - await assertDisabledState({ - disabledProperty: false, - disabledAttribute: false, - disabledSelector: false, - ariaDisabled: undefined, - focusable: true, - }); - }); - }); - }); - - describe('with initially checked attribute', () => { - beforeEach(async () => { - form = await fixture( - html`
-
- - Label - - -
- -
`, - ); - - element = form.querySelector(selector)!; - fieldset = form.querySelector('fieldset')!; - formResetButton = form.querySelector(`button[type='reset']`)!; - inputSpy = new EventSpy('input', element); - changeSpy = new EventSpy('change', element); - }); - - it('should have checked attribute initially', async () => { - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect state after clicking', async () => { - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 1, - changeEventCount: 1, - }); - - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 2, - changeEventCount: 2, - }); - }); - - it('should reflect state after programmatic change', async () => { - element.checked = false; - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should no longer interpret attribute after programmatic change', async () => { - element.checked = false; - await waitForLitRender(form); - - element.checked = true; - element.removeAttribute('checked'); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should interpret attribute after programmatic change and reset', async () => { - // Simulate programmatic change (according to previous test, now attribute mutation is blocked) - element.checked = false; - await waitForLitRender(form); - - // When resetting the form - formResetButton.click(); - await waitForLitRender(form); - - // State should be reset - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - // When performing - element.removeAttribute('checked'); - await waitForLitRender(form); - - // Attribute should be considered - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - // When we are manipulating again - element.checked = true; - await waitForLitRender(form); - - // Attribute mutation should be blocked again - element.removeAttribute('checked'); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: false, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect state after removing attribute', async () => { - element.removeAttribute('checked'); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: false, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.toggleAttribute('checked', true); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reflect indeterminate state', async () => { - element.indeterminate = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = false; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.checked = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: true, - inputEventCount: 0, - changeEventCount: 0, - }); - - element.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: false, - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: false, - inputEventCount: 1, - changeEventCount: 1, - }); - - element.indeterminate = true; - await waitForLitRender(form); - - await assertState({ - ariaChecked: 'mixed', - checkedAttribute: true, - checkedProperty: false, - indeterminateProperty: true, - inputEventCount: 1, - changeEventCount: 1, - }); - }); - - it('should reset form controls by resetting programmatically', async () => { - element.checked = false; - await waitForLitRender(form); - - form.reset(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - - it('should reset form controls by reset button click', async () => { - element.checked = false; - await waitForLitRender(form); - - formResetButton.click(); - await waitForLitRender(form); - - await assertState({ - ariaChecked: true, - checkedAttribute: true, - checkedProperty: true, - indeterminateProperty: false, - inputEventCount: 0, - changeEventCount: 0, - }); - }); - }); - }); - }); - }); + // All the functionalities of sbb-checkbox are tested in checkbox-common.e2e.ts file }); diff --git a/src/elements/checkbox/checkbox/checkbox.ts b/src/elements/checkbox/checkbox/checkbox.ts index a84ad9dcf7..9e0b72cc8c 100644 --- a/src/elements/checkbox/checkbox/checkbox.ts +++ b/src/elements/checkbox/checkbox/checkbox.ts @@ -1,202 +1,43 @@ -import { - type CSSResultGroup, - html, - LitElement, - type PropertyValues, - type TemplateResult, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { LitElement, html, type CSSResultGroup, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; -import { SbbLanguageController, SbbSlotStateController } from '../../core/controllers.js'; -import { EventEmitter } from '../../core/eventing.js'; -import { i18nCollapsed, i18nExpanded } from '../../core/i18n.js'; -import type { - SbbCheckedStateChange, - SbbDisabledStateChange, - SbbIconPlacement, - SbbStateChange, -} from '../../core/interfaces.js'; -import { - SbbFormAssociatedCheckboxMixin, - SbbHydrationMixin, - SbbUpdateSchedulerMixin, -} from '../../core/mixins.js'; +import { SbbSlotStateController } from '../../core/controllers.js'; +import type { SbbIconPlacement } from '../../core/interfaces.js'; import { SbbIconNameMixin } from '../../icon.js'; -import type { SbbSelectionPanelElement } from '../../selection-panel.js'; -import type { SbbCheckboxGroupElement } from '../checkbox-group.js'; +import { SbbCheckboxCommonElementMixin, checkboxCommonStyle } from '../common.js'; -import style from './checkbox.scss?lit&inline'; - -import '../../screen-reader-only.js'; import '../../visual-checkbox.js'; -export type SbbCheckboxStateChange = Extract< - SbbStateChange, - SbbDisabledStateChange | SbbCheckedStateChange ->; - -export type SbbCheckboxSize = 's' | 'm'; +import checkboxStyle from './checkbox.scss?lit&inline'; /** * It displays a checkbox enhanced with the SBB Design. * * @slot - Use the unnamed slot to add content to the `sbb-checkbox`. * @slot icon - Slot used to render the checkbox icon (disabled inside a selection panel). - * @slot subtext - Slot used to render a subtext under the label (only visible within a selection panel). - * @slot suffix - Slot used to render additional content after the label (only visible within a selection panel). * @event {CustomEvent} didChange - Deprecated. used for React. Will probably be removed once React 19 is available. * @event {Event} change - Event fired on change. * @event {InputEvent} input - Event fired on input. */ @customElement('sbb-checkbox') -export class SbbCheckboxElement extends SbbUpdateSchedulerMixin( - SbbFormAssociatedCheckboxMixin(SbbIconNameMixin(SbbHydrationMixin(LitElement))), +export class SbbCheckboxElement extends SbbCheckboxCommonElementMixin( + SbbIconNameMixin(LitElement), ) { - public static override styles: CSSResultGroup = style; + public static override styles: CSSResultGroup = [checkboxCommonStyle, checkboxStyle]; + public static readonly events = { didChange: 'didChange', - stateChange: 'stateChange', - checkboxLoaded: 'checkboxLoaded', } as const; - /** Whether the checkbox is indeterminate. */ - @property({ type: Boolean }) public indeterminate = false; - /** The label position relative to the labelIcon. Defaults to end */ @property({ attribute: 'icon-placement', reflect: true }) public iconPlacement: SbbIconPlacement = 'end'; - /** Label size variant, either m or s. */ - @property({ reflect: true }) - public set size(value: SbbCheckboxSize) { - this._size = value; - } - public get size(): SbbCheckboxSize { - return this.group?.size ?? this._size; - } - private _size: SbbCheckboxSize = 'm'; - - /** Reference to the connected checkbox group. */ - public get group(): SbbCheckboxGroupElement | null { - return this._group; - } - private _group: SbbCheckboxGroupElement | null = null; - - /** - * Whether the input is the main input of a selection panel. - * @internal - */ - public get isSelectionPanelInput(): boolean { - return this.hasAttribute('data-is-selection-panel-input'); - } - - /** The label describing whether the selection panel is expanded (for screen readers only). */ - @state() private _selectionPanelExpandedLabel!: string; - - private _language = new SbbLanguageController(this); - private _selectionPanelElement: SbbSelectionPanelElement | null = null; - - /** - * @internal - * Internal event that emits whenever the state of the checkbox - * in relation to the parent selection panel changes. - */ - private _stateChange: EventEmitter = new EventEmitter( - this, - SbbCheckboxElement.events.stateChange, - { bubbles: true }, - ); - - /** - * @internal - * Internal event that emits when the checkbox is loaded. - */ - private _checkboxLoaded: EventEmitter = new EventEmitter( - this, - SbbCheckboxElement.events.checkboxLoaded, - { bubbles: true }, - ); - public constructor() { super(); new SbbSlotStateController(this); } - public override connectedCallback(): void { - super.connectedCallback(); - this._group = this.closest('sbb-checkbox-group') as SbbCheckboxGroupElement; - // We can use closest here, as we expect the parent sbb-selection-panel to be in light DOM. - this._selectionPanelElement = this.closest?.('sbb-selection-panel'); - this.toggleAttribute('data-is-inside-selection-panel', !!this._selectionPanelElement); - this.toggleAttribute( - 'data-is-selection-panel-input', - !!this._selectionPanelElement && !this.closest?.('sbb-selection-panel [slot="content"]'), - ); - - this._checkboxLoaded.emit(); - - // We need to call requestUpdate to update the reflected attributes - ['disabled', 'required', 'size'].forEach((p) => this.requestUpdate(p)); - } - - protected override async willUpdate(changedProperties: PropertyValues): Promise { - super.willUpdate(changedProperties); - - if (changedProperties.has('checked')) { - if (this.isSelectionPanelInput && this.checked !== changedProperties.get('checked')!) { - this._stateChange.emit({ type: 'checked', checked: this.checked }); - this._updateExpandedLabel(); - } - } - if (changedProperties.has('disabled')) { - if (this.isSelectionPanelInput && this.disabled !== changedProperties.get('disabled')!) { - this._stateChange.emit({ type: 'disabled', disabled: this.disabled }); - } - } - if (changedProperties.has('checked') || changedProperties.has('indeterminate')) { - this.internals.ariaChecked = this.indeterminate ? 'mixed' : `${this.checked}`; - } - } - - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - - // We need to wait for the selection-panel to be fully initialized - this.startUpdate(); - setTimeout(() => { - this.isSelectionPanelInput && this._updateExpandedLabel(); - this.completeUpdate(); - }); - } - - protected override isDisabledExternally(): boolean { - return this.group?.disabled ?? false; - } - - protected override isRequiredExternally(): boolean { - return this.group?.required ?? false; - } - - protected override withUserInteraction(): void { - if (this.indeterminate) { - this.indeterminate = false; - } - } - - private async _updateExpandedLabel(): Promise { - await this.hydrationComplete; - if (!this._selectionPanelElement?.hasContent) { - this._selectionPanelExpandedLabel = ''; - this.removeAttribute('data-has-selection-panel-label'); - return; - } - - this._selectionPanelExpandedLabel = this.checked - ? ', ' + i18nExpanded[this._language.current] - : ', ' + i18nCollapsed[this._language.current]; - this.toggleAttribute('data-has-selection-panel-label', true); - } - protected override render(): TemplateResult { return html` @@ -211,14 +52,11 @@ export class SbbCheckboxElement extends SbbUpdateSchedulerMixin( - ${this.renderIconSlot()} - + ${this.renderIconSlot()} - - - ${this._selectionPanelExpandedLabel} - `; diff --git a/src/elements/checkbox/checkbox/readme.md b/src/elements/checkbox/checkbox/readme.md index 4260d1478f..a47cae7b99 100644 --- a/src/elements/checkbox/checkbox/readme.md +++ b/src/elements/checkbox/checkbox/readme.md @@ -43,7 +43,7 @@ The component can be displayed in `disabled` or `required` state by using the se ## Style -The component has two `size`, named `s` (default) and `m`. +The component has two `size`, named `s` and `m` (default). ```html Size @@ -103,9 +103,7 @@ If you don't want the label to appear next to the checkbox, you can use `aria-la ## Slots -| Name | Description | -| --------- | ----------------------------------------------------------------------------------------------- | -| | Use the unnamed slot to add content to the `sbb-checkbox`. | -| `icon` | Slot used to render the checkbox icon (disabled inside a selection panel). | -| `subtext` | Slot used to render a subtext under the label (only visible within a selection panel). | -| `suffix` | Slot used to render additional content after the label (only visible within a selection panel). | +| Name | Description | +| ------ | -------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the `sbb-checkbox`. | +| `icon` | Slot used to render the checkbox icon (disabled inside a selection panel). | diff --git a/src/elements/checkbox/common.ts b/src/elements/checkbox/common.ts new file mode 100644 index 0000000000..c9680209f2 --- /dev/null +++ b/src/elements/checkbox/common.ts @@ -0,0 +1,3 @@ +export * from './common/checkbox-common.js'; + +export { default as checkboxCommonStyle } from './common/checkbox-common.scss?lit&inline'; diff --git a/src/elements/checkbox/common/checkbox-common.scss b/src/elements/checkbox/common/checkbox-common.scss new file mode 100644 index 0000000000..67bd2d254d --- /dev/null +++ b/src/elements/checkbox/common/checkbox-common.scss @@ -0,0 +1,60 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-checkbox-dimension: var(--sbb-size-icon-ui-small); + --sbb-checkbox-label-color: var(--sbb-color-charcoal); + --sbb-checkbox-label-icon-color: var(--sbb-color-charcoal); + --sbb-checkbox-cursor: pointer; + --sbb-checkbox-label-gap: var(--sbb-spacing-fixed-2x); +} + +:host(:disabled) { + --sbb-checkbox-cursor: default; + --sbb-checkbox-label-color: var(--sbb-color-granite); +} + +.sbb-checkbox-wrapper { + display: flex; + + @include sbb.zero-width-space; +} + +.sbb-checkbox { + @include sbb.text-m--regular; + + position: relative; + display: block; + width: 100%; + cursor: var(--sbb-checkbox-cursor); + user-select: none; + -webkit-tap-highlight-color: transparent; + + :host([size='s']) & { + @include sbb.text-s--regular; + } +} + +.sbb-checkbox__inner { + display: flex; + align-items: start; + gap: var(--sbb-spacing-fixed-2x); +} + +.sbb-checkbox__label { + display: flex; + gap: var(--sbb-checkbox-label-gap); + flex-grow: 1; + color: var(--sbb-checkbox-label-color); + + // Fix for Chrome and Safari, they approximate 23.8px to 23px for line-height + line-height: max((1em * var(--sbb-typo-line-height-body-text)), var(--sbb-checkbox-dimension)); +} + +.sbb-checkbox__aligner { + display: flex; + align-items: center; + height: calc(1em * var(--sbb-typo-line-height-body-text)); +} diff --git a/src/elements/checkbox/common/checkbox-common.spec.ts b/src/elements/checkbox/common/checkbox-common.spec.ts new file mode 100644 index 0000000000..6a164e011a --- /dev/null +++ b/src/elements/checkbox/common/checkbox-common.spec.ts @@ -0,0 +1,997 @@ +import { expect } from '@open-wc/testing'; +import { a11ySnapshot, sendKeys } from '@web/test-runner-commands'; +import { html, unsafeStatic } from 'lit/static-html.js'; +import type { Context } from 'mocha'; + +import { isChromium, isFirefox } from '../../core/dom.js'; +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; +import type { SbbVisualCheckboxElement } from '../../visual-checkbox.js'; +import type { SbbCheckboxPanelElement } from '../checkbox-panel.js'; +import type { SbbCheckboxElement } from '../checkbox.js'; + +import '../checkbox.js'; +import '../checkbox-panel.js'; + +interface CheckboxAccessibilitySnapshot { + checked: boolean; + role: string; + disabled: boolean; + required: boolean; +} + +describe(`checkbox common behaviors`, () => { + ['sbb-checkbox', 'sbb-checkbox-panel'].forEach((selector) => { + const tagSingle = unsafeStatic(selector); + + describe(`${selector} general`, () => { + let element: SbbCheckboxElement | SbbCheckboxPanelElement; + + beforeEach(async () => { + /* eslint-disable lit/binding-positions */ + element = await fixture(html`<${tagSingle} name="name" value="value">Label`); + }); + + describe('events', () => { + it('emit event on click', async () => { + expect(element).not.to.have.attribute('checked'); + const changeSpy = new EventSpy('change'); + + element.click(); + await waitForLitRender(element); + + expect(changeSpy.count).to.be.greaterThan(0); + expect(element).not.to.have.attribute('checked'); + expect(element.checked).to.equal(true); + }); + + it('emit event on keypress', async () => { + const changeSpy = new EventSpy('change'); + + element.focus(); + await sendKeys({ press: 'Space' }); + + await waitForCondition(() => changeSpy.count === 1); + expect(changeSpy.count).to.be.greaterThan(0); + }); + }); + + it('should prevent scrolling on space bar press', async () => { + const root = await fixture( + html`
+
+ <${tagSingle}> +
+
`, + ); + element = root.querySelector(selector)!; + + expect(element.checked).to.be.false; + expect(root.scrollTop).to.be.equal(0); + + element.focus(); + await sendKeys({ press: ' ' }); + await waitForLitRender(element); + + await waitForCondition(() => element.checked); + expect(root.scrollTop).to.be.equal(0); + }); + + it('should reflect aria-required false', async () => { + const snapshot = (await a11ySnapshot({ + selector: selector, + })) as unknown as CheckboxAccessibilitySnapshot; + + expect(snapshot.required).to.be.undefined; + }); + + it('should reflect accessibility tree setting required attribute to true', async function (this: Context) { + // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. + this.retries(3); + + element.toggleAttribute('required', true); + await waitForLitRender(element); + + const snapshot = (await a11ySnapshot({ + selector: selector, + })) as unknown as CheckboxAccessibilitySnapshot; + + // TODO: Recheck if it is working in Chromium + if (!isChromium) { + expect(snapshot.required).to.be.true; + } + }); + + it('should reflect accessibility tree setting required attribute to false', async function (this: Context) { + // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. + this.retries(3); + + element.toggleAttribute('required', true); + await waitForLitRender(element); + + element.removeAttribute('required'); + await waitForLitRender(element); + + const snapshot = (await a11ySnapshot({ + selector: selector, + })) as unknown as CheckboxAccessibilitySnapshot; + + expect(snapshot.required).not.to.be.ok; + }); + + it('should reflect accessibility tree setting required property to true', async function (this: Context) { + // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. + this.retries(3); + + element.required = true; + await waitForLitRender(element); + + const snapshot = (await a11ySnapshot({ + selector: selector, + })) as unknown as CheckboxAccessibilitySnapshot; + + // TODO: Recheck if it is working in Chromium + if (!isChromium) { + expect(snapshot.required).to.be.true; + } + }); + + it('should reflect accessibility tree setting required property to false', async function (this: Context) { + // On Firefox sometimes a11ySnapshot fails. Retrying three times should stabilize the build. + this.retries(3); + + element.required = true; + await waitForLitRender(element); + + element.required = false; + await waitForLitRender(element); + + const snapshot = (await a11ySnapshot({ + selector: selector, + })) as unknown as CheckboxAccessibilitySnapshot; + + expect(snapshot.required).not.to.be.ok; + }); + + it('should should restore form state on formStateRestoreCallback()', async () => { + // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. + element.formStateRestoreCallback('true', 'restore'); + await waitForLitRender(element); + + expect(element.checked).to.be.true; + + element.formStateRestoreCallback('false', 'restore'); + await waitForLitRender(element); + + expect(element.checked).to.be.false; + }); + + it('should ignore interaction when disabled', async () => { + const inputSpy = new EventSpy('input', element); + const changeSpy = new EventSpy('change', element); + element.disabled = true; + await waitForLitRender(element); + + element.focus(); + element.click(); + await sendKeys({ up: 'Space' }); + + expect(inputSpy.count).to.be.equal(0); + expect(changeSpy.count).to.be.equal(0); + expect(element.checked).to.be.false; + }); + }); + }); + + describe('comparing with native checkbox', () => { + let element: HTMLInputElement | SbbCheckboxPanelElement | SbbCheckboxElement, + form: HTMLFormElement, + fieldset: HTMLFieldSetElement, + formResetButton: HTMLButtonElement, + inputSpy: EventSpy, + changeSpy: EventSpy; + + interface CheckboxAssertionState { + checkedAttribute: boolean; + checkedProperty: boolean; + indeterminateProperty: boolean; + ariaChecked: boolean | 'mixed'; + inputEventCount: number; + changeEventCount: number; + } + + const assertState = async (assertions: CheckboxAssertionState): Promise => { + if (assertions.checkedAttribute) { + expect(element).to.have.attribute('checked'); + } else { + expect(element).not.to.have.attribute('checked'); + } + expect(element.checked, `checked property`).to.be.equal(assertions.checkedProperty); + expect(element.indeterminate, `indeterminate property`).to.be.equal( + assertions.indeterminateProperty, + ); + + const snapshot = (await a11ySnapshot({ + selector: element.localName, + })) as unknown as CheckboxAccessibilitySnapshot; + + expect(snapshot.role).to.equal('checkbox'); + + expect(snapshot.checked, `ariaChecked in ${JSON.stringify(snapshot)}`).to.be.equal( + isFirefox && assertions.ariaChecked === false ? undefined : assertions.ariaChecked, + ); + + expect(inputSpy.count, `'input' event`).to.be.equal(assertions.inputEventCount); + expect(changeSpy.count, `'change' event`).to.be.equal(assertions.changeEventCount); + + // Form state should always correspond to checked property. + const formData = new FormData(form); + expect(formData.get(element.localName)).to.be.equal( + assertions.checkedProperty ? element.localName : null, + ); + }; + + ['input', 'sbb-checkbox', 'sbb-checkbox-panel'].forEach((selector) => { + describe(selector, () => { + describe('with initially unchecked attribute', () => { + beforeEach(async () => { + form = await fixture( + html`
+
+ Label + Label + +
+ +
`, + ); + + element = form.querySelector(selector)!; + fieldset = form.querySelector('fieldset')!; + formResetButton = form.querySelector(`button[type='reset']`)!; + inputSpy = new EventSpy('input', element); + changeSpy = new EventSpy('change', element); + }); + + it('should not have checked attribute initially', async () => { + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect state after clicking', async () => { + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 1, + changeEventCount: 1, + }); + + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 2, + changeEventCount: 2, + }); + }); + + it('should reflect state after programmatic change', async () => { + element.checked = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = false; + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should no longer interpret attribute after programmatic change', async () => { + element.checked = true; + await waitForLitRender(form); + + element.checked = false; + await waitForLitRender(form); + + element.toggleAttribute('checked', true); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should interpret attribute after programmatic change and reset', async () => { + // Simulate programmatic change (according to previous test, now attribute mutation is blocked) + element.checked = true; + await waitForLitRender(form); + + // When resetting the form + formResetButton.click(); + await waitForLitRender(form); + + // State should be reset + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + // When performing + element.toggleAttribute('checked', true); + await waitForLitRender(form); + + // Attribute should be considered + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + // When we are manipulating again + element.checked = false; + await waitForLitRender(form); + + // Attribute mutation should be blocked again + element.toggleAttribute('checked', true); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect state after adding attribute', async () => { + element.toggleAttribute('checked', true); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.removeAttribute('checked'); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect indeterminate state', async () => { + element.indeterminate = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = false; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 1, + changeEventCount: 1, + }); + + element.indeterminate = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: true, + inputEventCount: 1, + changeEventCount: 1, + }); + }); + + it('should reset form controls by resetting programmatically', async () => { + element.checked = true; + await waitForLitRender(form); + + form.reset(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reset form controls by reset button click', async () => { + element.checked = true; + await waitForLitRender(form); + + formResetButton.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should find connected form', () => { + expect(element.form).to.be.equal(form); + }); + + describe('disabled state', () => { + interface DisabledCheckboxAssertionState { + disabledAttribute: boolean; + disabledProperty: boolean; + ariaDisabled: true | undefined; + disabledSelector: boolean; + focusable: boolean; + } + + const assertDisabledState = async ( + assertions: DisabledCheckboxAssertionState, + ): Promise => { + expect(element.disabled, 'disabled property').to.be.equal( + assertions.disabledProperty, + ); + + if (assertions.disabledAttribute) { + expect(element).to.have.attribute('disabled'); + } else { + expect(element).not.to.have.attribute('disabled'); + } + + const disabledElements = Array.from(form.querySelectorAll(':disabled')); + + expect(disabledElements.includes(element), ':disabled selector').to.be.equal( + assertions.disabledSelector, + ); + + const snapshot = (await a11ySnapshot({ + selector: element.localName, + })) as unknown as CheckboxAccessibilitySnapshot; + expect(snapshot.disabled, `ariaDisabled in ${JSON.stringify(snapshot)}`).to.be.equal( + assertions.ariaDisabled, + ); + + element.focus(); + if (assertions.focusable) { + expect(document.activeElement).to.be.equal(element); + } else { + expect(document.activeElement).not.to.be.equal(element); + } + }; + + it('should be disabled if fieldset was disabled by attribute', async () => { + fieldset.toggleAttribute('disabled', true); + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: true, + ariaDisabled: true, + focusable: false, + }); + + if (selector === 'sbb-checkbox-panel') { + expect( + element.shadowRoot!.querySelector( + 'sbb-visual-checkbox', + )!.disabled, + ).to.be.true; + } + }); + + it('should be disabled if fieldset was disabled by property', async () => { + fieldset.disabled = true; + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: true, + ariaDisabled: true, + focusable: false, + }); + + if (selector === 'sbb-checkbox-panel') { + expect( + element.shadowRoot!.querySelector( + 'sbb-visual-checkbox', + )!.disabled, + ).to.be.true; + } + }); + + it('should be enabled if fieldset was enabled by attribute', async () => { + fieldset.toggleAttribute('disabled', true); + await waitForLitRender(form); + + fieldset.removeAttribute('disabled'); + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: false, + ariaDisabled: undefined, + focusable: true, + }); + + if (selector === 'sbb-checkbox-panel') { + expect( + element.shadowRoot!.querySelector( + 'sbb-visual-checkbox', + )!.disabled, + ).to.be.false; + } + }); + + it('should be enabled if fieldset was enabled by property', async () => { + fieldset.disabled = true; + await waitForLitRender(form); + + fieldset.disabled = false; + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: false, + ariaDisabled: undefined, + focusable: true, + }); + if (selector === 'sbb-checkbox-panel') { + expect( + element.shadowRoot!.querySelector( + 'sbb-visual-checkbox', + )!.disabled, + ).to.be.false; + } + }); + + it('should be disabled by attribute', async () => { + element.toggleAttribute('disabled', true); + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: true, + disabledAttribute: true, + disabledSelector: true, + ariaDisabled: true, + focusable: false, + }); + + element.toggleAttribute('disabled', false); + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: false, + ariaDisabled: undefined, + focusable: true, + }); + }); + + it('should be disabled by property', async () => { + element.disabled = true; + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: true, + disabledAttribute: true, + disabledSelector: true, + ariaDisabled: true, + focusable: false, + }); + + element.disabled = false; + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: false, + ariaDisabled: undefined, + focusable: true, + }); + }); + + it('should sync disabled attribute after re-enabling by property', async () => { + element.toggleAttribute('disabled', true); + await waitForLitRender(form); + + element.disabled = false; + await waitForLitRender(form); + + await assertDisabledState({ + disabledProperty: false, + disabledAttribute: false, + disabledSelector: false, + ariaDisabled: undefined, + focusable: true, + }); + }); + }); + }); + + describe('with initially checked attribute', () => { + beforeEach(async () => { + form = await fixture( + html`
+
+ + Label + + + Label + + +
+ +
`, + ); + + element = form.querySelector(selector)!; + fieldset = form.querySelector('fieldset')!; + formResetButton = form.querySelector(`button[type='reset']`)!; + inputSpy = new EventSpy('input', element); + changeSpy = new EventSpy('change', element); + }); + + it('should have checked attribute initially', async () => { + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect state after clicking', async () => { + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 1, + changeEventCount: 1, + }); + + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 2, + changeEventCount: 2, + }); + }); + + it('should reflect state after programmatic change', async () => { + element.checked = false; + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should no longer interpret attribute after programmatic change', async () => { + element.checked = false; + await waitForLitRender(form); + + element.checked = true; + element.removeAttribute('checked'); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should interpret attribute after programmatic change and reset', async () => { + // Simulate programmatic change (according to previous test, now attribute mutation is blocked) + element.checked = false; + await waitForLitRender(form); + + // When resetting the form + formResetButton.click(); + await waitForLitRender(form); + + // State should be reset + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + // When performing + element.toggleAttribute('checked', false); + await waitForLitRender(form); + + // Attribute should be considered + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + // When we are manipulating again + element.checked = true; + await waitForLitRender(form); + + // Attribute mutation should be blocked again + element.toggleAttribute('checked', false); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: false, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect state after removing attribute', async () => { + element.removeAttribute('checked'); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: false, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.toggleAttribute('checked', true); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reflect indeterminate state', async () => { + element.indeterminate = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = false; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.checked = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: true, + inputEventCount: 0, + changeEventCount: 0, + }); + + element.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: false, + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: false, + inputEventCount: 1, + changeEventCount: 1, + }); + + element.indeterminate = true; + await waitForLitRender(form); + + await assertState({ + ariaChecked: 'mixed', + checkedAttribute: true, + checkedProperty: false, + indeterminateProperty: true, + inputEventCount: 1, + changeEventCount: 1, + }); + }); + + it('should reset form controls by resetting programmatically', async () => { + element.checked = false; + await waitForLitRender(form); + + form.reset(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + + it('should reset form controls by reset button click', async () => { + element.checked = false; + await waitForLitRender(form); + + formResetButton.click(); + await waitForLitRender(form); + + await assertState({ + ariaChecked: true, + checkedAttribute: true, + checkedProperty: true, + indeterminateProperty: false, + inputEventCount: 0, + changeEventCount: 0, + }); + }); + }); + }); + }); + }); +}); diff --git a/src/elements/checkbox/common/checkbox-common.ts b/src/elements/checkbox/common/checkbox-common.ts new file mode 100644 index 0000000000..36cd4eea30 --- /dev/null +++ b/src/elements/checkbox/common/checkbox-common.ts @@ -0,0 +1,85 @@ +import type { LitElement, PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { + type Constructor, + type SbbDisabledMixinType, + SbbFormAssociatedCheckboxMixin, + type SbbFormAssociatedCheckboxMixinType, + type SbbRequiredMixinType, +} from '../../core/mixins.js'; +import type { SbbCheckboxGroupElement } from '../checkbox-group.js'; + +export type SbbCheckboxSize = 's' | 'm'; + +export declare class SbbCheckboxCommonElementMixinType + extends SbbFormAssociatedCheckboxMixinType + implements Partial, Partial +{ + public indeterminate: boolean; + + public set size(value: SbbCheckboxSize); + public get size(): SbbCheckboxSize; + + public get group(): SbbCheckboxGroupElement | null; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbCheckboxCommonElementMixin = >( + superClass: T, +): Constructor & T => { + abstract class SbbCheckboxCommonElement + extends SbbFormAssociatedCheckboxMixin(superClass) + implements Partial + { + /** Whether the checkbox is indeterminate. */ + @property({ type: Boolean }) public indeterminate = false; + + /** Label size variant, either m or s. */ + @property({ reflect: true }) + public set size(value: SbbCheckboxSize) { + this._size = value; + } + public get size(): SbbCheckboxSize { + return this.group?.size ?? this._size; + } + private _size: SbbCheckboxSize = 'm'; + + /** Reference to the connected checkbox group. */ + public get group(): SbbCheckboxGroupElement | null { + return this._group; + } + private _group: SbbCheckboxGroupElement | null = null; + + public override connectedCallback(): void { + super.connectedCallback(); + this._group = this.closest('sbb-checkbox-group') as SbbCheckboxGroupElement; + + // We need to call requestUpdate to update the reflected attributes + ['disabled', 'required', 'size'].forEach((p) => this.requestUpdate(p)); + } + + protected override async willUpdate(changedProperties: PropertyValues): Promise { + super.willUpdate(changedProperties); + + if (changedProperties.has('checked') || changedProperties.has('indeterminate')) { + this.internals.ariaChecked = this.indeterminate ? 'mixed' : `${this.checked}`; + } + } + + protected override isDisabledExternally(): boolean { + return this.group?.disabled ?? false; + } + + protected override isRequiredExternally(): boolean { + return this.group?.required ?? false; + } + + protected override withUserInteraction(): void { + if (this.indeterminate) { + this.indeterminate = false; + } + } + } + return SbbCheckboxCommonElement as unknown as Constructor & T; +}; diff --git a/src/elements/core/mixins.ts b/src/elements/core/mixins.ts index 2038021e61..dfb85b5223 100644 --- a/src/elements/core/mixins.ts +++ b/src/elements/core/mixins.ts @@ -5,5 +5,8 @@ export * from './mixins/form-associated-mixin.js'; export * from './mixins/hydration-mixin.js'; export * from './mixins/named-slot-list-mixin.js'; export * from './mixins/negative-mixin.js'; +export * from './mixins/panel-mixin.js'; export * from './mixins/required-mixin.js'; export * from './mixins/update-scheduler-mixin.js'; + +export { default as panelCommonStyle } from './mixins/panel-common.scss?lit&inline'; diff --git a/src/elements/core/mixins/panel-common.scss b/src/elements/core/mixins/panel-common.scss new file mode 100644 index 0000000000..c66ec18b9d --- /dev/null +++ b/src/elements/core/mixins/panel-common.scss @@ -0,0 +1,115 @@ +@use '../styles' as sbb; + +:host { + --sbb-selection-panel-background: var( + --sbb-selection-expansion-panel-inner-background, + var(--sbb-color-white) + ); + --sbb-selection-panel-border-color: var(--sbb-color-cloud); + + // We use --sbb-selection-expansion-panel-border-radius in case a user would define another border radius. + // The default is the same as here. + --sbb-selection-panel-border-radius: var( + --sbb-selection-expansion-panel-border-radius, + var(--sbb-border-radius-4x) + ); + --sbb-selection-panel-border-width: var( + --sbb-selection-expansion-panel-inner-border-width, + var(--sbb-border-width-1x) + ); + --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) + var(--sbb-spacing-responsive-xxs); + --sbb-selection-panel-animation-duration: var( + --sbb-disable-animation-zero-time, + var(--sbb-animation-duration-4x) + ); + --sbb-selection-panel-cursor: pointer; + --sbb-selection-panel-suffix-color: var(--sbb-color-charcoal); + --sbb-selection-panel-subtext-color: var(--sbb-color-granite); + + display: block; + + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; +} + +:host([color='milk']) { + --sbb-selection-panel-background: var( + --sbb-selection-expansion-panel-inner-background, + var(--sbb-color-milk) + ); +} + +:host([borderless]:not([data-checked])) { + --sbb-selection-panel-border-color: transparent; +} + +:host(:is([data-checked]):not(:disabled, [disabled])) { + --sbb-selection-panel-border-color: var(--sbb-color-charcoal); + --sbb-selection-panel-border-width: var( + --sbb-selection-expansion-panel-inner-border-width, + var(--sbb-border-width-2x) + ); +} + +:host(:is(:disabled, [disabled])) { + --sbb-selection-panel-cursor: default; +} + +.sbb-selection-panel { + display: block; + cursor: var(--sbb-selection-panel-cursor); + position: relative; + border-radius: var(--sbb-selection-panel-border-radius); + box-shadow: inset 0 0 0 var(--sbb-selection-panel-border-width) + var(--sbb-selection-panel-border-color); + padding: var(--sbb-selection-panel-input-padding); + background-color: var(--sbb-selection-panel-background); + + transition: { + duration: var(--sbb-selection-panel-animation-duration); + timing-function: var(--sbb-animation-easing); + property: box-shadow; + } + + // For high contrast mode we need a real border + @include sbb.if-forced-colors { + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + pointer-events: none; + border: var(--sbb-selection-panel-border-width) solid var(--sbb-selection-panel-border-color); + border-radius: var(--sbb-selection-panel-border-radius); + } + } + + :host(:focus-visible) & { + @include sbb.focus-outline; + } +} + +.sbb-selection-panel__badge { + user-select: none; + pointer-events: none; + position: absolute; + inset: 0; + border-radius: var(--sbb-selection-panel-border-radius); + overflow: hidden; +} + +slot[name='suffix'] { + color: var(--sbb-selection-panel-suffix-color); +} + +slot[name='subtext'] { + display: block; + color: var(--sbb-selection-panel-subtext-color); + padding-inline-start: var(--sbb-spacing-fixed-8x); + + :host(:not([data-slot-names~='subtext'])) & { + display: none; + } +} diff --git a/src/elements/core/mixins/panel-mixin.ts b/src/elements/core/mixins/panel-mixin.ts new file mode 100644 index 0000000000..688a5ad848 --- /dev/null +++ b/src/elements/core/mixins/panel-mixin.ts @@ -0,0 +1,53 @@ +import type { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { EventEmitter } from '../eventing.js'; + +import type { AbstractConstructor } from './constructor.js'; + +export declare class SbbPanelMixinType { + public color: 'white' | 'milk'; + public borderless: boolean; + public expansionState?: string; +} + +/** + * Mixin for common panel behaviors + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbPanelMixin = >( + superClass: T, +): AbstractConstructor & T => { + abstract class SbbPanelElement extends superClass implements SbbPanelMixinType { + public static readonly events = { + panelConnected: 'panelConnected', + } as const; + + /** The background color of the panel. */ + @property() public color: 'white' | 'milk' = 'white'; + + /** Whether the unselected panel has a border. */ + @property({ reflect: true, type: Boolean }) public borderless = false; + + /** @internal used for accessibility label when in expansion panel */ + @property() public expansionState?: string; + + /** + * @internal + * Internal event that emits when the checkbox is loaded. + */ + private _panelConnected: EventEmitter = new EventEmitter( + this, + SbbPanelElement.events.panelConnected, + { bubbles: true }, + ); + + public override connectedCallback(): void { + super.connectedCallback(); + + this._panelConnected.emit(); + } + } + + return SbbPanelElement as AbstractConstructor & T; +}; diff --git a/src/elements/radio-button.ts b/src/elements/radio-button.ts index 43d908e46d..5702306980 100644 --- a/src/elements/radio-button.ts +++ b/src/elements/radio-button.ts @@ -1,2 +1,4 @@ export * from './radio-button/radio-button.js'; export * from './radio-button/radio-button-group.js'; +export * from './radio-button/radio-button-panel.js'; +export * from './radio-button/common.js'; diff --git a/src/elements/radio-button/common.ts b/src/elements/radio-button/common.ts new file mode 100644 index 0000000000..45312e1eac --- /dev/null +++ b/src/elements/radio-button/common.ts @@ -0,0 +1,3 @@ +export * from './common/radio-button-common.js'; + +export { default as radioButtonCommonStyle } from './common/radio-button-common.scss?lit&inline'; diff --git a/src/elements/radio-button/common/radio-button-common.scss b/src/elements/radio-button/common/radio-button-common.scss new file mode 100644 index 0000000000..90a7ff5345 --- /dev/null +++ b/src/elements/radio-button/common/radio-button-common.scss @@ -0,0 +1,120 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-radio-button-label-color: var(--sbb-color-charcoal); + --sbb-radio-button-background-color: var(--sbb-color-white); + --sbb-radio-button-inner-circle-color: var(--sbb-color-white); + --sbb-radio-button-border-width: var(--sbb-border-width-1x); + --sbb-radio-button-border-style: solid; + --sbb-radio-button-border-color: var(--sbb-color-smoke); + --sbb-radio-button-dimension: var(--sbb-size-icon-ui-small); + --sbb-radio-button-inner-circle-dimension: #{sbb.px-to-rem-build(10)}; + --sbb-radio-button-cursor: pointer; + + // The border in unchecked state should fill the circle. + --sbb-radio-button-background-fake-border-width: calc(var(--sbb-radio-button-dimension) / 2); + + // Align radio button to the first row of the label based on the line-height so that it's vertically + // aligned to the label and sticks to the top if the label breaks into multiple lines + --sbb-radio-button-icon-align: calc( + (1em * var(--sbb-typo-line-height-body-text) - var(--sbb-radio-button-dimension)) / 2 + ); + + @include sbb.if-forced-colors { + --sbb-radio-button-background-color: Canvas !important; + --sbb-radio-button-border-width: var(--sbb-border-width-2x); + --sbb-radio-button-border-color: ButtonBorder; + } +} + +:host([checked]) { + --sbb-radio-button-inner-circle-color: var(--sbb-color-red); + --sbb-radio-button-background-fake-border-width: calc( + (var(--sbb-radio-button-dimension) - var(--sbb-radio-button-inner-circle-dimension)) / 2 + ); + + @include sbb.if-forced-colors { + --sbb-radio-button-inner-circle-color: Highlight; + --sbb-radio-button-border-color: Highlight; + } +} + +// Disabled definitions have to be after checked definitions +:host([disabled]) { + --sbb-radio-button-label-color: var(--sbb-color-granite); + --sbb-radio-button-background-color: var(--sbb-color-milk); + --sbb-radio-button-border-style: dashed; + --sbb-radio-button-inner-circle-color: var(--sbb-color-charcoal); + --sbb-radio-button-cursor: default; + + @include sbb.if-forced-colors { + --sbb-radio-button-inner-circle-color: GrayText; + --sbb-radio-button-border-color: GrayText; + --sbb-radio-button-border-style: solid; + } +} + +.sbb-screen-reader-only { + @include sbb.screen-reader-only; +} + +.sbb-radio-button { + @include sbb.text-m--regular; + + :host([size='s']) & { + @include sbb.text-s--regular; + } + + display: block; + cursor: var(--sbb-radio-button-cursor); + user-select: none; + position: relative; + color: var(--sbb-radio-button-label-color); + -webkit-tap-highlight-color: transparent; +} + +.sbb-radio-button__label-slot { + display: flex; + align-items: flex-start; + overflow: hidden; + + &::before, + &::after { + content: ''; + flex-shrink: 0; + width: var(--sbb-radio-button-dimension); + height: var(--sbb-radio-button-dimension); + border-radius: 50%; + margin-block-start: var(--sbb-radio-button-icon-align); + + transition: { + duration: var(--sbb-disable-animation-zero-time, var(--sbb-animation-duration-4x)); + timing-function: ease; + property: background-color, border; + } + + @include sbb.if-forced-colors { + transition: none; + } + } + + // Unchecked style + &::before { + background: var(--sbb-radio-button-inner-circle-color); + + // The border was used to generate the animation of the radio-button + // The border color acts as background color. + border: var(--sbb-radio-button-background-fake-border-width) solid + var(--sbb-radio-button-background-color); + margin-inline-end: var(--sbb-spacing-fixed-2x); + } + + &::after { + position: absolute; + border: var(--sbb-radio-button-border-width) var(--sbb-radio-button-border-style) + var(--sbb-radio-button-border-color); + } +} diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts new file mode 100644 index 0000000000..c2f0b008b8 --- /dev/null +++ b/src/elements/radio-button/common/radio-button-common.ts @@ -0,0 +1,203 @@ +import type { LitElement, PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SbbConnectedAbortController } from '../../core/controllers.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { setOrRemoveAttribute } from '../../core/dom.js'; +import { EventEmitter, HandlerRepository, formElementHandlerAspect } from '../../core/eventing.js'; +import type { + SbbCheckedStateChange, + SbbDisabledStateChange, + SbbStateChange, +} from '../../core/interfaces.js'; +import type { AbstractConstructor } from '../../core/mixins.js'; +import type { SbbRadioButtonGroupElement } from '../radio-button-group.js'; + +export type SbbRadioButtonSize = 's' | 'm'; + +export type SbbRadioButtonStateChange = Extract< + SbbStateChange, + SbbDisabledStateChange | SbbCheckedStateChange +>; + +export declare class SbbRadioButtonCommonElementMixinType { + public get allowEmptySelection(): boolean; + public set allowEmptySelection(boolean); + public value?: string; + public get disabled(): boolean; + public set disabled(boolean); + public get required(): boolean; + public set required(boolean); + public get group(): SbbRadioButtonGroupElement | null; + public get checked(): boolean; + public set checked(boolean); + public select(): void; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbRadioButtonCommonElementMixin = >( + superClass: T, +): AbstractConstructor & T => { + @hostAttributes({ + role: 'radio', + }) + abstract class SbbRadioButtonCommonElement + extends superClass + implements Partial + { + public static readonly events = { + stateChange: 'stateChange', + } as const; + + /** + * Whether the radio can be deselected. + */ + @property({ attribute: 'allow-empty-selection', type: Boolean }) + public set allowEmptySelection(value: boolean) { + this._allowEmptySelection = Boolean(value); + } + public get allowEmptySelection(): boolean { + return this._allowEmptySelection || (this.group?.allowEmptySelection ?? false); + } + private _allowEmptySelection = false; + + /** + * Value of radio button. + */ + @property() public value?: string; + + /** + * Whether the radio button is disabled. + */ + @property({ reflect: true, type: Boolean }) + public set disabled(value: boolean) { + this._disabled = Boolean(value); + } + public get disabled(): boolean { + return this._disabled || (this.group?.disabled ?? false); + } + private _disabled = false; + + /** + * Whether the radio button is required. + */ + @property({ reflect: true, type: Boolean }) + public set required(value: boolean) { + this._required = Boolean(value); + } + public get required(): boolean { + return this._required || (this.group?.required ?? false); + } + private _required = false; + + /** + * Reference to the connected radio button group. + */ + public get group(): SbbRadioButtonGroupElement | null { + return this._group; + } + private _group: SbbRadioButtonGroupElement | null = null; + + /** + * Whether the radio button is checked. + */ + @property({ reflect: true, type: Boolean }) + public set checked(value: boolean) { + this._checked = Boolean(value); + } + public get checked(): boolean { + return this._checked; + } + private _checked = false; + + /** + * Label size variant, either m or s. + */ + @property({ reflect: true }) + public set size(value: SbbRadioButtonSize) { + this._size = value; + } + public get size(): SbbRadioButtonSize { + return this.group?.size ?? this._size; + } + private _size: SbbRadioButtonSize = 'm'; + + private _abort = new SbbConnectedAbortController(this); + private _handlerRepository = new HandlerRepository(this, formElementHandlerAspect); + + /** + * @internal + * Internal event that emits whenever the state of the radio option + * in relation to the parent selection panel changes. + */ + private _stateChange: EventEmitter = new EventEmitter( + this, + SbbRadioButtonCommonElement.events.stateChange, + { bubbles: true }, + ); + + public select(): void { + if (this.disabled) { + return; + } + + if (this.allowEmptySelection) { + this.checked = !this.checked; + } else if (!this.checked) { + this.checked = true; + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._group = this.closest('sbb-radio-button-group') as SbbRadioButtonGroupElement; + + const signal = this._abort.signal; + this.addEventListener('click', (e) => this._handleClick(e), { signal }); + this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); + this._handlerRepository.connect(); + + // We need to call requestUpdate to update the reflected attributes + ['disabled', 'required', 'size'].forEach((p) => this.requestUpdate(p)); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._handlerRepository.disconnect(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has('checked')) { + this.setAttribute('aria-checked', `${this.checked}`); + if (this.checked !== changedProperties.get('checked')!) { + this._stateChange.emit({ type: 'checked', checked: this.checked }); + } + } + if (changedProperties.has('disabled')) { + setOrRemoveAttribute(this, 'aria-disabled', this.disabled ? 'true' : null); + if (this.disabled !== changedProperties.get('disabled')!) { + this._stateChange.emit({ type: 'disabled', disabled: this.disabled }); + } + } + if (changedProperties.has('required')) { + this.setAttribute('aria-required', `${this.required}`); + } + } + + private _handleClick(event: Event): void { + event.preventDefault(); + this.select(); + } + + private _handleKeyDown(evt: KeyboardEvent): void { + if (evt.code === 'Space') { + this.select(); + } + } + } + + return SbbRadioButtonCommonElement as unknown as AbstractConstructor & + T; +}; diff --git a/src/elements/radio-button/radio-button-group/__snapshots__/radio-button-group.snapshot.spec.snap.js b/src/elements/radio-button/radio-button-group/__snapshots__/radio-button-group.snapshot.spec.snap.js index 3be3d45027..db299bfefc 100644 --- a/src/elements/radio-button/radio-button-group/__snapshots__/radio-button-group.snapshot.spec.snap.js +++ b/src/elements/radio-button/radio-button-group/__snapshots__/radio-button-group.snapshot.spec.snap.js @@ -1,16 +1,16 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["sbb-radio-button-group renders - DOM"] = +snapshots["sbb-radio-button-group renders DOM"] = ` `; -/* end snapshot sbb-radio-button-group renders - DOM */ +/* end snapshot sbb-radio-button-group renders DOM */ -snapshots["sbb-radio-button-group renders - Shadow DOM"] = +snapshots["sbb-radio-button-group renders Shadow DOM"] = `
@@ -20,9 +20,9 @@ snapshots["sbb-radio-button-group renders - Shadow DOM"] =
`; -/* end snapshot sbb-radio-button-group renders - Shadow DOM */ +/* end snapshot sbb-radio-button-group renders Shadow DOM */ -snapshots["sbb-radio-button-group A11y tree Chrome"] = +snapshots["sbb-radio-button-group renders A11y tree Chrome"] = `

{ "role": "WebArea", @@ -30,9 +30,9 @@ snapshots["sbb-radio-button-group A11y tree Chrome"] = }

`; -/* end snapshot sbb-radio-button-group A11y tree Chrome */ +/* end snapshot sbb-radio-button-group renders A11y tree Chrome */ -snapshots["sbb-radio-button-group A11y tree Firefox"] = +snapshots["sbb-radio-button-group renders A11y tree Firefox"] = `

{ "role": "document", @@ -40,5 +40,5 @@ snapshots["sbb-radio-button-group A11y tree Firefox"] = }

`; -/* end snapshot sbb-radio-button-group A11y tree Firefox */ +/* end snapshot sbb-radio-button-group renders A11y tree Firefox */ diff --git a/src/elements/radio-button/radio-button-group/radio-button-group.scss b/src/elements/radio-button/radio-button-group/radio-button-group.scss index 243e7cd27d..2eb4e8dd86 100644 --- a/src/elements/radio-button/radio-button-group/radio-button-group.scss +++ b/src/elements/radio-button/radio-button-group/radio-button-group.scss @@ -16,18 +16,26 @@ $breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; --sbb-radio-button-group-gap: var(--sbb-spacing-fixed-3x) var(--sbb-spacing-fixed-6x); display: block; + + ::slotted(sbb-radio-button-panel) { + flex: auto; + } } :host([orientation='vertical']) { --sbb-radio-button-group-orientation: column; --sbb-radio-button-group-width: 100%; + + ::slotted(sbb-radio-button-panel) { + width: 100%; + } } -:host([data-has-selection-panel]) { +:host([data-has-panel]) { --sbb-radio-button-group-width: 100%; } -:host([data-has-selection-panel][orientation='vertical']) { +:host([data-has-panel][orientation='vertical']) { --sbb-radio-button-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); } @@ -36,11 +44,14 @@ $breakpoints: 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'; // horizontal-from overrides orientation vertical :host([orientation='vertical'][horizontal-from='#{$breakpoint}']) { @include horizontal-orientation; + + // We need to unset the 100% width of the vertical mode if it starts to be horizontal + ::slotted(sbb-radio-button-panel) { + width: unset; + } } - :host( - [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-panel]) - ) { + :host([orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-panel])) { --sbb-radio-button-group-width: max-content; } } diff --git a/src/elements/radio-button/radio-button-group/radio-button-group.snapshot.spec.ts b/src/elements/radio-button/radio-button-group/radio-button-group.snapshot.spec.ts index 27b59fefe9..5f7031c296 100644 --- a/src/elements/radio-button/radio-button-group/radio-button-group.snapshot.spec.ts +++ b/src/elements/radio-button/radio-button-group/radio-button-group.snapshot.spec.ts @@ -10,17 +10,19 @@ import './radio-button-group.js'; describe(`sbb-radio-button-group`, () => { let element: SbbRadioButtonGroupElement; - beforeEach(async () => { - element = await fixture(html``); - }); + describe('renders', () => { + beforeEach(async () => { + element = await fixture(html``); + }); - it('renders - DOM', async () => { - await expect(element).dom.to.be.equalSnapshot(); - }); + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); - it('renders - Shadow DOM', async () => { - await expect(element).shadowDom.to.be.equalSnapshot(); - }); + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); - testA11yTreeSnapshot(); + testA11yTreeSnapshot(); + }); }); diff --git a/src/elements/radio-button/radio-button-group/radio-button-group.spec.ts b/src/elements/radio-button/radio-button-group/radio-button-group.spec.ts index edeba50024..df8c0c8217 100644 --- a/src/elements/radio-button/radio-button-group/radio-button-group.spec.ts +++ b/src/elements/radio-button/radio-button-group/radio-button-group.spec.ts @@ -1,191 +1,249 @@ import { assert, expect } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; -import { html } from 'lit/static-html.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; import { fixture } from '../../core/testing/private.js'; import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing.js'; +import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js'; import type { SbbRadioButtonElement } from '../radio-button.js'; + import '../radio-button.js'; +import '../radio-button-panel.js'; import { SbbRadioButtonGroupElement } from './radio-button-group.js'; -describe(`sbb-radio-button-group`, () => { - let element: SbbRadioButtonGroupElement; +['sbb-radio-button', 'sbb-radio-button-panel'].forEach((selector) => { + const tagSingle = unsafeStatic(selector); + describe(`sbb-radio-button-group with ${selector}`, () => { + let element: SbbRadioButtonGroupElement; - describe('events', () => { - beforeEach(async () => { - element = await fixture(html` + describe('events', () => { + beforeEach(async () => { + /* eslint-disable lit/binding-positions */ + element = await fixture( + html` - Value one - Value two - Value threeValue one + <${tagSingle} id="sbb-radio-2" value="Value two">Value two + <${tagSingle} id="sbb-radio-3" value="Value three" disabled + >Value three - Value four + <${tagSingle} id="sbb-radio-4" value="Value four">Value four - `); - }); - - it('renders', () => { - assert.instanceOf(element, SbbRadioButtonGroupElement); - }); - - it('selects radio on click', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - const radio = element.querySelector('#sbb-radio-2') as SbbRadioButtonElement; - - expect(firstRadio).to.have.attribute('checked'); - - radio.click(); - await waitForLitRender(element); - - expect(radio).to.have.attribute('checked'); - expect(firstRadio).not.to.have.attribute('checked'); - }); - - it('dispatches event on radio change', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - const checkedRadio = element.querySelector('#sbb-radio-2') as SbbRadioButtonElement; - const changeSpy = new EventSpy('change'); - const inputSpy = new EventSpy('input'); - - checkedRadio.click(); - await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy.count).to.be.equal(1); - await waitForCondition(() => inputSpy.events.length === 1); - expect(inputSpy.count).to.be.equal(1); - - firstRadio.click(); - await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); - }); - - it('does not select disabled radio on click', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - const disabledRadio = element.querySelector('#sbb-radio-3') as SbbRadioButtonElement; - - disabledRadio.click(); - await waitForLitRender(element); - - expect(disabledRadio).not.to.have.attribute('checked'); - expect(firstRadio).to.have.attribute('checked'); - }); - - it('preserves radio button disabled state after being disabled from group', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - const secondRadio = element.querySelector('#sbb-radio-2') as SbbRadioButtonElement; - const disabledRadio = element.querySelector('#sbb-radio-3') as SbbRadioButtonElement; - - element.disabled = true; - await waitForLitRender(element); - - disabledRadio.click(); - await waitForLitRender(element); - expect(disabledRadio).not.to.have.attribute('checked'); - expect(firstRadio).to.have.attribute('checked'); - - secondRadio.click(); - await waitForLitRender(element); - expect(secondRadio).not.to.have.attribute('checked'); - - element.disabled = false; - await waitForLitRender(element); - - disabledRadio.click(); - await waitForLitRender(element); - expect(disabledRadio).not.to.have.attribute('checked'); - expect(firstRadio).to.have.attribute('checked'); - }); + `, + ); + }); + + it('renders', () => { + assert.instanceOf(element, SbbRadioButtonGroupElement); + }); + + it('selects radio on click', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const radio = element.querySelector('#sbb-radio-2') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + expect(firstRadio).to.have.attribute('checked'); + + radio.click(); + await waitForLitRender(element); + + expect(radio).to.have.attribute('checked'); + expect(firstRadio).not.to.have.attribute('checked'); + }); + + it('renders', () => { + assert.instanceOf(element, SbbRadioButtonGroupElement); + }); + + describe('events', () => { + it('selects radio on click', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const radio = element.querySelector('#sbb-radio-2') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + expect(firstRadio).to.have.attribute('checked'); + + radio.click(); + await waitForLitRender(element); + + expect(radio).to.have.attribute('checked'); + expect(firstRadio).not.to.have.attribute('checked'); + }); + + it('dispatches event on radio change', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const checkedRadio = element.querySelector('#sbb-radio-2') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const changeSpy = new EventSpy('change'); + const inputSpy = new EventSpy('input'); + + checkedRadio.click(); + await waitForCondition(() => changeSpy.events.length === 1); + expect(changeSpy.count).to.be.equal(1); + await waitForCondition(() => inputSpy.events.length === 1); + expect(inputSpy.count).to.be.equal(1); + + firstRadio.click(); + await waitForLitRender(element); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('does not select disabled radio on click', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const disabledRadio = element.querySelector('#sbb-radio-3') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + disabledRadio.click(); + await waitForLitRender(element); + + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('preserves radio button disabled state after being disabled from group', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const secondRadio = element.querySelector('#sbb-radio-2') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const disabledRadio = element.querySelector('#sbb-radio-3') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + element.disabled = true; + await waitForLitRender(element); + + disabledRadio.click(); + await waitForLitRender(element); + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + + secondRadio.click(); + await waitForLitRender(element); + expect(secondRadio).not.to.have.attribute('checked'); + + element.disabled = false; + await waitForLitRender(element); + + disabledRadio.click(); + await waitForLitRender(element); + expect(disabledRadio).not.to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + }); + + it('selects radio on left arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + firstRadio.focus(); + await waitForLitRender(element); + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(element); + + const radio = element.querySelector('#sbb-radio-4'); + expect(radio).to.have.attribute('checked'); + + firstRadio.click(); + await waitForLitRender(element); + + expect(firstRadio).to.have.attribute('checked'); + }); + + it('selects radio on right arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + + firstRadio.focus(); + await sendKeys({ press: 'ArrowRight' }); + + await waitForLitRender(element); + const radio = element.querySelector('#sbb-radio-2'); + + expect(radio).to.have.attribute('checked'); + + firstRadio.click(); + await waitForLitRender(element); + + expect(firstRadio).to.have.attribute('checked'); + }); + + it('wraps around on arrow key navigation', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + const secondRadio = element.querySelector('#sbb-radio-2') as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; - it('selects radio on left arrow key pressed', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; + secondRadio.click(); + await waitForLitRender(element); + expect(secondRadio).to.have.attribute('checked'); - firstRadio.focus(); - await waitForLitRender(element); + secondRadio.focus(); + await waitForLitRender(element); + + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(element); - await sendKeys({ press: 'ArrowLeft' }); - await waitForLitRender(element); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(element); - const radio = element.querySelector('#sbb-radio-4'); - expect(radio).to.have.attribute('checked'); + const radio = element.querySelector('#sbb-radio-1'); + expect(radio).to.have.attribute('checked'); - firstRadio.click(); - await waitForLitRender(element); + firstRadio.click(); + await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); + expect(firstRadio).to.have.attribute('checked'); + }); + }); }); - it('selects radio on right arrow key pressed', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; + describe('initialization', () => { + beforeEach(async () => { + element = await fixture(html` + +

Other content

+
+ `); + }); - firstRadio.focus(); - await sendKeys({ press: 'ArrowRight' }); - - await waitForLitRender(element); - const radio = element.querySelector('#sbb-radio-2'); - - expect(radio).to.have.attribute('checked'); - - firstRadio.click(); - await waitForLitRender(element); - - expect(firstRadio).to.have.attribute('checked'); - }); - - it('wraps around on arrow key navigation', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - const secondRadio = element.querySelector('#sbb-radio-2') as SbbRadioButtonElement; - - secondRadio.click(); - await waitForLitRender(element); - expect(secondRadio).to.have.attribute('checked'); - - secondRadio.focus(); - await waitForLitRender(element); - - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(element); - - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(element); - - const radio = element.querySelector('#sbb-radio-1'); - expect(radio).to.have.attribute('checked'); - - firstRadio.click(); - await waitForLitRender(element); - - expect(firstRadio).to.have.attribute('checked'); - }); - }); - - describe('initialization', () => { - beforeEach(async () => { - element = await fixture(html` - -

Other content

-
- `); - }); - - it('should preserve value when no radios were slotted but slotchange was triggered', () => { - expect(element.value).to.equal('Value one'); - }); + it('should preserve value when no radios were slotted but slotchange was triggered', () => { + expect(element.value).to.equal('Value one'); + }); - it('should restore value when radios are slotted', async () => { - const radioOne = document.createElement('sbb-radio-button'); - radioOne.value = 'Value one'; + it('should restore value when radios are slotted', async () => { + const radioOne = document.createElement('sbb-radio-button'); + radioOne.value = 'Value one'; - const radioTwo = document.createElement('sbb-radio-button'); - radioTwo.value = 'Value two'; + const radioTwo = document.createElement('sbb-radio-button'); + radioTwo.value = 'Value two'; - element.appendChild(radioTwo); - element.appendChild(radioOne); + element.appendChild(radioTwo); + element.appendChild(radioOne); - await waitForLitRender(element); + await waitForLitRender(element); - expect(element.value).to.equal('Value one'); - expect(radioOne).to.have.attribute('checked'); + expect(element.value).to.equal('Value one'); + expect(radioOne).to.have.attribute('checked'); + }); }); }); }); diff --git a/src/elements/radio-button/radio-button-group/radio-button-group.stories.ts b/src/elements/radio-button/radio-button-group/radio-button-group.stories.ts index 407afdbdf2..88ef87fc09 100644 --- a/src/elements/radio-button/radio-button-group/radio-button-group.stories.ts +++ b/src/elements/radio-button/radio-button-group/radio-button-group.stories.ts @@ -1,8 +1,9 @@ import { withActions } from '@storybook/addon-actions/decorator'; import type { InputType } from '@storybook/types'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; +import type { ArgTypes, Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; import type { TemplateResult } from 'lit'; import { html } from 'lit'; +import { styleMap, type StyleInfo } from 'lit/directives/style-map.js'; import { sbbSpread } from '../../../storybook/helpers/spread.js'; import type { SbbFormErrorElement } from '../../form-error.js'; @@ -10,7 +11,29 @@ import type { SbbFormErrorElement } from '../../form-error.js'; import readme from './readme.md?raw'; import './radio-button-group.js'; import '../radio-button.js'; +import '../radio-button-panel.js'; import '../../form-error.js'; +import '../../icon.js'; +import '../../card/card-badge.js'; + +const suffixStyle: Readonly = { + display: 'flex', + alignItems: 'center', + marginInline: 'var(--sbb-spacing-fixed-2x)', +}; + +const cardBadge = (): TemplateResult => html`%`; + +const suffixAndSubtext = (): TemplateResult => html` + Subtext + + + + CHF 40.00 + + + ${cardBadge()} +`; const value: InputType = { control: { @@ -92,10 +115,22 @@ const radioButtons = (): TemplateResult => html` Value four `; +const radioButtonPanels = (): TemplateResult => html` + Value 1 ${suffixAndSubtext()} + Value 2 ${suffixAndSubtext()} + + Value 3 ${suffixAndSubtext()} +`; + const DefaultTemplate = (args: Args): TemplateResult => html` ${radioButtons()} `; +const PanelTemplate = (args: Args): TemplateResult => html` + ${radioButtonPanels()} +`; + const ErrorMessageTemplate = (args: Args): TemplateResult => { const sbbFormError: SbbFormErrorElement = document.createElement('sbb-form-error'); sbbFormError.setAttribute('slot', 'error'); @@ -186,6 +221,28 @@ export const ErrorMessageVertical: StoryObj = { }, }; +export const HorizontalPanels: StoryObj = { + render: PanelTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const VerticalPanels: StoryObj = { + render: PanelTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, orientation: orientation.options![1] }, +}; + +export const VerticalToHorizontalPanels: StoryObj = { + render: PanelTemplate, + argTypes: defaultArgTypes, + args: { + ...defaultArgs, + orientation: orientation.options![1], + 'horizontal-from': horizontalFrom.options![4], + }, +}; + const meta: Meta = { decorators: [withActions as Decorator], parameters: { diff --git a/src/elements/radio-button/radio-button-group/radio-button-group.ts b/src/elements/radio-button/radio-button-group/radio-button-group.ts index 48bf53640e..79c32e6be2 100644 --- a/src/elements/radio-button/radio-button-group/radio-button-group.ts +++ b/src/elements/radio-button/radio-button-group/radio-button-group.ts @@ -1,5 +1,5 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { getNextElementIndex, isArrowKeyPressed } from '../../core/a11y.js'; @@ -8,18 +8,15 @@ import { hostAttributes } from '../../core/decorators.js'; import { EventEmitter } from '../../core/eventing.js'; import type { SbbHorizontalFrom, SbbOrientation, SbbStateChange } from '../../core/interfaces.js'; import { SbbDisabledMixin } from '../../core/mixins.js'; -import type { SbbSelectionPanelElement } from '../../selection-panel.js'; -import type { - SbbRadioButtonElement, - SbbRadioButtonSize, - SbbRadioButtonStateChange, -} from '../radio-button.js'; +import type { SbbRadioButtonSize, SbbRadioButtonStateChange } from '../common.js'; +import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js'; +import type { SbbRadioButtonElement } from '../radio-button.js'; import style from './radio-button-group.scss?lit&inline'; export type SbbRadioButtonGroupEventDetail = { value: any | null; - radioButton: SbbRadioButtonElement; + radioButton: SbbRadioButtonElement | SbbRadioButtonPanelElement; }; /** @@ -79,19 +76,22 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { /** * List of contained radio buttons. */ - public get radioButtons(): SbbRadioButtonElement[] { + public get radioButtons(): (SbbRadioButtonElement | SbbRadioButtonPanelElement)[] { return ( - Array.from(this.querySelectorAll?.('sbb-radio-button') ?? []) as SbbRadioButtonElement[] + Array.from(this.querySelectorAll?.('sbb-radio-button, sbb-radio-button-panel') ?? []) as ( + | SbbRadioButtonElement + | SbbRadioButtonPanelElement + )[] ).filter((el) => el.closest?.('sbb-radio-button-group') === this); } - private get _enabledRadios(): SbbRadioButtonElement[] | undefined { + private get _enabledRadios(): (SbbRadioButtonElement | SbbRadioButtonPanelElement)[] | undefined { if (!this.disabled) { return this.radioButtons.filter((r) => !r.disabled); } } - private _hasSelectionPanel: boolean = false; + private _hasSelectionExpansionPanelElement: boolean = false; private _didLoad = false; private _abort = new SbbConnectedAbortController(this); @@ -146,8 +146,13 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { }, ); this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); - this._hasSelectionPanel = !!this.querySelector?.('sbb-selection-panel'); - this.toggleAttribute('data-has-selection-panel', this._hasSelectionPanel); + this._hasSelectionExpansionPanelElement = !!this.querySelector?.( + 'sbb-selection-expansion-panel', + ); + this.toggleAttribute( + 'data-has-panel', + !!this.querySelector?.('sbb-selection-expansion-panel, sbb-radio-button-panel'), + ); this._updateRadios(this.value); } @@ -234,13 +239,10 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { } } - private _getRadioTabIndex(radio: SbbRadioButtonElement): number { + private _getRadioTabIndex(radio: SbbRadioButtonElement | SbbRadioButtonPanelElement): number { const isSelected: boolean = radio.checked && !radio.disabled && !this.disabled; - const isParentPanelWithContent: boolean = - radio.parentElement?.nodeName === 'SBB-SELECTION-PANEL' && - (radio.parentElement as SbbSelectionPanelElement).hasContent; - return isSelected || (this._hasSelectionPanel && isParentPanelWithContent) ? 0 : -1; + return isSelected || this._hasSelectionExpansionPanelElement ? 0 : -1; } private _handleKeyDown(evt: KeyboardEvent): void { @@ -252,7 +254,7 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { // don't trap nested handling ((evt.target as HTMLElement) !== this && (evt.target as HTMLElement).parentElement !== this && - (evt.target as HTMLElement).parentElement?.nodeName !== 'SBB-SELECTION-PANEL') + (evt.target as HTMLElement).parentElement?.localName !== 'sbb-selection-expansion-panel') ) { return; } @@ -261,14 +263,12 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { return; } - const current: number = enabledRadios.findIndex((e: SbbRadioButtonElement) => e === evt.target); + const current: number = enabledRadios.findIndex( + (e: SbbRadioButtonElement | SbbRadioButtonPanelElement) => e === evt.target, + ); const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length); - // Selection on arrow keypress is allowed only if all the selection-panels have no content. - const allPanelsHaveNoContent: boolean = ( - Array.from(this.querySelectorAll?.('sbb-selection-panel')) || [] - ).every((e: SbbSelectionPanelElement) => !e.hasContent); - if (!this._hasSelectionPanel || (this._hasSelectionPanel && allPanelsHaveNoContent)) { + if (!this._hasSelectionExpansionPanelElement) { enabledRadios[nextIndex].select(); } diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md index 8a372b94db..d9036b55f3 100644 --- a/src/elements/radio-button/radio-button-group/readme.md +++ b/src/elements/radio-button/radio-button-group/readme.md @@ -1,6 +1,6 @@ The `sbb-radio-button-group` is a component which can be used as a wrapper for -a collection of [sbb-radio-button](/docs/elements-sbb-radio-button-sbb-radio-button--docs)s, -or, alternatively, for a collection of [sbb-selection-panel](/docs/elements-sbb-selection-panel--docs)s. +a collection of either [sbb-radio-button](/docs/elements-sbb-radio-button-sbb-radio-button--docs)s, [sbb-radio-button-panel](/docs/elements-sbb-radio-button-sbb-radio-button-panel--docs)s, +or [sbb-selection-expansion-panel](/docs/elements-sbb-selection-expansion-panel--docs)s. ```html @@ -69,16 +69,16 @@ In order to ensure readability for screen-readers, please provide an `aria-label ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| --------------------- | ----------------------- | ------- | -------------------------------- | -------------- | --------------------------------------------------------- | -| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radios can be deselected. | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. | -| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Radio group's orientation, either horizontal or vertical. | -| `radioButtons` | - | public | `SbbRadioButtonElement[]` | | List of contained radio buttons. | -| `required` | `required` | public | `boolean` | `false` | Whether the radio group is required. | -| `size` | `size` | public | `SbbRadioButtonSize` | `'m'` | Size variant, either m or s. | -| `value` | `value` | public | `any \| null \| undefined` | | The value of the radio group. | +| Name | Attribute | Privacy | Type | Default | Description | +| --------------------- | ----------------------- | ------- | --------------------------------------------------------- | -------------- | --------------------------------------------------------- | +| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radios can be deselected. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. | +| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Radio group's orientation, either horizontal or vertical. | +| `radioButtons` | - | public | `(SbbRadioButtonElement \| SbbRadioButtonPanelElement)[]` | | List of contained radio buttons. | +| `required` | `required` | public | `boolean` | `false` | Whether the radio group is required. | +| `size` | `size` | public | `SbbRadioButtonSize` | `'m'` | Size variant, either m or s. | +| `value` | `value` | public | `any \| null \| undefined` | | The value of the radio group. | ## Events diff --git a/src/elements/radio-button/radio-button-panel.ts b/src/elements/radio-button/radio-button-panel.ts new file mode 100644 index 0000000000..e5a90eef4f --- /dev/null +++ b/src/elements/radio-button/radio-button-panel.ts @@ -0,0 +1 @@ +export * from './radio-button-panel/radio-button-panel.js'; diff --git a/src/elements/radio-button/radio-button-panel/__snapshots__/radio-button-panel.snapshot.spec.snap.js b/src/elements/radio-button/radio-button-panel/__snapshots__/radio-button-panel.snapshot.spec.snap.js new file mode 100644 index 0000000000..db5ca2f4fd --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/__snapshots__/radio-button-panel.snapshot.spec.snap.js @@ -0,0 +1,167 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-radio-button-panel should render unchecked DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-radio-button-panel should render unchecked DOM */ + +snapshots["sbb-radio-button-panel should render unchecked Shadow DOM"] = +` +`; +/* end snapshot sbb-radio-button-panel should render unchecked Shadow DOM */ + +snapshots["sbb-radio-button-panel should render checked DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-radio-button-panel should render checked DOM */ + +snapshots["sbb-radio-button-panel should render checked Shadow DOM"] = +` +`; +/* end snapshot sbb-radio-button-panel should render checked Shadow DOM */ + +snapshots["sbb-radio-button-panel Unchecked - A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "radio", + "name": "Label" + } + ] +} +

+`; +/* end snapshot sbb-radio-button-panel Unchecked - A11y tree Firefox */ + +snapshots["sbb-radio-button-panel Unchecked - A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "radio", + "name": "Label", + "checked": false + } + ] +} +

+`; +/* end snapshot sbb-radio-button-panel Unchecked - A11y tree Chrome */ + +snapshots["sbb-radio-button-panel Checked - A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "radio", + "name": "Label", + "checked": true + } + ] +} +

+`; +/* end snapshot sbb-radio-button-panel Checked - A11y tree Chrome */ + +snapshots["sbb-radio-button-panel Checked - A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "radio", + "name": "Label", + "checked": true + } + ] +} +

+`; +/* end snapshot sbb-radio-button-panel Checked - A11y tree Firefox */ + diff --git a/src/elements/radio-button/radio-button-panel/index.ts b/src/elements/radio-button/radio-button-panel/index.ts new file mode 100644 index 0000000000..082c6a7306 --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/index.ts @@ -0,0 +1 @@ +export * from './radio-button-panel.js'; diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.snapshot.spec.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.snapshot.spec.ts new file mode 100644 index 0000000000..afd8ec850d --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.snapshot.spec.ts @@ -0,0 +1,61 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import { SbbRadioButtonPanelElement } from './radio-button-panel.js'; + +describe('sbb-radio-button-panel', () => { + let element: SbbRadioButtonPanelElement; + + describe('should render unchecked', async () => { + beforeEach(async () => { + element = (await fixture( + html` + Label + Subtext + Suffix + `, + )) as SbbRadioButtonPanelElement; + assert.instanceOf(element, SbbRadioButtonPanelElement); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('should render checked', async () => { + beforeEach(async () => { + element = await fixture( + html` + Label + Subtext + Suffix + `, + ); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + }); + + testA11yTreeSnapshot( + html`Label`, + 'Unchecked - A11y tree', + ); + + testA11yTreeSnapshot( + html`Label`, + 'Checked - A11y tree', + ); +}); diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.spec.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.spec.ts new file mode 100644 index 0000000000..f502726e1f --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.spec.ts @@ -0,0 +1,70 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing.js'; + +import { SbbRadioButtonPanelElement } from './radio-button-panel.js'; + +describe(`sbb-radio-button`, () => { + let element: SbbRadioButtonPanelElement; + + beforeEach(async () => { + element = await fixture( + html`Value label`, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbRadioButtonPanelElement); + }); + + it('should not render accessibility label about containing state', async () => { + element = element.shadowRoot!.querySelector('.sbb-screen-reader-only:not(input)')!; + expect(element).not.to.be.ok; + }); + + it('selects radio on click', async () => { + const stateChange = new EventSpy(SbbRadioButtonPanelElement.events.stateChange); + + element.click(); + await waitForLitRender(element); + + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + }); + + it('does not deselect radio if already checked', async () => { + const stateChange = new EventSpy(SbbRadioButtonPanelElement.events.stateChange); + + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + }); + + it('allows empty selection', async () => { + const stateChange = new EventSpy(SbbRadioButtonPanelElement.events.stateChange); + + element.allowEmptySelection = true; + element.click(); + await waitForLitRender(element); + expect(element).to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 1); + expect(stateChange.count).to.be.equal(1); + + element.click(); + await waitForLitRender(element); + expect(element).not.to.have.attribute('checked'); + await waitForCondition(() => stateChange.events.length === 2); + expect(stateChange.count).to.be.equal(2); + }); +}); diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.ssr.spec.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.ssr.spec.ts new file mode 100644 index 0000000000..3e7557fe40 --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.ssr.spec.ts @@ -0,0 +1,23 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbRadioButtonPanelElement } from './radio-button-panel.js'; + +describe(`sbb-radio-button-panel ${fixture.name}`, () => { + let root: SbbRadioButtonPanelElement; + + beforeEach(async () => { + root = await fixture( + html`Value label`, + { + modules: ['./radio-button-panel.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbRadioButtonPanelElement); + }); +}); diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts new file mode 100644 index 0000000000..228adc481f --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts @@ -0,0 +1,165 @@ +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Meta, StoryObj } from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import readme from './readme.md?raw'; + +import '../../icon.js'; +import '../../card/card-badge.js'; +import '../radio-button-panel.js'; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const checked: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const labelBoldClass: InputType = { + control: { + type: 'boolean', + }, +}; + +const color: InputType = { + control: { + type: 'inline-radio', + }, + options: ['white', 'milk'], +}; + +const borderless: InputType = { + control: { + type: 'boolean', + }, +}; + +const size: InputType = { + control: { + type: 'inline-radio', + }, + options: ['m', 's'], +}; + +const defaultArgTypes: ArgTypes = { + value, + checked, + disabled, + 'aria-label': ariaLabel, + labelBoldClass, + color, + borderless, + size, +}; + +const defaultArgs: Args = { + value: 'First value', + checked: false, + disabled: false, + 'aria-label': undefined, + labelBoldClass: false, + color: color.options![0], + borderless: false, + size: size.options![0], +}; + +const cardBadge = (): TemplateResult => html`%`; + +const DefaultTemplate = ({ labelBoldClass, ...args }: Args): TemplateResult => + html`${labelBoldClass ? html`Label` : 'Label'} + Subtext + + + + + CHF 40.00 + + + + ${cardBadge()} + `; + +export const Default: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Checked: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true }, +}; + +export const SizeS: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, size: size.options![1] }, +}; + +export const Milk: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, color: color.options![1] }, +}; + +export const Disabled: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const CheckedDisabled: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true, disabled: true }, +}; + +export const DefaultBold: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, labelBoldClass: true }, +}; + +export const CheckedBold: StoryObj = { + render: DefaultTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, checked: true, labelBoldClass: true }, +}; + +const meta: Meta = { + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-radio-button/sbb-radio-button-panel', +}; + +export default meta; diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.ts new file mode 100644 index 0000000000..ab7d784ac6 --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.ts @@ -0,0 +1,87 @@ +import { + type CSSResultGroup, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbSlotStateController } from '../../core/controllers.js'; +import { panelCommonStyle, SbbPanelMixin, SbbUpdateSchedulerMixin } from '../../core/mixins.js'; +import { radioButtonCommonStyle, SbbRadioButtonCommonElementMixin } from '../common.js'; + +import '../../screen-reader-only.js'; + +/** + /** + * It displays a radio button enhanced with the panel design. + * + * @slot - Use the unnamed slot to add content to the radio label. + * @slot subtext - Slot used to render a subtext under the label. + * @slot suffix - Slot used to render additional content after the label. + * @slot badge - Use this slot to provide a `sbb-card-badge` (optional). + */ +@customElement('sbb-radio-button-panel') +export class SbbRadioButtonPanelElement extends SbbPanelMixin( + SbbRadioButtonCommonElementMixin(SbbUpdateSchedulerMixin(LitElement)), +) { + public static override styles: CSSResultGroup = [radioButtonCommonStyle, panelCommonStyle]; + + // FIXME using ...super.events requires: https://github.com/sbb-design-systems/lyne-components/issues/2600 + public static readonly events = { + stateChange: 'stateChange', + panelConnected: 'panelConnected', + } as const; + + public constructor() { + super(); + new SbbSlotStateController(this); + } + + protected override async willUpdate(changedProperties: PropertyValues): Promise { + super.willUpdate(changedProperties); + + if (changedProperties.has('checked')) { + this.toggleAttribute('data-checked', this.checked); + } + } + + protected override render(): TemplateResult { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-radio-button-panel': SbbRadioButtonPanelElement; + } +} diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md new file mode 100644 index 0000000000..78151f4360 --- /dev/null +++ b/src/elements/radio-button/radio-button-panel/readme.md @@ -0,0 +1,76 @@ +The `sbb-radio-button-panel` component provides the same functionality as a native `` enhanced with the selection panel design and functionalities. Use multiple `sbb-radio-button-panel` components inside a [sbb-radio-button-group](/docs/components-sbb-radio-button-sbb-radio-button-group--docs) component in order to display a radio input within a group. + +```html + + Option one + Option two + +``` + +## Slots + +It is possible to provide a label via an unnamed slot; +additionally the slots named `subtext` can be used to provide a subtext and +the slot named `suffix` can be used to provide suffix items. +If you use a , the slot `badge` is automatically assigned. + +```html + + % + Label + Subtext + Suffix + +``` + +## States + +It is possible to display the component in `disabled` or `checked` state by using the self-named properties. +The `allowEmptySelection` property allows user to deselect the component. + +```html +Option one +Option two +Option three +``` + +## Style + +The component's label can be displayed in bold using the `sbb-text--bold` class on a wrapper tag: + +```html + + Bold label + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| --------------------- | ----------------------- | ------- | ------------------------------------ | --------- | ---------------------------------------------- | +| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. | +| `borderless` | `borderless` | public | `boolean` | `false` | Whether the unselected panel has a border. | +| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. | +| `color` | `color` | public | `'white' \| 'milk'` | `'white'` | The background color of the panel. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the radio button is disabled. | +| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. | +| `required` | `required` | public | `boolean` | `false` | Whether the radio button is required. | +| `size` | `size` | public | `SbbRadioButtonSize` | `'m'` | Label size variant, either m or s. | +| `value` | `value` | public | `string \| undefined` | | Value of radio button. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------- | ------- | ----------- | ---------- | ------ | -------------------------------- | +| `select` | public | | | `void` | SbbRadioButtonCommonElementMixin | + +## Slots + +| Name | Description | +| --------- | ------------------------------------------------------- | +| | Use the unnamed slot to add content to the radio label. | +| `badge` | Use this slot to provide a `sbb-card-badge` (optional). | +| `subtext` | Slot used to render a subtext under the label. | +| `suffix` | Slot used to render additional content after the label. | diff --git a/src/elements/radio-button/radio-button/radio-button.scss b/src/elements/radio-button/radio-button/radio-button.scss index 3cab598aa9..810c6368c2 100644 --- a/src/elements/radio-button/radio-button/radio-button.scss +++ b/src/elements/radio-button/radio-button/radio-button.scss @@ -4,178 +4,20 @@ @include sbb.box-sizing; :host { - --sbb-radio-button-label-color: var(--sbb-color-charcoal); - --sbb-radio-button-background-color: var(--sbb-color-white); - --sbb-radio-button-inner-circle-color: var(--sbb-color-white); - --sbb-radio-button-border-width: var(--sbb-border-width-1x); - --sbb-radio-button-border-style: solid; - --sbb-radio-button-border-color: var(--sbb-color-smoke); - --sbb-radio-button-dimension: var(--sbb-size-icon-ui-small); - --sbb-radio-button-inner-circle-dimension: #{sbb.px-to-rem-build(10)}; - --sbb-radio-button-suffix-color: var(--sbb-color-charcoal); - --sbb-radio-button-subtext-color: var(--sbb-color-granite); - --sbb-radio-button-cursor: pointer; - - // The border in unchecked state should fill the circle. - --sbb-radio-button-background-fake-border-width: calc(var(--sbb-radio-button-dimension) / 2); - - // Align radio button to the first row of the label based on the line-height so that it's vertically - // aligned to the label and sticks to the top if the label breaks into multiple lines - --sbb-radio-button-icon-align: calc( - (1em * var(--sbb-typo-line-height-body-text) - var(--sbb-radio-button-dimension)) / 2 - ); - display: block; // Use !important here to not interfere with Firefox focus ring definition // which appears in normalize css of several frameworks. outline: none !important; - - @include sbb.if-forced-colors { - --sbb-radio-button-background-color: Canvas !important; - --sbb-radio-button-border-width: var(--sbb-border-width-2x); - --sbb-radio-button-border-color: ButtonBorder; - } -} - -// Change the focus outline when the input is placed inside of a selection panel -// as the main input element. -:host(:focus-visible[data-is-selection-panel-input]) { - // Use !important here to not interfere with Firefox focus ring definition - // which appears in normalize css of several frameworks. - outline: none !important; - - .sbb-radio-button::after { - content: ''; - position: absolute; - display: block; - inset-block: calc( - (var(--sbb-spacing-responsive-xs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - inset-inline: calc( - (var(--sbb-spacing-responsive-xxs) * -1) + var(--sbb-focus-outline-width) - - (var(--sbb-focus-outline-offset) * 2) - ); - border: var(--sbb-focus-outline-color) solid var(--sbb-focus-outline-width); - border-radius: calc(var(--sbb-border-radius-4x) + var(--sbb-focus-outline-offset)); - } -} - -:host([checked]) { - --sbb-radio-button-inner-circle-color: var(--sbb-color-red); - --sbb-radio-button-background-fake-border-width: calc( - (var(--sbb-radio-button-dimension) - var(--sbb-radio-button-inner-circle-dimension)) / 2 - ); - - @include sbb.if-forced-colors { - --sbb-radio-button-inner-circle-color: Highlight; - --sbb-radio-button-border-color: Highlight; - } } -// Disabled definitions have to be after checked definitions -:host([disabled]) { - --sbb-radio-button-label-color: var(--sbb-color-granite); - --sbb-radio-button-background-color: var(--sbb-color-milk); - --sbb-radio-button-border-style: dashed; - --sbb-radio-button-inner-circle-color: var(--sbb-color-charcoal); - --sbb-radio-button-cursor: default; - - @include sbb.if-forced-colors { - --sbb-radio-button-inner-circle-color: GrayText; - --sbb-radio-button-border-color: GrayText; - --sbb-radio-button-border-style: solid; - } -} - -// One radio button per line .sbb-radio-button { - @include sbb.text-m--regular; - display: block; - cursor: var(--sbb-radio-button-cursor); - user-select: none; - position: relative; - color: var(--sbb-radio-button-label-color); - -webkit-tap-highlight-color: transparent; - - :host([size='s']) & { - @include sbb.text-s--regular; - } // Hide focus outline when focus origin is mouse or touch. This is being used as a workaround in various components. - :host( - :focus-visible:not( - [data-focus-origin='mouse'], - [data-focus-origin='touch'], - [data-is-selection-panel-input] - ) - ) - & { + :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { @include sbb.focus-outline; border-radius: calc(var(--sbb-border-radius-4x) - var(--sbb-focus-outline-offset)); } } - -slot[name='suffix'] { - color: var(--sbb-radio-button-suffix-color); -} - -slot[name='subtext'] { - display: block; - color: var(--sbb-radio-button-subtext-color); - padding-inline-start: var(--sbb-spacing-fixed-8x); - - :host(:not([data-slot-names~='subtext'])) & { - display: none; - } -} - -.sbb-radio-button__label-slot { - display: flex; - align-items: flex-start; - overflow: hidden; - - &::before, - &::after { - content: ''; - flex-shrink: 0; - width: var(--sbb-radio-button-dimension); - height: var(--sbb-radio-button-dimension); - border-radius: 50%; - margin-block-start: var(--sbb-radio-button-icon-align); - - transition: { - duration: var(--sbb-disable-animation-zero-time, var(--sbb-animation-duration-4x)); - timing-function: ease; - property: background-color, border; - } - - @include sbb.if-forced-colors { - transition: none; - } - } - - // Unchecked style - &::before { - background: var(--sbb-radio-button-inner-circle-color); - - // The border was used to generate the animation of the radio-button - // The border color acts as background color. - border: var(--sbb-radio-button-background-fake-border-width) solid - var(--sbb-radio-button-background-color); - margin-inline-end: var(--sbb-spacing-fixed-2x); - } - - &::after { - position: absolute; - border: var(--sbb-radio-button-border-width) var(--sbb-radio-button-border-style) - var(--sbb-radio-button-border-color); - } -} - -.sbb-screen-reader-only { - @include sbb.screen-reader-only; -} diff --git a/src/elements/radio-button/radio-button/radio-button.ts b/src/elements/radio-button/radio-button/radio-button.ts index 77b91b739a..82e5a2194e 100644 --- a/src/elements/radio-button/radio-button/radio-button.ts +++ b/src/elements/radio-button/radio-button/radio-button.ts @@ -1,260 +1,29 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { LitElement, html, nothing } from 'lit'; +import { customElement } from 'lit/decorators.js'; -import { - SbbConnectedAbortController, - SbbLanguageController, - SbbSlotStateController, -} from '../../core/controllers.js'; -import { hostAttributes } from '../../core/decorators.js'; -import { setOrRemoveAttribute } from '../../core/dom.js'; -import { EventEmitter, formElementHandlerAspect, HandlerRepository } from '../../core/eventing.js'; -import { i18nCollapsed, i18nExpanded } from '../../core/i18n.js'; -import type { - SbbCheckedStateChange, - SbbDisabledStateChange, - SbbStateChange, -} from '../../core/interfaces.js'; -import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; -import type { SbbSelectionPanelElement } from '../../selection-panel.js'; -import type { SbbRadioButtonGroupElement } from '../radio-button-group.js'; +import { SbbSlotStateController } from '../../core/controllers.js'; +import { SbbRadioButtonCommonElementMixin, radioButtonCommonStyle } from '../common.js'; -import style from './radio-button.scss?lit&inline'; - -export type SbbRadioButtonStateChange = Extract< - SbbStateChange, - SbbDisabledStateChange | SbbCheckedStateChange ->; - -export type SbbRadioButtonSize = 's' | 'm'; +import radioButtonStyle from './radio-button.scss?lit&inline'; /** * It displays a radio button enhanced with the SBB Design. * * @slot - Use the unnamed slot to add content to the radio label. - * @slot subtext - Slot used to render a subtext under the label (only visible within a `sbb-selection-panel`). - * @slot suffix - Slot used to render additional content after the label (only visible within a `sbb-selection-panel`). */ @customElement('sbb-radio-button') -@hostAttributes({ - role: 'radio', -}) -export class SbbRadioButtonElement extends SbbUpdateSchedulerMixin(LitElement) { - public static override styles: CSSResultGroup = style; +export class SbbRadioButtonElement extends SbbRadioButtonCommonElementMixin(LitElement) { + public static override styles: CSSResultGroup = [radioButtonCommonStyle, radioButtonStyle]; public static readonly events = { stateChange: 'stateChange', - radioButtonLoaded: 'radioButtonLoaded', } as const; - /** - * Whether the radio can be deselected. - */ - @property({ attribute: 'allow-empty-selection', type: Boolean }) - public set allowEmptySelection(value: boolean) { - this._allowEmptySelection = Boolean(value); - } - public get allowEmptySelection(): boolean { - return this._allowEmptySelection || (this.group?.allowEmptySelection ?? false); - } - private _allowEmptySelection = false; - - /** - * Value of radio button. - */ - @property() public value?: string; - - /** - * Whether the radio button is disabled. - */ - @property({ reflect: true, type: Boolean }) - public set disabled(value: boolean) { - this._disabled = Boolean(value); - } - public get disabled(): boolean { - return this._disabled || (this.group?.disabled ?? false); - } - private _disabled = false; - - /** - * Whether the radio button is required. - */ - @property({ reflect: true, type: Boolean }) - public set required(value: boolean) { - this._required = Boolean(value); - } - public get required(): boolean { - return this._required || (this.group?.required ?? false); - } - private _required = false; - - /** - * Reference to the connected radio button group. - */ - public get group(): SbbRadioButtonGroupElement | null { - return this._group; - } - private _group: SbbRadioButtonGroupElement | null = null; - - /** - * Whether the radio button is checked. - */ - @property({ reflect: true, type: Boolean }) - public set checked(value: boolean) { - this._checked = Boolean(value); - } - public get checked(): boolean { - return this._checked; - } - private _checked = false; - - /** - * Label size variant, either m or s. - */ - @property({ reflect: true }) - public set size(value: SbbRadioButtonSize) { - this._size = value; - } - public get size(): SbbRadioButtonSize { - return this.group?.size ?? this._size; - } - private _size: SbbRadioButtonSize = 'm'; - - /** - * Whether the input is the main input of a selection panel. - * @internal - */ - public get isSelectionPanelInput(): boolean { - return this._isSelectionPanelInput; - } - @state() private _isSelectionPanelInput = false; - - /** - * The label describing whether the selection panel is expanded (for screen readers only). - */ - @state() private _selectionPanelExpandedLabel?: string; - - private _selectionPanelElement: SbbSelectionPanelElement | null = null; - private _abort = new SbbConnectedAbortController(this); - private _language = new SbbLanguageController(this); - - /** - * @internal - * Internal event that emits whenever the state of the radio option - * in relation to the parent selection panel changes. - */ - private _stateChange: EventEmitter = new EventEmitter( - this, - SbbRadioButtonElement.events.stateChange, - { bubbles: true }, - ); - - /** - * @internal - * Internal event that emits when the radio button is loaded. - */ - private _radioButtonLoaded: EventEmitter = new EventEmitter( - this, - SbbRadioButtonElement.events.radioButtonLoaded, - { bubbles: true }, - ); - private _handleClick(event: Event): void { - event.preventDefault(); - this.select(); - } - - public select(): void { - if (this.disabled) { - return; - } - - if (this.allowEmptySelection) { - this.checked = !this.checked; - } else if (!this.checked) { - this.checked = true; - } - } - - private _handlerRepository = new HandlerRepository(this, formElementHandlerAspect); - public constructor() { super(); new SbbSlotStateController(this); } - public override connectedCallback(): void { - super.connectedCallback(); - this._group = this.closest('sbb-radio-button-group') as SbbRadioButtonGroupElement; - // We can use closest here, as we expect the parent sbb-selection-panel to be in light DOM. - this._selectionPanelElement = this.closest('sbb-selection-panel'); - this._isSelectionPanelInput = - !!this._selectionPanelElement && !this.closest('sbb-selection-panel [slot="content"]'); - this.toggleAttribute('data-is-selection-panel-input', this._isSelectionPanelInput); - - const signal = this._abort.signal; - this.addEventListener('click', (e) => this._handleClick(e), { signal }); - this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); - this._handlerRepository.connect(); - this._radioButtonLoaded.emit(); - - // We need to call requestUpdate to update the reflected attributes - ['disabled', 'required', 'size'].forEach((p) => this.requestUpdate(p)); - } - - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - - if (changedProperties.has('checked')) { - this.setAttribute('aria-checked', `${this.checked}`); - if (this.checked !== changedProperties.get('checked')!) { - this._stateChange.emit({ type: 'checked', checked: this.checked }); - this.isSelectionPanelInput && this._updateExpandedLabel(); - } - } - if (changedProperties.has('disabled')) { - setOrRemoveAttribute(this, 'aria-disabled', this.disabled ? 'true' : null); - if (this.disabled !== changedProperties.get('disabled')!) { - this._stateChange.emit({ type: 'disabled', disabled: this.disabled }); - } - } - if (changedProperties.has('required')) { - this.setAttribute('aria-required', `${this.required}`); - } - } - - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - - // We need to wait for the selection-panel to be fully initialized - this.startUpdate(); - setTimeout(() => { - this.isSelectionPanelInput && this._updateExpandedLabel(); - this.completeUpdate(); - }); - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._handlerRepository.disconnect(); - } - - private _handleKeyDown(evt: KeyboardEvent): void { - if (evt.code === 'Space') { - this.select(); - } - } - - private _updateExpandedLabel(): void { - if (!this._selectionPanelElement?.hasContent) { - this._selectionPanelExpandedLabel = ''; - return; - } - - this._selectionPanelExpandedLabel = this.checked - ? ', ' + i18nExpanded[this._language.current] - : ', ' + i18nCollapsed[this._language.current]; - } - protected override render(): TemplateResult { return html` `; } diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md index c316bda112..7f9c5e54d7 100644 --- a/src/elements/radio-button/radio-button/readme.md +++ b/src/elements/radio-button/radio-button/readme.md @@ -62,14 +62,12 @@ The component's label can be displayed in bold using the `sbb-text--bold` class ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| -------- | ------- | ----------- | ---------- | ------ | -------------- | -| `select` | public | | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------- | ------- | ----------- | ---------- | ------ | -------------------------------- | +| `select` | public | | | `void` | SbbRadioButtonCommonElementMixin | ## Slots -| Name | Description | -| --------- | ----------------------------------------------------------------------------------------------------- | -| | Use the unnamed slot to add content to the radio label. | -| `subtext` | Slot used to render a subtext under the label (only visible within a `sbb-selection-panel`). | -| `suffix` | Slot used to render additional content after the label (only visible within a `sbb-selection-panel`). | +| Name | Description | +| ---- | ------------------------------------------------------- | +| | Use the unnamed slot to add content to the radio label. | diff --git a/src/elements/selection-expansion-panel.ts b/src/elements/selection-expansion-panel.ts new file mode 100644 index 0000000000..a851b0feb7 --- /dev/null +++ b/src/elements/selection-expansion-panel.ts @@ -0,0 +1 @@ +export * from './selection-expansion-panel/selection-expansion-panel.js'; diff --git a/src/elements/selection-expansion-panel/__snapshots__/selection-expansion-panel.snapshot.spec.snap.js b/src/elements/selection-expansion-panel/__snapshots__/selection-expansion-panel.snapshot.spec.snap.js new file mode 100644 index 0000000000..dedcbd0ac0 --- /dev/null +++ b/src/elements/selection-expansion-panel/__snapshots__/selection-expansion-panel.snapshot.spec.snap.js @@ -0,0 +1,95 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-selection-expansion-panel renders DOM"] = +` + + Value one + + Subtext + + + Suffix + + + % + + +
+ Inner content +
+
+`; +/* end snapshot sbb-selection-expansion-panel renders DOM */ + +snapshots["sbb-selection-expansion-panel renders Shadow DOM"] = +`
+
+ + +
+
+
+ + + + +
+
+
+`; +/* end snapshot sbb-selection-expansion-panel renders Shadow DOM */ + +snapshots["sbb-selection-expansion-panel renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "% ​ Value one Suffix Subtext , collapsed", + "checked": false + } + ] +} +

+`; +/* end snapshot sbb-selection-expansion-panel renders A11y tree Chrome */ + +snapshots["sbb-selection-expansion-panel renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "checkbox", + "name": "% ​ Value one Suffix Subtext , collapsed" + } + ] +} +

+`; +/* end snapshot sbb-selection-expansion-panel renders A11y tree Firefox */ + diff --git a/src/elements/selection-panel/readme.md b/src/elements/selection-expansion-panel/readme.md similarity index 65% rename from src/elements/selection-panel/readme.md rename to src/elements/selection-expansion-panel/readme.md index 9c6d85e868..0480d6c071 100644 --- a/src/elements/selection-panel/readme.md +++ b/src/elements/selection-expansion-panel/readme.md @@ -1,7 +1,7 @@ -The `sbb-selection-panel` component wraps either a [sbb-checkbox](/docs/elements-sbb-checkbox-sbb-checkbox--docs) -or a [sbb-radio-button](/docs/elements-sbb-radio-button-sbb-radio-button--docs) that can optionally toggle a content section. +The `sbb-selection-expansion-panel` component wraps either a [sbb-checkbox-panel](/docs/elements-sbb-checkbox-sbb-checkbox-panel--docs) +or a [sbb-radio-button-panel](/docs/elements-sbb-radio-button-sbb-radio-button-panel--docs) that can toggle a content section. -The content section can be opened by checking `sbb-checkbox` or selecting the `sbb-radio-button`. +The content section can be opened by checking `sbb-checkbox-panel` or selecting the `sbb-radio-button-panel`. Additionally, clicking on all the upper area sets the checked state and therefore opens the content; clicking on the content area does not toggle anything. @@ -12,13 +12,9 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). ```html - - - % - from CHF - 19.99 - - + + + % Value Subtext @@ -26,9 +22,9 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). CHF 40.00 - +
Inner Content
-
+
``` @@ -36,13 +32,9 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). ```html - - - % - from CHF - 19.99 - - + + + % Value Subtext @@ -50,27 +42,24 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). CHF 40.00 - +
Inner Content
-
+
``` -As shown in the examples above, `sbb-checkbox` and `sbb-radio-button` placed in a `sbb-selection-panel` are extended -with a slot named `subtext` for the subtext and a slot named `suffix` for the suffix items. - ## Style The component has two background options that can be set using the `color` variable: `milk` and `white` (default). ```html - ... + ... ``` -It's also possible to display the `sbb-selection-panel` without border by setting the `borderless` variable to `true`. +It's also possible to display the `sbb-selection-expansion-panel` without border by setting the `borderless` variable to `true`. ```html - ... + ... ``` @@ -94,8 +83,7 @@ It's also possible to display the `sbb-selection-panel` without border by settin ## Slots -| Name | Description | -| --------- | ------------------------------------------------------------------------------------------------------- | -| | Use the unnamed slot to add `sbb-checkbox` or `sbb-radio-button` elements to the `sbb-selection-panel`. | -| `badge` | Use this slot to provide a `sbb-card-badge` (optional). | -| `content` | Use this slot to provide custom content for the panel (optional). | +| Name | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-checkbox` or `sbb-radio-button` elements to the `sbb-selection-expansion-panel`. | +| `content` | Use this slot to provide custom content for the panel (optional). | diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.scss b/src/elements/selection-expansion-panel/selection-expansion-panel.scss new file mode 100644 index 0000000000..362809acc3 --- /dev/null +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.scss @@ -0,0 +1,163 @@ +@use '../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +// Open/Close animation vars +$open-anim-rows-from: 0fr; +$open-anim-rows-to: 1fr; +$open-anim-opacity-from: 0; +$open-anim-opacity-to: 1; + +:host { + --sbb-selection-expansion-panel-background: var(--sbb-color-white); + --sbb-selection-expansion-panel-border-color: var(--sbb-color-cloud); + + // Variable used to override background color of selection panels. + --sbb-selection-expansion-panel-inner-background: transparent; + + // Variable used to override border width of selection panels. + --sbb-selection-expansion-panel-inner-border-width: 0px; + --sbb-selection-expansion-panel-animation-duration: var( + --sbb-disable-animation-zero-time, + var(--sbb-animation-duration-4x) + ); + --sbb-selection-expansion-panel-border-width: var(--sbb-border-width-1x); + --sbb-selection-expansion-panel-content-visibility: hidden; + --sbb-selection-expansion-panel-content-padding-inline: var(--sbb-spacing-responsive-xxs); + --sbb-selection-expansion-panel-border-radius: var(--sbb-border-radius-4x); + + // As the selection panel has always a white/milk background, we have to fix the focus outline color + // to default color for cases where the selection panel is used in a negative context. + --sbb-focus-outline-color: var(--sbb-focus-outline-color-default); + + display: contents; +} + +:host([color='milk']) { + --sbb-selection-expansion-panel-background: var(--sbb-color-milk); +} + +:host([data-checked]:not([data-disabled])) { + --sbb-selection-expansion-panel-border-color: var(--sbb-color-charcoal); + --sbb-selection-expansion-panel-border-width: var(--sbb-border-width-2x); +} + +:host([data-slot-names~='content'][data-disabled]) { + --sbb-selection-expansion-panel-border-color: var(--sbb-color-cloud); +} + +:host([borderless]:not([data-checked])) { + --sbb-selection-expansion-panel-border-color: transparent; +} + +:host([data-slot-names~='content']:where([data-state='opening'], [data-state='opened'])) { + --sbb-selection-expansion-panel-content-visibility: visible; + --sbb-selection-expansion-panel-content-padding-block-end: var(--sbb-spacing-responsive-xs); +} + +.sbb-selection-expansion-panel { + flex: auto; + position: relative; + width: 100%; + background-color: var(--sbb-selection-expansion-panel-background); + border-radius: var(--sbb-selection-expansion-panel-border-radius); + + // To provide a smooth transition of width, we use box-shadow to imitate border. + box-shadow: inset 0 0 0 var(--sbb-selection-expansion-panel-border-width) + var(--sbb-selection-expansion-panel-border-color); + + transition: { + duration: var(--sbb-selection-expansion-panel-animation-duration); + timing-function: var(--sbb-animation-easing); + property: box-shadow; + } + + // For high contrast mode we need a real border + @include sbb.if-forced-colors { + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + pointer-events: none; + border: var(--sbb-selection-expansion-panel-border-width) solid + var(--sbb-selection-expansion-panel-border-color); + border-radius: var(--sbb-selection-expansion-panel-border-radius); + } + } +} + +.sbb-selection-expansion-panel__content--wrapper { + display: grid; + visibility: var(--sbb-selection-expansion-panel-content-visibility); + grid-template-rows: #{$open-anim-rows-from}; + opacity: #{$open-anim-opacity-from}; + + :host([data-state='opened']) & { + grid-template-rows: #{$open-anim-rows-to}; + opacity: #{$open-anim-opacity-to}; + } + + :host([data-state='opening']) & { + animation-name: open, open-opacity; + animation-fill-mode: forwards; + animation-duration: var(--sbb-selection-expansion-panel-animation-duration); + animation-timing-function: var(--sbb-animation-easing); + animation-delay: 0s, var(--sbb-selection-expansion-panel-animation-duration); + } + + :host([data-state='closing']) & { + animation-name: close; + animation-duration: var(--sbb-selection-expansion-panel-animation-duration); + animation-timing-function: var(--sbb-animation-easing); + } + + :host(:not([data-slot-names~='content'])) & { + display: none; + } +} + +.sbb-selection-expansion-panel__content { + overflow: hidden; + padding-inline: var(--sbb-selection-expansion-panel-content-padding-inline); + padding-block-end: var(--sbb-selection-expansion-panel-content-padding-block-end); + transition: padding var(--sbb-selection-expansion-panel-animation-duration) + var(--sbb-animation-easing); +} + +sbb-divider { + margin-block-end: var(--sbb-spacing-responsive-xxs); +} + +@keyframes open { + from { + grid-template-rows: #{$open-anim-rows-from}; + } + + to { + grid-template-rows: #{$open-anim-rows-to}; + } +} + +@keyframes open-opacity { + from { + opacity: #{$open-anim-opacity-from}; + } + + to { + opacity: #{$open-anim-opacity-to}; + } +} + +@keyframes close { + from { + grid-template-rows: #{$open-anim-rows-to}; + opacity: #{$open-anim-opacity-to}; + } + + to { + grid-template-rows: #{$open-anim-rows-from}; + opacity: #{$open-anim-opacity-from}; + } +} diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.snapshot.spec.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.snapshot.spec.ts new file mode 100644 index 0000000000..e677645a99 --- /dev/null +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.snapshot.spec.ts @@ -0,0 +1,40 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../core/testing/private.js'; + +import type { SbbSelectionExpansionPanelElement } from './selection-expansion-panel.js'; + +import '../card/card-badge.js'; +import '../checkbox/checkbox-panel.js'; +import './selection-expansion-panel.js'; + +describe(`sbb-selection-expansion-panel`, () => { + let element: SbbSelectionExpansionPanelElement; + + describe('renders', () => { + beforeEach(async () => { + element = await fixture(html` + + + Value one + Subtext + Suffix + % + +
Inner content
+
+ `); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/selection-panel/selection-panel.spec.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts similarity index 62% rename from src/elements/selection-panel/selection-panel.spec.ts rename to src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts index 3d8025e350..90a39fb47a 100644 --- a/src/elements/selection-panel/selection-panel.spec.ts +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts @@ -1,29 +1,36 @@ import { assert, expect } from '@open-wc/testing'; -import { sendKeys } from '@web/test-runner-commands'; +import { a11ySnapshot, sendKeys } from '@web/test-runner-commands'; import type { TemplateResult } from 'lit'; import { html, unsafeStatic } from 'lit/static-html.js'; -import type { SbbCheckboxGroupElement } from '../checkbox.js'; -import { SbbCheckboxElement } from '../checkbox.js'; -import { tabKey } from '../core/testing/private/keys.js'; +import { + SbbCheckboxPanelElement, + type SbbCheckboxElement, + type SbbCheckboxGroupElement, +} from '../checkbox.js'; import { fixture } from '../core/testing/private.js'; import { EventSpy, waitForCondition, waitForLitRender } from '../core/testing.js'; -import type { SbbRadioButtonGroupElement } from '../radio-button.js'; -import { SbbRadioButtonElement } from '../radio-button.js'; +import { + SbbRadioButtonPanelElement, + type SbbRadioButtonElement, + type SbbRadioButtonGroupElement, +} from '../radio-button.js'; + +import { SbbSelectionExpansionPanelElement } from './selection-expansion-panel.js'; -import { SbbSelectionPanelElement } from './selection-panel.js'; import '../link/block-link-button.js'; +import '../selection-expansion-panel.js'; -describe(`sbb-selection-panel`, () => { - let elements: SbbSelectionPanelElement[]; +describe(`sbb-selection-expansion-panel`, () => { + let elements: SbbSelectionExpansionPanelElement[]; const getPageContent = (inputType: string): TemplateResult => { const tagGroupElement = unsafeStatic(`sbb-${inputType}-group`); - const tagSingle = unsafeStatic(`sbb-${inputType}`); + const tagSingle = unsafeStatic(`sbb-${inputType}-panel`); /* eslint-disable lit/binding-positions */ return html` <${tagGroupElement} ${inputType === 'radio-button' && 'value="Value one"'}> - + <${tagSingle} id="sbb-input-1" value="Value one" ?checked='${ inputType === 'checkbox' }'>Value one @@ -31,35 +38,35 @@ describe(`sbb-selection-panel`, () => { Inner Content Link - - + + <${tagSingle} id="sbb-input-2" value="Value two">Value two
Inner Content Link
-
- + + <${tagSingle} id="sbb-input-3" value="Value three" disabled>Value three
Inner Content Link
-
- + + <${tagSingle} id="sbb-input-4" value="Value four">Value four
Inner Content Link
-
+ `; /* eslint-enable lit/binding-positions */ }; const forceOpenTest = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, - secondInput: SbbRadioButtonElement | SbbCheckboxElement, + secondInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, ): Promise => { elements.forEach((e) => (e.forceOpen = true)); await waitForLitRender(wrapper); @@ -77,8 +84,8 @@ describe(`sbb-selection-panel`, () => { const preservesDisabled = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, - disabledInput: SbbRadioButtonElement | SbbCheckboxElement, - secondInput: SbbRadioButtonElement | SbbCheckboxElement, + disabledInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, + secondInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, ): Promise => { wrapper.disabled = true; await waitForLitRender(wrapper); @@ -105,8 +112,8 @@ describe(`sbb-selection-panel`, () => { const wrapsAround = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, - firstInput: SbbRadioButtonElement | SbbCheckboxElement, - secondInput: SbbRadioButtonElement | SbbCheckboxElement, + firstInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, + secondInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, ): Promise => { secondInput.click(); secondInput.focus(); @@ -122,38 +129,42 @@ describe(`sbb-selection-panel`, () => { describe('with radio buttons', () => { let wrapper: SbbRadioButtonGroupElement; - let firstPanel: SbbSelectionPanelElement; - let firstInput: SbbRadioButtonElement; - let secondPanel: SbbSelectionPanelElement; - let secondInput: SbbRadioButtonElement; - let disabledInput: SbbRadioButtonElement; + let firstPanel: SbbSelectionExpansionPanelElement; + let firstInput: SbbRadioButtonPanelElement; + let secondPanel: SbbSelectionExpansionPanelElement; + let secondInput: SbbRadioButtonPanelElement; + let disabledInput: SbbRadioButtonPanelElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); wrapper = await fixture(getPageContent('radio-button')); - elements = Array.from(wrapper.querySelectorAll('sbb-selection-panel')); - firstPanel = wrapper.querySelector('#sbb-selection-panel-1')!; - firstInput = wrapper.querySelector('#sbb-input-1')!; - secondPanel = wrapper.querySelector('#sbb-selection-panel-2')!; - secondInput = wrapper.querySelector('#sbb-input-2')!; - disabledInput = wrapper.querySelector('#sbb-input-3')!; + elements = Array.from(wrapper.querySelectorAll('sbb-selection-expansion-panel')); + firstPanel = wrapper.querySelector( + '#sbb-selection-expansion-panel-1', + )!; + firstInput = wrapper.querySelector('#sbb-input-1')!; + secondPanel = wrapper.querySelector( + '#sbb-selection-expansion-panel-2', + )!; + secondInput = wrapper.querySelector('#sbb-input-2')!; + disabledInput = wrapper.querySelector('#sbb-input-3')!; }); it('renders', () => { - elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); - assert.instanceOf(firstPanel, SbbSelectionPanelElement); - assert.instanceOf(firstInput, SbbRadioButtonElement); - assert.instanceOf(secondPanel, SbbSelectionPanelElement); - assert.instanceOf(secondInput, SbbRadioButtonElement); + elements.forEach((e) => assert.instanceOf(e, SbbSelectionExpansionPanelElement)); + assert.instanceOf(firstPanel, SbbSelectionExpansionPanelElement); + assert.instanceOf(firstInput, SbbRadioButtonPanelElement); + assert.instanceOf(secondPanel, SbbSelectionExpansionPanelElement); + assert.instanceOf(secondInput, SbbRadioButtonPanelElement); }); it('selects input on click and shows related content', async () => { - willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); await waitForLitRender(wrapper); @@ -217,7 +228,7 @@ describe(`sbb-selection-panel`, () => { }); it('focuses input on left arrow key pressed and selects it on space key pressed', async () => { - const fourthInput = wrapper.querySelector('#sbb-input-4')!; + const fourthInput = wrapper.querySelector('#sbb-input-4')!; firstInput.click(); firstInput.focus(); @@ -251,130 +262,69 @@ describe(`sbb-selection-panel`, () => { }); }); - describe('with radio group with no slotted content', () => { - it('focus selected, the focus and select on keyboard navigation', async () => { - const wrapperNoContent = await fixture(html` - - - Value one - - - Value two - - - Value three - - - Value four - - - `); - const firstInputNoContent = - wrapperNoContent.querySelector('#input-no-content-1')!; - const secondInputNoContent = - wrapperNoContent.querySelector('#input-no-content-2')!; - const fourthInputNoContent = - wrapperNoContent.querySelector('#input-no-content-4')!; - const firstPanel = wrapperNoContent.querySelector('#no-content-1')!; - const secondPanel = - wrapperNoContent.querySelector('#no-content-2')!; - - expect(firstPanel).to.have.attribute('data-state', 'closed'); - expect(secondPanel).to.have.attribute('data-state', 'closed'); - - await sendKeys({ press: tabKey }); - await waitForLitRender(wrapperNoContent); - expect(document.activeElement!.id).to.be.equal(secondInputNoContent.id); - - await sendKeys({ press: 'ArrowUp' }); - await waitForLitRender(wrapperNoContent); - expect(document.activeElement!.id).to.be.equal(firstInputNoContent.id); - expect(secondInputNoContent.checked).to.be.false; - expect(firstInputNoContent.checked).to.be.true; - - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(wrapperNoContent); - expect(document.activeElement!.id).to.be.equal(secondInputNoContent.id); - expect(firstInputNoContent.checked).to.be.false; - expect(secondInputNoContent.checked).to.be.true; - - await sendKeys({ press: 'ArrowDown' }); - await waitForLitRender(wrapperNoContent); - expect(document.activeElement!.id).to.be.equal(fourthInputNoContent.id); - expect(secondInputNoContent.checked).to.be.false; - expect(fourthInputNoContent.checked).to.be.true; - - await sendKeys({ press: 'ArrowLeft' }); - await waitForLitRender(wrapperNoContent); - expect(document.activeElement!.id).to.be.equal(secondInputNoContent.id); - expect(fourthInputNoContent.checked).to.be.false; - expect(secondInputNoContent.checked).to.be.true; - }); - }); - describe('with nested radio buttons', () => { let nestedElement: SbbRadioButtonGroupElement; - let panel1: SbbSelectionPanelElement; - let panel2: SbbSelectionPanelElement; + let panel1: SbbSelectionExpansionPanelElement; + let panel2: SbbSelectionExpansionPanelElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; let willCloseEventSpy: EventSpy; let didCloseEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); - willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); - didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.didClose); + willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didClose); nestedElement = await fixture(html` - - Main Option 1 + + + Main Option 1 Suboption 1 Suboption 2 - + - - Main Option 2 + + Main Option 2 Suboption 3 Suboption 4 - + `); - panel1 = nestedElement.querySelector('#panel1')!; - panel2 = nestedElement.querySelector('#panel2')!; + panel1 = nestedElement.querySelector('#panel1')!; + panel2 = nestedElement.querySelector('#panel2')!; }); it('should display expanded label correctly', async () => { - const mainRadioButton1 = nestedElement.querySelector( - "sbb-radio-button[value='main1']", - )!; - const mainRadioButton1Label = mainRadioButton1.shadowRoot!.querySelector( - '.sbb-screen-reader-only:not(input)', - )!; - const mainRadioButton2 = nestedElement.querySelector( - "sbb-radio-button[value='main2']", - )!; - const mainRadioButton2Label = mainRadioButton2.shadowRoot!.querySelector( - '.sbb-screen-reader-only:not(input)', - )!; - const subRadioButton1 = nestedElement - .querySelector("sbb-radio-button[value='sub1']")! - .shadowRoot!.querySelector('.sbb-screen-reader-only:not(input)'); + const mainRadioButton2: SbbRadioButtonPanelElement = + nestedElement.querySelector( + "sbb-radio-button-panel[value='main2']", + )!; + + const mainRadioButton1Label = (await a11ySnapshot({ + selector: 'sbb-radio-button-panel[value="main1"]', + })) as unknown as { name: string }; + + const mainRadioButton2Label = (await a11ySnapshot({ + selector: 'sbb-radio-button-panel[value="main2"]', + })) as unknown as { name: string }; + + // We assert that there was no fade in animation (skipped opening state). + await waitForCondition(() => panel1.getAttribute('data-state') === 'opening', 1, 100) + .then(() => Promise.reject('accidentally passed')) + .catch((error) => expect(error).to.include('timeout')); await waitForCondition(() => didOpenEventSpy.count === 1); expect(willOpenEventSpy.count).to.be.equal(1); expect(didOpenEventSpy.count).to.be.equal(1); - expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', expanded'); - expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', collapsed'); - expect(subRadioButton1).to.be.null; + expect(mainRadioButton1Label.name.trim()).to.be.equal('Main Option 1 , expanded'); + expect(mainRadioButton2Label.name.trim()).to.be.equal('Main Option 2 , collapsed'); expect(panel1).to.have.attribute('data-state', 'opened'); expect(panel2).to.have.attribute('data-state', 'closed'); @@ -384,13 +334,23 @@ describe(`sbb-selection-panel`, () => { await waitForCondition(() => didOpenEventSpy.count === 2); await waitForCondition(() => didCloseEventSpy.count === 1); + const mainRadioButton1LabelSecondRender = (await a11ySnapshot({ + selector: 'sbb-radio-button-panel[value="main1"]', + })) as unknown as { name: string }; + + const mainRadioButton2LabelSecondRender = (await a11ySnapshot({ + selector: 'sbb-radio-button-panel[value="main2"]', + })) as unknown as { name: string }; + expect(willOpenEventSpy.count).to.be.equal(2); expect(didOpenEventSpy.count).to.be.equal(2); expect(willCloseEventSpy.count).to.be.equal(1); expect(didCloseEventSpy.count).to.be.equal(1); - expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', collapsed'); - expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', expanded'); - expect(subRadioButton1).to.be.null; + expect(mainRadioButton1LabelSecondRender.name.trim()).to.be.equal( + 'Main Option 1 , collapsed', + ); + expect(mainRadioButton2LabelSecondRender.name.trim()).to.be.equal('Main Option 2 , expanded'); + expect(panel1).to.have.attribute('data-state', 'closed'); expect(panel2).to.have.attribute('data-state', 'opened'); }); @@ -399,7 +359,9 @@ describe(`sbb-selection-panel`, () => { nestedElement.toggleAttribute('disabled', true); await waitForLitRender(nestedElement); - const radioButtons = Array.from(nestedElement.querySelectorAll('sbb-radio-button')); + const radioButtons: SbbRadioButtonPanelElement[] = Array.from( + nestedElement.querySelectorAll('sbb-radio-button-panel, sbb-radio-button'), + ); expect(radioButtons.length).to.be.equal(6); expect(radioButtons[0]).to.have.attribute('disabled'); @@ -411,12 +373,14 @@ describe(`sbb-selection-panel`, () => { }); it('should not with interfere content on selection', async () => { - const main1 = nestedElement.querySelector( - 'sbb-radio-button[value="main1"]', - )!; - const main2 = nestedElement.querySelector( - 'sbb-radio-button[value="main2"]', - )!; + const main1: SbbRadioButtonPanelElement = + nestedElement.querySelector( + 'sbb-radio-button-panel[value="main1"]', + )!; + const main2: SbbRadioButtonPanelElement = + nestedElement.querySelector( + 'sbb-radio-button-panel[value="main2"]', + )!; const sub1 = nestedElement.querySelector( 'sbb-radio-button[value="sub1"]', )!; @@ -453,21 +417,23 @@ describe(`sbb-selection-panel`, () => { const root = await fixture(html`
@@ -476,7 +442,7 @@ describe(`sbb-selection-panel`, () => { const radioGroup = root.querySelector('sbb-radio-button-group')!; const selectionPanels = Array.from( - root.querySelector('template')!.content.querySelectorAll('sbb-selection-panel'), + root.querySelector('template')!.content.querySelectorAll('sbb-selection-expansion-panel'), ); selectionPanels.forEach((el) => radioGroup.appendChild(el)); @@ -498,38 +464,42 @@ describe(`sbb-selection-panel`, () => { describe('with checkboxes', () => { let wrapper: SbbCheckboxGroupElement; - let firstPanel: SbbSelectionPanelElement; - let firstInput: SbbCheckboxElement; - let secondPanel: SbbSelectionPanelElement; - let secondInput: SbbCheckboxElement; - let disabledInput: SbbCheckboxElement; + let firstPanel: SbbSelectionExpansionPanelElement; + let firstInput: SbbCheckboxPanelElement; + let secondPanel: SbbSelectionExpansionPanelElement; + let secondInput: SbbCheckboxPanelElement; + let disabledInput: SbbCheckboxPanelElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; let willCloseEventSpy: EventSpy; let didCloseEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); - willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); - didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.didClose); + willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didClose); wrapper = await fixture(getPageContent('checkbox')); - elements = Array.from(wrapper.querySelectorAll('sbb-selection-panel')); - firstPanel = wrapper.querySelector('#sbb-selection-panel-1')!; - firstInput = wrapper.querySelector('#sbb-input-1')!; - secondPanel = wrapper.querySelector('#sbb-selection-panel-2')!; - secondInput = wrapper.querySelector('#sbb-input-2')!; - disabledInput = wrapper.querySelector('#sbb-input-3')!; + elements = Array.from(wrapper.querySelectorAll('sbb-selection-expansion-panel')); + firstPanel = wrapper.querySelector( + '#sbb-selection-expansion-panel-1', + )!; + firstInput = wrapper.querySelector('#sbb-input-1')!; + secondPanel = wrapper.querySelector( + '#sbb-selection-expansion-panel-2', + )!; + secondInput = wrapper.querySelector('#sbb-input-2')!; + disabledInput = wrapper.querySelector('#sbb-input-3')!; }); it('renders', () => { - elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); + elements.forEach((e) => assert.instanceOf(e, SbbSelectionExpansionPanelElement)); - assert.instanceOf(firstPanel, SbbSelectionPanelElement); - assert.instanceOf(firstInput, SbbCheckboxElement); - assert.instanceOf(secondPanel, SbbSelectionPanelElement); - assert.instanceOf(secondInput, SbbCheckboxElement); + assert.instanceOf(firstPanel, SbbSelectionExpansionPanelElement); + assert.instanceOf(firstInput, SbbCheckboxPanelElement); + assert.instanceOf(secondPanel, SbbSelectionExpansionPanelElement); + assert.instanceOf(secondInput, SbbCheckboxPanelElement); }); it('selects input on click and shows related content', async () => { @@ -604,11 +574,13 @@ describe(`sbb-selection-panel`, () => { }); it('focuses input on left arrow key pressed and selects it on space key pressed', async () => { - const fourthInput = wrapper.querySelector('#sbb-input-4')!; + const fourthInput: SbbCheckboxPanelElement = + wrapper.querySelector('#sbb-input-4')!; firstInput.click(); firstInput.focus(); await sendKeys({ press: 'ArrowLeft' }); + await waitForCondition(() => !firstInput.checked); await waitForLitRender(wrapper); expect(document.activeElement!.id).to.be.equal(fourthInput.id); expect(firstInput.checked).to.be.false; @@ -648,45 +620,41 @@ describe(`sbb-selection-panel`, () => { beforeEach(async () => { nestedElement = await fixture(html` - - Main Option 1 + + Main Option 1 Suboption 1 Suboption 2 - + - - Main Option 2 + + Main Option 2 Suboption 3 Suboption 4 - + `); }); it('should display expanded label correctly', async () => { - const mainCheckbox1: SbbCheckboxElement = nestedElement.querySelector( - "sbb-checkbox[value='main1']", - )!; - const mainCheckbox1Label = mainCheckbox1.shadowRoot!.querySelector( - '.sbb-checkbox__expanded-label', - )!; - const mainCheckbox2: SbbCheckboxElement = nestedElement.querySelector( - "sbb-checkbox[value='main2']", - )!; - const mainCheckbox2Label = mainCheckbox2.shadowRoot!.querySelector( - '.sbb-checkbox__expanded-label', - )!; - const subCheckbox1 = document - .querySelector("sbb-checkbox[value='sub1']")! - .shadowRoot!.querySelector('.sbb-checkbox__expanded-label'); + const mainCheckbox1: SbbCheckboxPanelElement = + nestedElement.querySelector("sbb-checkbox-panel[value='main1']")!; + const mainCheckbox2: SbbCheckboxPanelElement = + nestedElement.querySelector("sbb-checkbox-panel[value='main2']")!; - expect(mainCheckbox1Label.textContent!.trim()).to.be.equal(', expanded'); - expect(mainCheckbox2Label.textContent!.trim()).to.be.equal(', collapsed'); - expect(subCheckbox1).to.be.empty; + const mainCheckbox1Label = (await a11ySnapshot({ + selector: 'sbb-checkbox-panel[value="main1"]', + })) as unknown as { name: string }; + + const mainCheckbox2Label = (await a11ySnapshot({ + selector: 'sbb-checkbox-panel[value="main2"]', + })) as unknown as { name: string }; + + expect(mainCheckbox1Label.name.trim()).to.be.equal('​ Main Option 1 , expanded'); + expect(mainCheckbox2Label.name.trim()).to.be.equal('​ Main Option 2 , collapsed'); // Deactivate main option 1 mainCheckbox1.click(); @@ -696,16 +664,25 @@ describe(`sbb-selection-panel`, () => { await waitForLitRender(nestedElement); - expect(mainCheckbox1Label.textContent!.trim()).to.be.equal(', collapsed'); - expect(mainCheckbox2Label.textContent!.trim()).to.be.equal(', expanded'); - expect(subCheckbox1).to.be.empty; + const mainCheckbox1LabelSecondRender = (await a11ySnapshot({ + selector: 'sbb-checkbox-panel[value="main1"]', + })) as unknown as { name: string }; + + const mainCheckbox2LabelSecondRender = (await a11ySnapshot({ + selector: 'sbb-checkbox-panel[value="main2"]', + })) as unknown as { name: string }; + + expect(mainCheckbox1LabelSecondRender.name.trim()).to.be.equal('​ Main Option 1 , collapsed'); + expect(mainCheckbox2LabelSecondRender.name.trim()).to.be.equal('​ Main Option 2 , expanded'); }); it('should mark only outer group children as disabled', async () => { nestedElement.toggleAttribute('disabled', true); await waitForLitRender(nestedElement); - const checkboxes = Array.from(nestedElement.querySelectorAll('sbb-checkbox')); + const checkboxes: (SbbCheckboxPanelElement | SbbCheckboxElement)[] = Array.from( + nestedElement.querySelectorAll('sbb-checkbox-panel, sbb-checkbox'), + ); expect(checkboxes.length).to.be.equal(6); expect(checkboxes[0]).to.have.attribute('disabled'); diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts new file mode 100644 index 0000000000..fb427f35e1 --- /dev/null +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts @@ -0,0 +1,27 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { fixture } from '../core/testing/private.js'; + +import { SbbSelectionExpansionPanelElement } from './selection-expansion-panel.js'; + +import '../checkbox.js'; + +describe(`sbb-selection-expansion-panel ${fixture.name}`, () => { + let root: SbbSelectionExpansionPanelElement; + + beforeEach(async () => { + root = await fixture( + html` + Value + `, + { + modules: ['./selection-expansion-panel.js', '../checkbox.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbSelectionExpansionPanelElement); + }); +}); diff --git a/src/elements/selection-panel/selection-panel.stories.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts similarity index 64% rename from src/elements/selection-panel/selection-panel.stories.ts rename to src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts index f869b97391..abcf65a8e8 100644 --- a/src/elements/selection-panel/selection-panel.stories.ts +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts @@ -12,19 +12,20 @@ import type { SbbRadioButtonGroupElement, SbbRadioButtonGroupEventDetail, } from '../radio-button.js'; +import { SbbSelectionExpansionPanelElement } from '../selection-expansion-panel.js'; -import readme from './readme.md?raw'; -import { SbbSelectionPanelElement } from './selection-panel.js'; import '../card.js'; import '../checkbox.js'; import '../divider.js'; import '../form-error.js'; import '../icon.js'; import '../link/block-link-button.js'; -import '../radio-button.js'; import '../popover.js'; +import '../radio-button.js'; import '../title.js'; +import readme from './readme.md?raw'; + const color: InputType = { control: { type: 'inline-radio', @@ -90,9 +91,7 @@ const suffixAndSubtext = (): TemplateResult => html` - - CHF 40.00 - + CHF 40.00 `; @@ -111,13 +110,12 @@ const WithCheckboxTemplate = ({ disabledInput, ...args }: Args): TemplateResult => html` - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + `; const WithRadioButtonTemplate = ({ @@ -125,13 +123,12 @@ const WithRadioButtonTemplate = ({ disabledInput, ...args }: Args): TemplateResult => html` - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + `; const WithCheckboxGroupTemplate = ({ @@ -140,23 +137,24 @@ const WithCheckboxGroupTemplate = ({ ...args }: Args): TemplateResult => html` - - ${cardBadge()} - Value one ${suffixAndSubtext()} + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value two ${suffixAndSubtext()} + + + Value two ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value three ${suffixAndSubtext()} + + Value three ${suffixAndSubtext()} ${cardBadge()} ${innerContent()} - + `; @@ -171,27 +169,26 @@ const WithRadioButtonGroupTemplate = ({ horizontal-from="large" ?allow-empty-selection=${allowEmptySelection} > - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - - Value two ${suffixAndSubtext()} - + + + Value two ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value three ${suffixAndSubtext()} + + + Value three ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + `; @@ -201,33 +198,30 @@ const TicketsOptionsExampleTemplate = ({ ...args }: Args): TemplateResult => html` - - ${cardBadge()} - Saving ${suffixAndSubtext()} + + + Saving ${suffixAndSubtext()} ${cardBadge()} +
Non-Flex - No refund possible - - - CHF 0.00 - + + CHF 0.00 Semi-Flex - Partial refund possible - - - + CHF 5.00 - + + + CHF 5.00 -
+
1 x 0 x Supersaver ticket, Half-Fare Card${' '} Simple popover
- + - - ${cardBadge()} - City offer ${suffixAndSubtext()} + + + City offer ${suffixAndSubtext()} ${cardBadge()} +
Option one - - - CHF 0.00 - + + CHF 0.00 Option two - - - + CHF 5.00 - + + + CHF 5.00 -
+
1 x 0 x City Ticket incl. City Supplement City, Half-Fare Card${' '} Simple popover
- + `; @@ -295,25 +288,25 @@ const NestedRadioTemplate = ({ ...args }: Args): TemplateResult => html` - - + + Main Option 1 - + Suboption 1 Suboption 2 - + - - + + Main Option 2 - + Suboption 1 Suboption 2 - + `; @@ -323,21 +316,25 @@ const NestedCheckboxTemplate = ({ ...args }: Args): TemplateResult => html` - - Main Option 1 + + + Main Option 1 + Suboption 1 Suboption 2 - + - - Main Option 2 + + + Main Option 2 + Suboption 1 Suboption 2 - + `; @@ -366,23 +363,24 @@ const WithCheckboxesErrorMessageTemplate = ({ } }} > - - ${cardBadge()} - Value one ${suffixAndSubtext()} + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value two ${suffixAndSubtext()} + + + Value two ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value three ${suffixAndSubtext()} + + Value three ${suffixAndSubtext()} ${cardBadge()} ${innerContent()} - + ${sbbFormError} `; @@ -411,79 +409,31 @@ const WithRadiosErrorMessageTemplate = ({ } }} > - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - + + + Value one ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - - Value two ${suffixAndSubtext()} - + + + Value two ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + - - ${cardBadge()} - Value three ${suffixAndSubtext()} + + + Value three ${suffixAndSubtext()} ${cardBadge()} + ${innerContent()} - + ${sbbFormError} `; }; -const WithNoContentTemplate = ({ - checkedInput, - disabledInput, - ...args -}: Args): TemplateResult => html` - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - - - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - - -`; - -const WithNoContentGroupTemplate = ({ - checkedInput, - disabledInput, - ...args -}: Args): TemplateResult => html` - - - ${cardBadge()} - - Value one ${suffixAndSubtext()} - - - - ${cardBadge()} - - Value two ${suffixAndSubtext()} - - - - ${cardBadge()} - Value three ${suffixAndSubtext()} - - -`; - export const WithCheckbox: StoryObj = { render: WithCheckboxTemplate, argTypes: basicArgTypes, @@ -665,24 +615,6 @@ export const WithRadiosErrorMessage: StoryObj = { }, }; -export const WithNoContent: StoryObj = { - render: WithNoContentTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, checkedInput: true }, -}; - -export const WithNoContentCheckedDisabled: StoryObj = { - render: WithNoContentTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, checkedInput: true, disabledInput: true }, -}; - -export const WithNoContentGroup: StoryObj = { - render: WithNoContentGroupTemplate, - argTypes: basicArgTypes, - args: { ...basicArgs, checkedInput: true }, -}; - export const TicketsOptionsExample: StoryObj = { render: TicketsOptionsExampleTemplate, argTypes: basicArgTypes, @@ -707,17 +639,17 @@ const meta: Meta = { chromatic: { delay: 9000, fixedHeight: '14500px' }, actions: { handles: [ - SbbSelectionPanelElement.events.didOpen, - SbbSelectionPanelElement.events.didClose, - SbbSelectionPanelElement.events.willOpen, - SbbSelectionPanelElement.events.willClose, + SbbSelectionExpansionPanelElement.events.didOpen, + SbbSelectionExpansionPanelElement.events.didClose, + SbbSelectionExpansionPanelElement.events.willOpen, + SbbSelectionExpansionPanelElement.events.willClose, ], }, docs: { extractComponentDescription: () => readme, }, }, - title: 'elements/sbb-selection-panel', + title: 'elements/sbb-selection-expansion-panel', }; export default meta; diff --git a/src/elements/selection-panel/selection-panel.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.ts similarity index 73% rename from src/elements/selection-panel/selection-panel.ts rename to src/elements/selection-expansion-panel/selection-expansion-panel.ts index 13b4c9e82d..9030d5ae11 100644 --- a/src/elements/selection-panel/selection-panel.ts +++ b/src/elements/selection-expansion-panel/selection-expansion-panel.ts @@ -2,29 +2,34 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { SbbCheckboxElement } from '../checkbox.js'; -import { SbbConnectedAbortController, SbbSlotStateController } from '../core/controllers.js'; +import type { SbbCheckboxPanelElement } from '../checkbox.js'; +import { + SbbConnectedAbortController, + SbbLanguageController, + SbbSlotStateController, +} from '../core/controllers.js'; import { EventEmitter } from '../core/eventing.js'; +import { i18nCollapsed, i18nExpanded } from '../core/i18n.js'; import type { SbbOpenedClosedState, SbbStateChange } from '../core/interfaces.js'; -import type { SbbRadioButtonElement } from '../radio-button.js'; +import { SbbHydrationMixin } from '../core/mixins.js'; +import type { SbbRadioButtonPanelElement } from '../radio-button.js'; -import style from './selection-panel.scss?lit&inline'; +import style from './selection-expansion-panel.scss?lit&inline'; import '../divider.js'; /** * It displays an expandable panel connected to a `sbb-checkbox` or to a `sbb-radio-button`. * - * @slot - Use the unnamed slot to add `sbb-checkbox` or `sbb-radio-button` elements to the `sbb-selection-panel`. - * @slot badge - Use this slot to provide a `sbb-card-badge` (optional). + * @slot - Use the unnamed slot to add `sbb-checkbox` or `sbb-radio-button` elements to the `sbb-selection-expansion-panel`. * @slot content - Use this slot to provide custom content for the panel (optional). * @event {CustomEvent} willOpen - Emits whenever the content section starts the opening transition. * @event {CustomEvent} didOpen - Emits whenever the content section is opened. * @event {CustomEvent} willClose - Emits whenever the content section begins the closing transition. * @event {CustomEvent} didClose - Emits whenever the content section is closed. */ -@customElement('sbb-selection-panel') -export class SbbSelectionPanelElement extends LitElement { +@customElement('sbb-selection-expansion-panel') +export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElement) { // FIXME inheriting from SbbOpenCloseBaseElement requires: https://github.com/open-wc/custom-elements-manifest/issues/253 public static override styles: CSSResultGroup = style; public static readonly events: Record = { @@ -68,35 +73,35 @@ export class SbbSelectionPanelElement extends LitElement { /** Emits whenever the content section starts the opening transition. */ private _willOpen: EventEmitter = new EventEmitter( this, - SbbSelectionPanelElement.events.willOpen, + SbbSelectionExpansionPanelElement.events.willOpen, ); /** Emits whenever the content section is opened. */ private _didOpen: EventEmitter = new EventEmitter( this, - SbbSelectionPanelElement.events.didOpen, + SbbSelectionExpansionPanelElement.events.didOpen, ); /** Emits whenever the content section begins the closing transition. */ private _willClose: EventEmitter = new EventEmitter( this, - SbbSelectionPanelElement.events.willClose, + SbbSelectionExpansionPanelElement.events.willClose, ); /** Emits whenever the content section is closed. */ private _didClose: EventEmitter = new EventEmitter( this, - SbbSelectionPanelElement.events.didClose, + SbbSelectionExpansionPanelElement.events.didClose, ); + private _language = new SbbLanguageController(this); private _abort = new SbbConnectedAbortController(this); private _initialized: boolean = false; /** * Whether it has an expandable content - * @internal */ - public get hasContent(): boolean { + private get _hasContent(): boolean { // We cannot use the NamedSlots because it's too slow to initialize return this.querySelectorAll?.('[slot="content"]').length > 0; } @@ -108,11 +113,12 @@ export class SbbSelectionPanelElement extends LitElement { public override connectedCallback(): void { super.connectedCallback(); + + this.addEventListener('panelConnected', this._initFromInput.bind(this), { + signal: this._abort.signal, + }); + this._state ||= 'closed'; - const signal = this._abort.signal; - this.addEventListener('stateChange', this._onInputStateChange.bind(this), { signal }); - this.addEventListener('checkboxLoaded', this._initFromInput.bind(this), { signal }); - this.addEventListener('radioButtonLoaded', this._initFromInput.bind(this), { signal }); } protected override willUpdate(changedProperties: PropertyValues): void { @@ -130,11 +136,12 @@ export class SbbSelectionPanelElement extends LitElement { } private _updateState(): void { - if (!this.hasContent) { + if (!this._hasContent) { return; } this.forceOpen || this._checked ? this._open(!this._initialized) : this._close(); + this._updateExpandedLabel(this.forceOpen || this._checked); } private _open(skipAnimation = false): void { @@ -161,11 +168,7 @@ export class SbbSelectionPanelElement extends LitElement { } private _initFromInput(event: Event): void { - const input = event.target as SbbCheckboxElement | SbbRadioButtonElement; - - if (!input.isSelectionPanelInput) { - return; - } + const input = event.target as SbbCheckboxPanelElement | SbbRadioButtonPanelElement; this._checked = input.checked; this._disabled = input.disabled; @@ -173,12 +176,6 @@ export class SbbSelectionPanelElement extends LitElement { } private _onInputStateChange(event: CustomEvent): void { - const input = event.target as SbbCheckboxElement | SbbRadioButtonElement; - - if (!input.isSelectionPanelInput) { - return; - } - if (event.detail.type === 'disabled') { this._disabled = event.detail.disabled; return; @@ -200,22 +197,38 @@ export class SbbSelectionPanelElement extends LitElement { } } + private async _updateExpandedLabel(open: boolean): Promise { + await this.hydrationComplete; + + const panelElement = this.querySelector( + 'sbb-radio-button-panel, sbb-checkbox-panel', + ); + if (!panelElement) { + return; + } + + if (!this._hasContent) { + panelElement.expansionState = ''; + return; + } + + panelElement.expansionState = open + ? ', ' + i18nExpanded[this._language.current] + : ', ' + i18nCollapsed[this._language.current]; + } + protected override render(): TemplateResult { return html` -
-
- -
- -
+
+
this._onAnimationEnd(event)} > -
+
@@ -228,6 +241,6 @@ export class SbbSelectionPanelElement extends LitElement { declare global { interface HTMLElementTagNameMap { // eslint-disable-next-line @typescript-eslint/naming-convention - 'sbb-selection-panel': SbbSelectionPanelElement; + 'sbb-selection-expansion-panel': SbbSelectionExpansionPanelElement; } } diff --git a/src/elements/selection-panel.ts b/src/elements/selection-panel.ts deleted file mode 100644 index 61c0949e27..0000000000 --- a/src/elements/selection-panel.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './selection-panel/selection-panel.js'; diff --git a/src/elements/selection-panel/__snapshots__/selection-panel.snapshot.spec.snap.js b/src/elements/selection-panel/__snapshots__/selection-panel.snapshot.spec.snap.js deleted file mode 100644 index eeaa254719..0000000000 --- a/src/elements/selection-panel/__snapshots__/selection-panel.snapshot.spec.snap.js +++ /dev/null @@ -1,135 +0,0 @@ -/* @web/test-runner snapshot v1 */ -export const snapshots = {}; - -snapshots["sbb-selection-panel renders - DOM"] = -` - - - % - - - from CHF - - - 19.99 - - - - Value one - - Subtext - - - Suffix - - -
- Inner content -
-
-`; -/* end snapshot sbb-selection-panel renders - DOM */ - -snapshots["sbb-selection-panel renders - Shadow DOM"] = -`
-
- - -
-
- - -
-
-
- - - - -
-
-
-`; -/* end snapshot sbb-selection-panel renders - Shadow DOM */ - -snapshots["sbb-selection-panel A11y tree Chrome"] = -`

- { - "role": "WebArea", - "name": "", - "children": [ - { - "role": "text", - "name": "%" - }, - { - "role": "text", - "name": "from CHF" - }, - { - "role": "text", - "name": "19.99" - }, - { - "role": "checkbox", - "name": "​ Value one Suffix Subtext , collapsed", - "checked": false - } - ] -} -

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

- { - "role": "document", - "name": "", - "children": [ - { - "role": "text leaf", - "name": "%" - }, - { - "role": "text leaf", - "name": "from CHF" - }, - { - "role": "text leaf", - "name": "19.99" - }, - { - "role": "checkbox", - "name": "​ Value one Suffix Subtext , collapsed" - } - ] -} -

-`; -/* end snapshot sbb-selection-panel A11y tree Firefox */ - diff --git a/src/elements/selection-panel/selection-panel.scss b/src/elements/selection-panel/selection-panel.scss deleted file mode 100644 index a977c9694e..0000000000 --- a/src/elements/selection-panel/selection-panel.scss +++ /dev/null @@ -1,199 +0,0 @@ -@use '../core/styles' as sbb; - -// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. -@include sbb.box-sizing; - -// Open/Close animation vars -$open-anim-rows-from: 0fr; -$open-anim-rows-to: 1fr; -$open-anim-opacity-from: 0; -$open-anim-opacity-to: 1; - -:host { - --sbb-selection-panel-cursor: pointer; - --sbb-selection-panel-background: var(--sbb-color-white); - --sbb-selection-panel-border-color: var(--sbb-color-cloud); - --sbb-selection-panel-animation-duration: var( - --sbb-disable-animation-zero-time, - var(--sbb-animation-duration-4x) - ); - --sbb-selection-panel-border-width: var(--sbb-border-width-1x); - --sbb-selection-panel-input-pointer-events: all; - --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) - var(--sbb-spacing-responsive-xxs); - --sbb-selection-panel-content-visibility: hidden; - --sbb-selection-panel-content-padding-inline: var(--sbb-spacing-responsive-xxs); - --sbb-selection-panel-border-radius: var(--sbb-border-radius-4x); - - // As the selection panel has always a white/milk background, we have to fix the focus outline color - // to default color for cases where the selection panel is used in a negative context. - --sbb-focus-outline-color: var(--sbb-focus-outline-color-default); - - display: contents; -} - -:host([color='milk']) { - --sbb-selection-panel-background: var(--sbb-color-milk); -} - -:host([data-checked]:not([data-disabled])) { - --sbb-selection-panel-border-color: var(--sbb-color-charcoal); - --sbb-selection-panel-border-width: var(--sbb-border-width-2x); -} - -:host([data-slot-names~='content'][data-disabled]) { - --sbb-selection-panel-input-pointer-events: none; - --sbb-selection-panel-border-color: var(--sbb-color-cloud); -} - -:host([data-disabled]) { - --sbb-selection-panel-cursor: default; -} - -:host([borderless]:not([data-checked])) { - --sbb-selection-panel-border-color: transparent; -} - -:host([data-resize-disable-animation]) { - @include sbb.disable-animation; -} - -:host([data-slot-names~='content']:where([data-state='opening'], [data-state='opened'])) { - --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) - var(--sbb-spacing-responsive-xxs) var(--sbb-spacing-responsive-xxs) - var(--sbb-spacing-responsive-xxs); - --sbb-selection-panel-content-visibility: visible; - --sbb-selection-panel-content-grid-template-rows: 1fr; - --sbb-selection-panel-content-opacity: 1; - --sbb-selection-panel-content-padding-block-end: var(--sbb-spacing-responsive-xs); - --sbb-selection-panel-content-transition: grid-template-rows - var(--sbb-selection-panel-animation-duration) var(--sbb-animation-easing), - opacity var(--sbb-selection-panel-animation-duration) - var(--sbb-selection-panel-animation-duration) var(--sbb-animation-easing); -} - -.sbb-selection-panel { - flex: auto; - position: relative; - width: 100%; - background-color: var(--sbb-selection-panel-background); - border-radius: var(--sbb-selection-panel-border-radius); - - // To provide a smooth transition of width, we use box-shadow to imitate border. - box-shadow: inset 0 0 0 var(--sbb-selection-panel-border-width) - var(--sbb-selection-panel-border-color); - - transition: { - duration: var(--sbb-selection-panel-animation-duration); - timing-function: var(--sbb-animation-easing); - property: box-shadow; - } - - // For high contrast mode we need a real border - @include sbb.if-forced-colors { - &::after { - content: ''; - display: block; - position: absolute; - inset: 0; - pointer-events: none; - border: var(--sbb-selection-panel-border-width) solid var(--sbb-selection-panel-border-color); - border-radius: var(--sbb-selection-panel-border-radius); - } - } -} - -.sbb-selection-panel__badge { - user-select: none; - pointer-events: none; - position: absolute; - inset: 0; - border-radius: var(--sbb-selection-panel-border-radius); - overflow: hidden; -} - -.sbb-selection-panel__content--wrapper { - display: grid; - visibility: var(--sbb-selection-panel-content-visibility); - grid-template-rows: #{$open-anim-rows-from}; - opacity: #{$open-anim-opacity-from}; - - :host([data-state='opened']) & { - grid-template-rows: #{$open-anim-rows-to}; - opacity: #{$open-anim-opacity-to}; - } - - :host([data-state='opening']) & { - animation-name: open, open-opacity; - animation-fill-mode: forwards; - animation-duration: var(--sbb-selection-panel-animation-duration); - animation-timing-function: var(--sbb-animation-easing); - animation-delay: 0s, var(--sbb-selection-panel-animation-duration); - } - - :host([data-state='closing']) & { - animation-name: close; - animation-duration: var(--sbb-selection-panel-animation-duration); - animation-timing-function: var(--sbb-animation-easing); - } - - :host(:not([data-slot-names~='content'])) & { - display: none; - } -} - -.sbb-selection-panel__content { - overflow: hidden; - padding-inline: var(--sbb-selection-panel-content-padding-inline); - padding-block-end: var(--sbb-selection-panel-content-padding-block-end); - transition: padding var(--sbb-selection-panel-animation-duration) var(--sbb-animation-easing); -} - -sbb-divider { - margin-block-end: var(--sbb-spacing-responsive-xxs); -} - -::slotted(sbb-radio-button), -::slotted(sbb-checkbox) { - cursor: var(--sbb-selection-panel-cursor); - pointer-events: var(--sbb-selection-panel-input-pointer-events); - display: block; - padding: var(--sbb-selection-panel-input-padding); - transition: { - duration: var(--sbb-selection-panel-animation-duration); - timing-function: var(--sbb-animation-easing); - property: padding; - } -} - -@keyframes open { - from { - grid-template-rows: #{$open-anim-rows-from}; - } - - to { - grid-template-rows: #{$open-anim-rows-to}; - } -} - -@keyframes open-opacity { - from { - opacity: #{$open-anim-opacity-from}; - } - - to { - opacity: #{$open-anim-opacity-to}; - } -} - -@keyframes close { - from { - grid-template-rows: #{$open-anim-rows-to}; - opacity: #{$open-anim-opacity-to}; - } - - to { - grid-template-rows: #{$open-anim-rows-from}; - opacity: #{$open-anim-opacity-from}; - } -} diff --git a/src/elements/selection-panel/selection-panel.snapshot.spec.ts b/src/elements/selection-panel/selection-panel.snapshot.spec.ts deleted file mode 100644 index de752d3aa4..0000000000 --- a/src/elements/selection-panel/selection-panel.snapshot.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { html } from 'lit/static-html.js'; - -import { fixture, testA11yTreeSnapshot } from '../core/testing/private.js'; - -import type { SbbSelectionPanelElement } from './selection-panel.js'; -import './selection-panel.js'; -import '../card/card-badge.js'; -import '../checkbox.js'; - -describe(`sbb-selection-panel`, () => { - let element: SbbSelectionPanelElement; - - beforeEach(async () => { - // Note: for easier testing, we add the slot="badge" - // to which would not be needed in real. - element = await fixture(html` - - - % - from CHF - 19.99 - - - Value one - Subtext - Suffix - -
Inner content
-
- `); - }); - - it('renders - DOM', async () => { - await expect(element).dom.to.be.equalSnapshot(); - }); - - it('renders - Shadow DOM', async () => { - await expect(element).shadowDom.to.be.equalSnapshot(); - }); - - testA11yTreeSnapshot(); -});