diff --git a/src/elements/card/card-badge/readme.md b/src/elements/card/card-badge/readme.md index fc950206dc..2d9b41d33b 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/components-sbb-card-sbb-card--docs) or -[sbb-selection-expansion-panel](/docs/components-sbb-selection-expansion-panel--docs). +and can be used in [sbb-card](/docs/elements-sbb-card-sbb-card--docs) or +[sbb-selection-panel](/docs/elements-sbb-selection-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 5d6a31dca6..9aabfd750c 100644 --- a/src/elements/checkbox.ts +++ b/src/elements/checkbox.ts @@ -1,4 +1,2 @@ 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 4363e6089f..42af7b6b2c 100644 --- a/src/elements/checkbox/checkbox-group/checkbox-group.scss +++ b/src/elements/checkbox/checkbox-group/checkbox-group.scss @@ -23,21 +23,13 @@ $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-panel]) { +:host([data-has-selection-panel]) { --sbb-checkbox-group-width: 100%; - - ::slotted(sbb-checkbox-panel) { - flex: auto; - } } -:host([data-has-panel][orientation='vertical']) { +:host([data-has-selection-panel][orientation='vertical']) { --sbb-checkbox-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); } @@ -46,14 +38,11 @@ $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-panel])) { + :host( + [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-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 0c431500ba..ffe13c0e26 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 { ArgTypes, Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; import type { TemplateResult } from 'lit'; import { html, nothing } from 'lit'; -import { styleMap, type StyleInfo } from 'lit/directives/style-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { sbbSpread } from '../../../storybook/helpers/spread.js'; import type { SbbCheckboxElement } from '../checkbox.js'; @@ -12,10 +12,7 @@ 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 @@ -23,29 +20,11 @@ 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: 'start' | 'end', + iconPlacement: string, 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, @@ -96,12 +61,6 @@ const DefaultTemplate = ({ `; -const PanelTemplate = ({ checked, disabledSingle, label, ...args }: Args): TemplateResult => html` - - ${checkboxPanels(checked, disabledSingle, label)} - -`; - const ErrorMessageTemplate = ({ checked, disabledSingle, @@ -295,10 +254,6 @@ const basicArgTypes: ArgTypes = { label, checked, disabledSingle, -}; - -const checkboxArgTypes: ArgTypes = { - ...basicArgTypes, iconName, iconPlacement, }; @@ -312,16 +267,12 @@ const basicArgs: Args = { label: 'Label', checked: true, disabledSingle: false, -}; - -const checkboxArgs: Args = { - ...basicArgs, iconName: undefined, iconPlacement: undefined, }; -const checkboxArgsVertical = { - ...checkboxArgs, +const basicArgsVertical = { + ...basicArgs, orientation: orientation.options![1], }; @@ -337,104 +288,86 @@ const iconEnd: Args = { export const horizontal: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs }, + argTypes: basicArgTypes, + args: { ...basicArgs }, }; export const vertical: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical }, }; export const verticalToHorizontal: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, 'horizontal-from': 'medium' }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, 'horizontal-from': 'medium' }, }; export const horizontalSizeM: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs, size: 'm' }, + argTypes: basicArgTypes, + args: { ...basicArgs, size: 'm' }, }; export const horizontalDisabled: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs, disabled: true, disabledSingle: true }, + argTypes: basicArgTypes, + args: { ...basicArgs, disabled: true, disabledSingle: true }, }; export const verticalDisabled: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, disabled: true, disabledSingle: true }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, disabled: true, disabledSingle: true }, }; export const horizontalIconStart: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs, ...iconStart }, + argTypes: basicArgTypes, + args: { ...basicArgs, ...iconStart }, }; export const verticalIconStart: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, ...iconStart }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, ...iconStart }, }; export const horizontalIconEnd: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs, ...iconEnd }, + argTypes: basicArgTypes, + args: { ...basicArgs, ...iconEnd }, }; export const verticalIconEnd: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, ...iconEnd }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, ...iconEnd }, }; export const verticalIconEndLongLabel: StoryObj = { render: DefaultTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, ...iconEnd, label: longLabelText }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, ...iconEnd, label: longLabelText }, }; export const horizontalWithSbbFormError: StoryObj = { render: ErrorMessageTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgs, required: true }, + argTypes: basicArgTypes, + args: { ...basicArgs, required: true }, }; export const verticalWithSbbFormError: StoryObj = { render: ErrorMessageTemplate, - argTypes: checkboxArgTypes, - args: { ...checkboxArgsVertical, required: true }, + argTypes: basicArgTypes, + args: { ...basicArgsVertical, required: true }, }; export const indeterminateGroup: StoryObj = { render: IndeterminateGroupTemplate, - 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' }, + argTypes: { ...basicArgTypes }, + args: { ...basicArgsVertical, checked: undefined }, }; const meta: Meta = { diff --git a/src/elements/checkbox/checkbox-group/checkbox-group.ts b/src/elements/checkbox/checkbox-group/checkbox-group.ts index 72f5baf99b..65b102ea4a 100644 --- a/src/elements/checkbox/checkbox-group/checkbox-group.ts +++ b/src/elements/checkbox/checkbox-group/checkbox-group.ts @@ -1,14 +1,12 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { LitElement, html } from 'lit'; +import { html, LitElement } 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 { SbbCheckboxPanelElement } from '../checkbox-panel.js'; -import type { SbbCheckboxElement } from '../checkbox.js'; -import type { SbbCheckboxSize } from '../common.js'; +import type { SbbCheckboxElement, SbbCheckboxSize } from '../checkbox.js'; import style from './checkbox-group.scss?lit&inline'; @@ -37,11 +35,9 @@ export class SbbCheckboxGroupElement extends SbbDisabledMixin(LitElement) { public orientation: SbbOrientation = 'horizontal'; /** List of contained checkbox elements. */ - 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, - ) + public get checkboxes(): SbbCheckboxElement[] { + return Array.from(this.querySelectorAll?.('sbb-checkbox') ?? []).filter( + (el: SbbCheckboxElement) => el.closest('sbb-checkbox-group') === this, ); } @@ -56,10 +52,7 @@ 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-panel', - !!this.querySelector?.('sbb-selection-expansion-panel, sbb-checkbox-panel'), - ); + this.toggleAttribute('data-has-selection-panel', !!this.querySelector?.('sbb-selection-panel')); } protected override willUpdate(changedProperties: PropertyValues): void { @@ -77,25 +70,24 @@ export class SbbCheckboxGroupElement extends SbbDisabledMixin(LitElement) { } private _handleKeyDown(evt: KeyboardEvent): void { - const enabledCheckboxes: (SbbCheckboxElement | SbbCheckboxPanelElement)[] = - this.checkboxes.filter( - (checkbox: SbbCheckboxElement | SbbCheckboxPanelElement) => - !checkbox.disabled && interactivityChecker.isVisible(checkbox as HTMLElement), - ); + const enabledCheckboxes: SbbCheckboxElement[] = this.checkboxes.filter( + (checkbox: SbbCheckboxElement) => + !checkbox.disabled && interactivityChecker.isVisible(checkbox), + ); if ( !enabledCheckboxes || // don't trap nested handling ((evt.target as HTMLElement) !== this && (evt.target as HTMLElement).parentElement !== this && - (evt.target as HTMLElement).parentElement!.localName !== 'sbb-selection-expansion-panel') + (evt.target as HTMLElement).parentElement!.nodeName !== 'SBB-SELECTION-PANEL') ) { return; } if (isArrowKeyPressed(evt)) { const current: number = enabledCheckboxes.findIndex( - (e: SbbCheckboxElement | SbbCheckboxPanelElement) => e === evt.target, + (e: SbbCheckboxElement) => 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 558bec1bd3..0887a2167f 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 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). +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). ```html @@ -10,7 +10,7 @@ or [sbb-selection-expansion-panel](/docs/elements-sbb-selection-expansion-panel- - + Value @@ -19,7 +19,7 @@ or [sbb-selection-expansion-panel](/docs/elements-sbb-selection-expansion-panel- 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 \| 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. | +| 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. | ## Slots diff --git a/src/elements/checkbox/checkbox-panel.ts b/src/elements/checkbox/checkbox-panel.ts deleted file mode 100644 index e8c5fd8a3f..0000000000 --- a/src/elements/checkbox/checkbox-panel.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4935c50264..0000000000 --- a/src/elements/checkbox/checkbox-panel/__snapshots__/checkbox-panel.snapshot.spec.snap.js +++ /dev/null @@ -1,254 +0,0 @@ -/* @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 deleted file mode 100644 index 6d729e4e55..0000000000 --- a/src/elements/checkbox/checkbox-panel/checkbox-panel.snapshot.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index c9ab9b88a5..0000000000 --- a/src/elements/checkbox/checkbox-panel/checkbox-panel.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 2c4a020d0f..0000000000 --- a/src/elements/checkbox/checkbox-panel/checkbox-panel.ssr.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 79d84968e9..0000000000 --- a/src/elements/checkbox/checkbox-panel/checkbox-panel.stories.ts +++ /dev/null @@ -1,218 +0,0 @@ -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 deleted file mode 100644 index 6e899d62d7..0000000000 --- a/src/elements/checkbox/checkbox-panel/checkbox-panel.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 80cb20c72a..0000000000 --- a/src/elements/checkbox/checkbox-panel/readme.md +++ /dev/null @@ -1,99 +0,0 @@ -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 241cea22e2..bfa71fa0d6 100644 --- a/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js +++ b/src/elements/checkbox/checkbox/__snapshots__/checkbox.snapshot.spec.snap.js @@ -24,12 +24,18 @@ snapshots["sbb-checkbox should render unchecked Shadow DOM"] = - + + + + + + + `; @@ -59,12 +65,18 @@ snapshots["sbb-checkbox should render checked Shadow DOM"] = - + + + + + + + `; @@ -94,12 +106,18 @@ snapshots["sbb-checkbox should render indeterminate Shadow DOM"] = - + + + + + + + `; @@ -129,12 +147,18 @@ snapshots["sbb-checkbox should render unchecked disabled Shadow DOM"] = - + + + + + + + `; @@ -157,38 +181,38 @@ snapshots["sbb-checkbox Unchecked - A11y tree Chrome"] = `; /* end snapshot sbb-checkbox Unchecked - A11y tree Chrome */ -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 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 Checked - A11y tree Firefox"] = `

diff --git a/src/elements/checkbox/checkbox/checkbox.scss b/src/elements/checkbox/checkbox/checkbox.scss index 00b6471042..46e6702eb6 100644 --- a/src/elements/checkbox/checkbox/checkbox.scss +++ b/src/elements/checkbox/checkbox/checkbox.scss @@ -1,36 +1,154 @@ @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; } -.sbb-checkbox__label--icon { - color: var(--sbb-checkbox-label-icon-color); +:host(:disabled) { + --sbb-checkbox-cursor: default; + --sbb-checkbox-label-color: var(--sbb-color-granite); +} - :host([icon-placement='end']) & { - margin-inline-start: auto; +:host([data-is-selection-panel-input]) { + --sbb-checkbox-label-gap: 0; +} + +:is(slot[name='subtext'], slot[name='suffix']) { + :host(:not([data-is-inside-selection-panel])) & { + display: none; } +} - :host( - /** No icon */ - :not([icon-name], [data-slot-names~="icon"])) & { +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'])) & { 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'])) & { + :host( + :focus-visible:not( + [data-focus-origin='mouse'], + [data-focus-origin='touch'], + [data-is-selection-panel-input] + ) + ) + & { @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; @@ -41,3 +159,9 @@ 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 ae0f68e9f0..dc3c3a496e 100644 --- a/src/elements/checkbox/checkbox/checkbox.spec.ts +++ b/src/elements/checkbox/checkbox/checkbox.spec.ts @@ -1,11 +1,23 @@ -import { assert } from '@open-wc/testing'; +import { assert, expect } from '@open-wc/testing'; +import { a11ySnapshot, sendKeys } from '@web/test-runner-commands'; 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'; -describe(`sbb-checkbox with`, () => { +interface CheckboxAccessibilitySnapshot { + checked: boolean; + role: string; + disabled: boolean; + required: boolean; +} + +describe(`sbb-checkbox`, () => { describe('general', () => { let element: SbbCheckboxElement; @@ -16,7 +28,968 @@ describe(`sbb-checkbox with`, () => { 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; + }); }); - // All the functionalities of sbb-checkbox are tested in checkbox-common.e2e.ts file + 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, + }); + }); + }); + }); + }); + }); }); diff --git a/src/elements/checkbox/checkbox/checkbox.ts b/src/elements/checkbox/checkbox/checkbox.ts index 9e0b72cc8c..a84ad9dcf7 100644 --- a/src/elements/checkbox/checkbox/checkbox.ts +++ b/src/elements/checkbox/checkbox/checkbox.ts @@ -1,43 +1,202 @@ -import { LitElement, html, type CSSResultGroup, type TemplateResult } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { + type CSSResultGroup, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; -import { SbbSlotStateController } from '../../core/controllers.js'; -import type { SbbIconPlacement } from '../../core/interfaces.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 { SbbIconNameMixin } from '../../icon.js'; -import { SbbCheckboxCommonElementMixin, checkboxCommonStyle } from '../common.js'; +import type { SbbSelectionPanelElement } from '../../selection-panel.js'; +import type { SbbCheckboxGroupElement } from '../checkbox-group.js'; +import style from './checkbox.scss?lit&inline'; + +import '../../screen-reader-only.js'; import '../../visual-checkbox.js'; -import checkboxStyle from './checkbox.scss?lit&inline'; +export type SbbCheckboxStateChange = Extract< + SbbStateChange, + SbbDisabledStateChange | SbbCheckedStateChange +>; + +export type SbbCheckboxSize = 's' | 'm'; /** * 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 SbbCheckboxCommonElementMixin( - SbbIconNameMixin(LitElement), +export class SbbCheckboxElement extends SbbUpdateSchedulerMixin( + SbbFormAssociatedCheckboxMixin(SbbIconNameMixin(SbbHydrationMixin(LitElement))), ) { - public static override styles: CSSResultGroup = [checkboxCommonStyle, checkboxStyle]; - + public static override styles: CSSResultGroup = style; 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` @@ -52,11 +211,14 @@ export class SbbCheckboxElement extends SbbCheckboxCommonElementMixin( - ${this.renderIconSlot()} + ${this.renderIconSlot()} + + + + ${this._selectionPanelExpandedLabel} + `; diff --git a/src/elements/checkbox/checkbox/readme.md b/src/elements/checkbox/checkbox/readme.md index a47cae7b99..4260d1478f 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` and `m` (default). +The component has two `size`, named `s` (default) and `m`. ```html Size @@ -103,7 +103,9 @@ 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). | +| 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). | diff --git a/src/elements/checkbox/common.ts b/src/elements/checkbox/common.ts deleted file mode 100644 index c9680209f2..0000000000 --- a/src/elements/checkbox/common.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 67bd2d254d..0000000000 --- a/src/elements/checkbox/common/checkbox-common.scss +++ /dev/null @@ -1,60 +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; - -: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 deleted file mode 100644 index 6a164e011a..0000000000 --- a/src/elements/checkbox/common/checkbox-common.spec.ts +++ /dev/null @@ -1,997 +0,0 @@ -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 deleted file mode 100644 index 36cd4eea30..0000000000 --- a/src/elements/checkbox/common/checkbox-common.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 dfb85b5223..2038021e61 100644 --- a/src/elements/core/mixins.ts +++ b/src/elements/core/mixins.ts @@ -5,8 +5,5 @@ 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 deleted file mode 100644 index c66ec18b9d..0000000000 --- a/src/elements/core/mixins/panel-common.scss +++ /dev/null @@ -1,115 +0,0 @@ -@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 deleted file mode 100644 index 688a5ad848..0000000000 --- a/src/elements/core/mixins/panel-mixin.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 5702306980..43d908e46d 100644 --- a/src/elements/radio-button.ts +++ b/src/elements/radio-button.ts @@ -1,4 +1,2 @@ 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 deleted file mode 100644 index 45312e1eac..0000000000 --- a/src/elements/radio-button/common.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 90a7ff5345..0000000000 --- a/src/elements/radio-button/common/radio-button-common.scss +++ /dev/null @@ -1,120 +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; - -: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 deleted file mode 100644 index c2f0b008b8..0000000000 --- a/src/elements/radio-button/common/radio-button-common.ts +++ /dev/null @@ -1,203 +0,0 @@ -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 db299bfefc..3be3d45027 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 renders A11y tree Chrome"] = +snapshots["sbb-radio-button-group A11y tree Chrome"] = `

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

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

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

`; -/* end snapshot sbb-radio-button-group renders A11y tree Firefox */ +/* end snapshot sbb-radio-button-group 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 2eb4e8dd86..243e7cd27d 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,26 +16,18 @@ $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-panel]) { +:host([data-has-selection-panel]) { --sbb-radio-button-group-width: 100%; } -:host([data-has-panel][orientation='vertical']) { +:host([data-has-selection-panel][orientation='vertical']) { --sbb-radio-button-group-gap: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); } @@ -44,14 +36,11 @@ $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-panel])) { + :host( + [orientation='vertical'][horizontal-from='#{$breakpoint}']:not([data-has-selection-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 5f7031c296..27b59fefe9 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,19 +10,17 @@ import './radio-button-group.js'; describe(`sbb-radio-button-group`, () => { let element: SbbRadioButtonGroupElement; - describe('renders', () => { - beforeEach(async () => { - element = await fixture(html``); - }); - - it('DOM', async () => { - await expect(element).dom.to.be.equalSnapshot(); - }); + beforeEach(async () => { + element = await fixture(html``); + }); - it('Shadow DOM', async () => { - await expect(element).shadowDom.to.be.equalSnapshot(); - }); + it('renders - DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); - testA11yTreeSnapshot(); + it('renders - Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); }); + + 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 df8c0c8217..edeba50024 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,249 +1,191 @@ import { assert, expect } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; -import { html, unsafeStatic } from 'lit/static-html.js'; +import { html } 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'; -['sbb-radio-button', 'sbb-radio-button-panel'].forEach((selector) => { - const tagSingle = unsafeStatic(selector); - describe(`sbb-radio-button-group with ${selector}`, () => { - let element: SbbRadioButtonGroupElement; +describe(`sbb-radio-button-group`, () => { + let element: SbbRadioButtonGroupElement; - describe('events', () => { - beforeEach(async () => { - /* eslint-disable lit/binding-positions */ - element = await fixture( - html` + describe('events', () => { + beforeEach(async () => { + element = await fixture(html` - <${tagSingle} id="sbb-radio-1" value="Value one">Value one - <${tagSingle} id="sbb-radio-2" value="Value two">Value two - <${tagSingle} id="sbb-radio-3" value="Value three" disabled - >Value threeValue one + Value two + Value three - <${tagSingle} id="sbb-radio-4" value="Value four">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 - | 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('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'); + }); - secondRadio.click(); - await waitForLitRender(element); - expect(secondRadio).to.have.attribute('checked'); + it('selects radio on left arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - secondRadio.focus(); - await waitForLitRender(element); - - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(element); + firstRadio.focus(); + await waitForLitRender(element); - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(element); + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(element); - const radio = element.querySelector('#sbb-radio-1'); - expect(radio).to.have.attribute('checked'); + const radio = element.querySelector('#sbb-radio-4'); + 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'); }); - describe('initialization', () => { - beforeEach(async () => { - element = await fixture(html` - -

Other content

-
- `); - }); + it('selects radio on right arrow key pressed', async () => { + const firstRadio = element.querySelector('#sbb-radio-1') as SbbRadioButtonElement; - it('should preserve value when no radios were slotted but slotchange was triggered', () => { - expect(element.value).to.equal('Value one'); - }); + 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 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 88ef87fc09..407afdbdf2 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,9 +1,8 @@ import { withActions } from '@storybook/addon-actions/decorator'; import type { InputType } from '@storybook/types'; -import type { ArgTypes, Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } 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'; @@ -11,29 +10,7 @@ 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: { @@ -115,22 +92,10 @@ 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'); @@ -221,28 +186,6 @@ 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 79c32e6be2..48bf53640e 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 { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { getNextElementIndex, isArrowKeyPressed } from '../../core/a11y.js'; @@ -8,15 +8,18 @@ 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 { SbbRadioButtonSize, SbbRadioButtonStateChange } from '../common.js'; -import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js'; -import type { SbbRadioButtonElement } from '../radio-button.js'; +import type { SbbSelectionPanelElement } from '../../selection-panel.js'; +import type { + SbbRadioButtonElement, + SbbRadioButtonSize, + SbbRadioButtonStateChange, +} from '../radio-button.js'; import style from './radio-button-group.scss?lit&inline'; export type SbbRadioButtonGroupEventDetail = { value: any | null; - radioButton: SbbRadioButtonElement | SbbRadioButtonPanelElement; + radioButton: SbbRadioButtonElement; }; /** @@ -76,22 +79,19 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { /** * List of contained radio buttons. */ - public get radioButtons(): (SbbRadioButtonElement | SbbRadioButtonPanelElement)[] { + public get radioButtons(): SbbRadioButtonElement[] { return ( - Array.from(this.querySelectorAll?.('sbb-radio-button, sbb-radio-button-panel') ?? []) as ( - | SbbRadioButtonElement - | SbbRadioButtonPanelElement - )[] + Array.from(this.querySelectorAll?.('sbb-radio-button') ?? []) as SbbRadioButtonElement[] ).filter((el) => el.closest?.('sbb-radio-button-group') === this); } - private get _enabledRadios(): (SbbRadioButtonElement | SbbRadioButtonPanelElement)[] | undefined { + private get _enabledRadios(): SbbRadioButtonElement[] | undefined { if (!this.disabled) { return this.radioButtons.filter((r) => !r.disabled); } } - private _hasSelectionExpansionPanelElement: boolean = false; + private _hasSelectionPanel: boolean = false; private _didLoad = false; private _abort = new SbbConnectedAbortController(this); @@ -146,13 +146,8 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { }, ); this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); - this._hasSelectionExpansionPanelElement = !!this.querySelector?.( - 'sbb-selection-expansion-panel', - ); - this.toggleAttribute( - 'data-has-panel', - !!this.querySelector?.('sbb-selection-expansion-panel, sbb-radio-button-panel'), - ); + this._hasSelectionPanel = !!this.querySelector?.('sbb-selection-panel'); + this.toggleAttribute('data-has-selection-panel', this._hasSelectionPanel); this._updateRadios(this.value); } @@ -239,10 +234,13 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { } } - private _getRadioTabIndex(radio: SbbRadioButtonElement | SbbRadioButtonPanelElement): number { + private _getRadioTabIndex(radio: SbbRadioButtonElement): 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._hasSelectionExpansionPanelElement ? 0 : -1; + return isSelected || (this._hasSelectionPanel && isParentPanelWithContent) ? 0 : -1; } private _handleKeyDown(evt: KeyboardEvent): void { @@ -254,7 +252,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?.localName !== 'sbb-selection-expansion-panel') + (evt.target as HTMLElement).parentElement?.nodeName !== 'SBB-SELECTION-PANEL') ) { return; } @@ -263,12 +261,14 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { return; } - const current: number = enabledRadios.findIndex( - (e: SbbRadioButtonElement | SbbRadioButtonPanelElement) => e === evt.target, - ); + const current: number = enabledRadios.findIndex((e: SbbRadioButtonElement) => e === evt.target); const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length); - if (!this._hasSelectionExpansionPanelElement) { + // 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)) { 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 d9036b55f3..8a372b94db 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 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. +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. ```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 \| 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. | +| 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. | ## Events diff --git a/src/elements/radio-button/radio-button-panel.ts b/src/elements/radio-button/radio-button-panel.ts deleted file mode 100644 index e5a90eef4f..0000000000 --- a/src/elements/radio-button/radio-button-panel.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index db5ca2f4fd..0000000000 --- a/src/elements/radio-button/radio-button-panel/__snapshots__/radio-button-panel.snapshot.spec.snap.js +++ /dev/null @@ -1,167 +0,0 @@ -/* @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 deleted file mode 100644 index 082c6a7306..0000000000 --- a/src/elements/radio-button/radio-button-panel/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index afd8ec850d..0000000000 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.snapshot.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index f502726e1f..0000000000 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 3e7557fe40..0000000000 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.ssr.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 228adc481f..0000000000 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index ab7d784ac6..0000000000 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 78151f4360..0000000000 --- a/src/elements/radio-button/radio-button-panel/readme.md +++ /dev/null @@ -1,76 +0,0 @@ -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 810c6368c2..3cab598aa9 100644 --- a/src/elements/radio-button/radio-button/radio-button.scss +++ b/src/elements/radio-button/radio-button/radio-button.scss @@ -4,20 +4,178 @@ @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'])) & { + :host( + :focus-visible:not( + [data-focus-origin='mouse'], + [data-focus-origin='touch'], + [data-is-selection-panel-input] + ) + ) + & { @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 82e5a2194e..77b91b739a 100644 --- a/src/elements/radio-button/radio-button/radio-button.ts +++ b/src/elements/radio-button/radio-button/radio-button.ts @@ -1,29 +1,260 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; -import { LitElement, html, nothing } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; -import { SbbSlotStateController } from '../../core/controllers.js'; -import { SbbRadioButtonCommonElementMixin, radioButtonCommonStyle } from '../common.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 radioButtonStyle from './radio-button.scss?lit&inline'; +import style from './radio-button.scss?lit&inline'; + +export type SbbRadioButtonStateChange = Extract< + SbbStateChange, + SbbDisabledStateChange | SbbCheckedStateChange +>; + +export type SbbRadioButtonSize = 's' | 'm'; /** * 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') -export class SbbRadioButtonElement extends SbbRadioButtonCommonElementMixin(LitElement) { - public static override styles: CSSResultGroup = [radioButtonCommonStyle, radioButtonStyle]; +@hostAttributes({ + role: 'radio', +}) +export class SbbRadioButtonElement extends SbbUpdateSchedulerMixin(LitElement) { + public static override styles: CSSResultGroup = style; 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 7f9c5e54d7..c316bda112 100644 --- a/src/elements/radio-button/radio-button/readme.md +++ b/src/elements/radio-button/radio-button/readme.md @@ -62,12 +62,14 @@ 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` | SbbRadioButtonCommonElementMixin | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| -------- | ------- | ----------- | ---------- | ------ | -------------- | +| `select` | public | | | `void` | | ## Slots -| Name | Description | -| ---- | ------------------------------------------------------- | -| | Use the unnamed slot to add content to the radio label. | +| 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`). | diff --git a/src/elements/selection-expansion-panel.ts b/src/elements/selection-expansion-panel.ts deleted file mode 100644 index a851b0feb7..0000000000 --- a/src/elements/selection-expansion-panel.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index dedcbd0ac0..0000000000 --- a/src/elements/selection-expansion-panel/__snapshots__/selection-expansion-panel.snapshot.spec.snap.js +++ /dev/null @@ -1,95 +0,0 @@ -/* @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-expansion-panel/selection-expansion-panel.scss b/src/elements/selection-expansion-panel/selection-expansion-panel.scss deleted file mode 100644 index 362809acc3..0000000000 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.scss +++ /dev/null @@ -1,163 +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-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 deleted file mode 100644 index e677645a99..0000000000 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.snapshot.spec.ts +++ /dev/null @@ -1,40 +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 { 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-expansion-panel/selection-expansion-panel.ssr.spec.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts deleted file mode 100644 index fb427f35e1..0000000000 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.ssr.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -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.ts b/src/elements/selection-panel.ts new file mode 100644 index 0000000000..61c0949e27 --- /dev/null +++ b/src/elements/selection-panel.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..eeaa254719 --- /dev/null +++ b/src/elements/selection-panel/__snapshots__/selection-panel.snapshot.spec.snap.js @@ -0,0 +1,135 @@ +/* @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-expansion-panel/readme.md b/src/elements/selection-panel/readme.md similarity index 65% rename from src/elements/selection-expansion-panel/readme.md rename to src/elements/selection-panel/readme.md index 0480d6c071..9c6d85e868 100644 --- a/src/elements/selection-expansion-panel/readme.md +++ b/src/elements/selection-panel/readme.md @@ -1,7 +1,7 @@ -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 `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 content section can be opened by checking `sbb-checkbox-panel` or selecting the `sbb-radio-button-panel`. +The content section can be opened by checking `sbb-checkbox` or selecting the `sbb-radio-button`. 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,9 +12,13 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). ```html - - - % + + + % + from CHF + 19.99 + + Value Subtext @@ -22,9 +26,9 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). CHF 40.00 - +
Inner Content
-
+
``` @@ -32,9 +36,13 @@ or a [sbb-checkbox-group](/docs/elements-sbb-checkbox-sbb-checkbox-group--docs). ```html - - - % + + + % + from CHF + 19.99 + + Value Subtext @@ -42,24 +50,27 @@ 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-expansion-panel` without border by setting the `borderless` variable to `true`. +It's also possible to display the `sbb-selection-panel` without border by setting the `borderless` variable to `true`. ```html - ... + ... ``` @@ -83,7 +94,8 @@ It's also possible to display the `sbb-selection-expansion-panel` without border ## Slots -| 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). | +| 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). | diff --git a/src/elements/selection-panel/selection-panel.scss b/src/elements/selection-panel/selection-panel.scss new file mode 100644 index 0000000000..a977c9694e --- /dev/null +++ b/src/elements/selection-panel/selection-panel.scss @@ -0,0 +1,199 @@ +@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 new file mode 100644 index 0000000000..de752d3aa4 --- /dev/null +++ b/src/elements/selection-panel/selection-panel.snapshot.spec.ts @@ -0,0 +1,43 @@ +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(); +}); diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts b/src/elements/selection-panel/selection-panel.spec.ts similarity index 62% rename from src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts rename to src/elements/selection-panel/selection-panel.spec.ts index 90a39fb47a..3d8025e350 100644 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts +++ b/src/elements/selection-panel/selection-panel.spec.ts @@ -1,36 +1,29 @@ import { assert, expect } from '@open-wc/testing'; -import { a11ySnapshot, sendKeys } from '@web/test-runner-commands'; +import { sendKeys } from '@web/test-runner-commands'; import type { TemplateResult } from 'lit'; import { html, unsafeStatic } from 'lit/static-html.js'; -import { - SbbCheckboxPanelElement, - type SbbCheckboxElement, - type SbbCheckboxGroupElement, -} from '../checkbox.js'; +import type { SbbCheckboxGroupElement } from '../checkbox.js'; +import { SbbCheckboxElement } from '../checkbox.js'; +import { tabKey } from '../core/testing/private/keys.js'; import { fixture } from '../core/testing/private.js'; import { EventSpy, waitForCondition, waitForLitRender } from '../core/testing.js'; -import { - SbbRadioButtonPanelElement, - type SbbRadioButtonElement, - type SbbRadioButtonGroupElement, -} from '../radio-button.js'; - -import { SbbSelectionExpansionPanelElement } from './selection-expansion-panel.js'; +import type { SbbRadioButtonGroupElement } from '../radio-button.js'; +import { SbbRadioButtonElement } from '../radio-button.js'; +import { SbbSelectionPanelElement } from './selection-panel.js'; import '../link/block-link-button.js'; -import '../selection-expansion-panel.js'; -describe(`sbb-selection-expansion-panel`, () => { - let elements: SbbSelectionExpansionPanelElement[]; +describe(`sbb-selection-panel`, () => { + let elements: SbbSelectionPanelElement[]; const getPageContent = (inputType: string): TemplateResult => { const tagGroupElement = unsafeStatic(`sbb-${inputType}-group`); - const tagSingle = unsafeStatic(`sbb-${inputType}-panel`); + const tagSingle = unsafeStatic(`sbb-${inputType}`); /* 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 @@ -38,35 +31,35 @@ describe(`sbb-selection-expansion-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: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, + secondInput: SbbRadioButtonElement | SbbCheckboxElement, ): Promise => { elements.forEach((e) => (e.forceOpen = true)); await waitForLitRender(wrapper); @@ -84,8 +77,8 @@ describe(`sbb-selection-expansion-panel`, () => { const preservesDisabled = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, - disabledInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, - secondInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, + disabledInput: SbbRadioButtonElement | SbbCheckboxElement, + secondInput: SbbRadioButtonElement | SbbCheckboxElement, ): Promise => { wrapper.disabled = true; await waitForLitRender(wrapper); @@ -112,8 +105,8 @@ describe(`sbb-selection-expansion-panel`, () => { const wrapsAround = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, - firstInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, - secondInput: SbbRadioButtonPanelElement | SbbCheckboxPanelElement, + firstInput: SbbRadioButtonElement | SbbCheckboxElement, + secondInput: SbbRadioButtonElement | SbbCheckboxElement, ): Promise => { secondInput.click(); secondInput.focus(); @@ -129,42 +122,38 @@ describe(`sbb-selection-expansion-panel`, () => { describe('with radio buttons', () => { let wrapper: SbbRadioButtonGroupElement; - let firstPanel: SbbSelectionExpansionPanelElement; - let firstInput: SbbRadioButtonPanelElement; - let secondPanel: SbbSelectionExpansionPanelElement; - let secondInput: SbbRadioButtonPanelElement; - let disabledInput: SbbRadioButtonPanelElement; + let firstPanel: SbbSelectionPanelElement; + let firstInput: SbbRadioButtonElement; + let secondPanel: SbbSelectionPanelElement; + let secondInput: SbbRadioButtonElement; + let disabledInput: SbbRadioButtonElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); wrapper = await fixture(getPageContent('radio-button')); - 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')!; + 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')!; }); it('renders', () => { - elements.forEach((e) => assert.instanceOf(e, SbbSelectionExpansionPanelElement)); - assert.instanceOf(firstPanel, SbbSelectionExpansionPanelElement); - assert.instanceOf(firstInput, SbbRadioButtonPanelElement); - assert.instanceOf(secondPanel, SbbSelectionExpansionPanelElement); - assert.instanceOf(secondInput, SbbRadioButtonPanelElement); + elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); + assert.instanceOf(firstPanel, SbbSelectionPanelElement); + assert.instanceOf(firstInput, SbbRadioButtonElement); + assert.instanceOf(secondPanel, SbbSelectionPanelElement); + assert.instanceOf(secondInput, SbbRadioButtonElement); }); it('selects input on click and shows related content', async () => { - willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); await waitForLitRender(wrapper); @@ -228,7 +217,7 @@ describe(`sbb-selection-expansion-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(); @@ -262,69 +251,130 @@ describe(`sbb-selection-expansion-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: SbbSelectionExpansionPanelElement; - let panel2: SbbSelectionExpansionPanelElement; + let panel1: SbbSelectionPanelElement; + let panel2: SbbSelectionPanelElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; let willCloseEventSpy: EventSpy; let didCloseEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); - willCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willClose); - didCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didClose); + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.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 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')); + 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)'); await waitForCondition(() => didOpenEventSpy.count === 1); expect(willOpenEventSpy.count).to.be.equal(1); expect(didOpenEventSpy.count).to.be.equal(1); - expect(mainRadioButton1Label.name.trim()).to.be.equal('Main Option 1 , expanded'); - expect(mainRadioButton2Label.name.trim()).to.be.equal('Main Option 2 , collapsed'); + expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', expanded'); + expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', collapsed'); + expect(subRadioButton1).to.be.null; expect(panel1).to.have.attribute('data-state', 'opened'); expect(panel2).to.have.attribute('data-state', 'closed'); @@ -334,23 +384,13 @@ describe(`sbb-selection-expansion-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(mainRadioButton1LabelSecondRender.name.trim()).to.be.equal( - 'Main Option 1 , collapsed', - ); - expect(mainRadioButton2LabelSecondRender.name.trim()).to.be.equal('Main Option 2 , expanded'); - + expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', collapsed'); + expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', expanded'); + expect(subRadioButton1).to.be.null; expect(panel1).to.have.attribute('data-state', 'closed'); expect(panel2).to.have.attribute('data-state', 'opened'); }); @@ -359,9 +399,7 @@ describe(`sbb-selection-expansion-panel`, () => { nestedElement.toggleAttribute('disabled', true); await waitForLitRender(nestedElement); - const radioButtons: SbbRadioButtonPanelElement[] = Array.from( - nestedElement.querySelectorAll('sbb-radio-button-panel, sbb-radio-button'), - ); + const radioButtons = Array.from(nestedElement.querySelectorAll('sbb-radio-button')); expect(radioButtons.length).to.be.equal(6); expect(radioButtons[0]).to.have.attribute('disabled'); @@ -373,14 +411,12 @@ describe(`sbb-selection-expansion-panel`, () => { }); it('should not with interfere content on selection', async () => { - const main1: SbbRadioButtonPanelElement = - nestedElement.querySelector( - 'sbb-radio-button-panel[value="main1"]', - )!; - const main2: SbbRadioButtonPanelElement = - nestedElement.querySelector( - 'sbb-radio-button-panel[value="main2"]', - )!; + const main1 = nestedElement.querySelector( + 'sbb-radio-button[value="main1"]', + )!; + const main2 = nestedElement.querySelector( + 'sbb-radio-button[value="main2"]', + )!; const sub1 = nestedElement.querySelector( 'sbb-radio-button[value="sub1"]', )!; @@ -417,23 +453,21 @@ describe(`sbb-selection-expansion-panel`, () => { const root = await fixture(html`
@@ -442,7 +476,7 @@ describe(`sbb-selection-expansion-panel`, () => { const radioGroup = root.querySelector('sbb-radio-button-group')!; const selectionPanels = Array.from( - root.querySelector('template')!.content.querySelectorAll('sbb-selection-expansion-panel'), + root.querySelector('template')!.content.querySelectorAll('sbb-selection-panel'), ); selectionPanels.forEach((el) => radioGroup.appendChild(el)); @@ -464,42 +498,38 @@ describe(`sbb-selection-expansion-panel`, () => { describe('with checkboxes', () => { let wrapper: SbbCheckboxGroupElement; - let firstPanel: SbbSelectionExpansionPanelElement; - let firstInput: SbbCheckboxPanelElement; - let secondPanel: SbbSelectionExpansionPanelElement; - let secondInput: SbbCheckboxPanelElement; - let disabledInput: SbbCheckboxPanelElement; + let firstPanel: SbbSelectionPanelElement; + let firstInput: SbbCheckboxElement; + let secondPanel: SbbSelectionPanelElement; + let secondInput: SbbCheckboxElement; + let disabledInput: SbbCheckboxElement; let willOpenEventSpy: EventSpy; let didOpenEventSpy: EventSpy; let willCloseEventSpy: EventSpy; let didCloseEventSpy: EventSpy; beforeEach(async () => { - willOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willOpen); - didOpenEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didOpen); - willCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.willClose); - didCloseEventSpy = new EventSpy(SbbSelectionExpansionPanelElement.events.didClose); + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.didClose); wrapper = await fixture(getPageContent('checkbox')); - 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')!; + 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')!; }); it('renders', () => { - elements.forEach((e) => assert.instanceOf(e, SbbSelectionExpansionPanelElement)); + elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); - assert.instanceOf(firstPanel, SbbSelectionExpansionPanelElement); - assert.instanceOf(firstInput, SbbCheckboxPanelElement); - assert.instanceOf(secondPanel, SbbSelectionExpansionPanelElement); - assert.instanceOf(secondInput, SbbCheckboxPanelElement); + assert.instanceOf(firstPanel, SbbSelectionPanelElement); + assert.instanceOf(firstInput, SbbCheckboxElement); + assert.instanceOf(secondPanel, SbbSelectionPanelElement); + assert.instanceOf(secondInput, SbbCheckboxElement); }); it('selects input on click and shows related content', async () => { @@ -574,13 +604,11 @@ describe(`sbb-selection-expansion-panel`, () => { }); it('focuses input on left arrow key pressed and selects it on space key pressed', async () => { - const fourthInput: SbbCheckboxPanelElement = - wrapper.querySelector('#sbb-input-4')!; + const fourthInput = 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; @@ -620,41 +648,45 @@ describe(`sbb-selection-expansion-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: SbbCheckboxPanelElement = - nestedElement.querySelector("sbb-checkbox-panel[value='main1']")!; - const mainCheckbox2: SbbCheckboxPanelElement = - nestedElement.querySelector("sbb-checkbox-panel[value='main2']")!; - - 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 }; + 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'); - expect(mainCheckbox1Label.name.trim()).to.be.equal('​ Main Option 1 , expanded'); - expect(mainCheckbox2Label.name.trim()).to.be.equal('​ Main Option 2 , collapsed'); + expect(mainCheckbox1Label.textContent!.trim()).to.be.equal(', expanded'); + expect(mainCheckbox2Label.textContent!.trim()).to.be.equal(', collapsed'); + expect(subCheckbox1).to.be.empty; // Deactivate main option 1 mainCheckbox1.click(); @@ -664,25 +696,16 @@ describe(`sbb-selection-expansion-panel`, () => { await waitForLitRender(nestedElement); - 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'); + expect(mainCheckbox1Label.textContent!.trim()).to.be.equal(', collapsed'); + expect(mainCheckbox2Label.textContent!.trim()).to.be.equal(', expanded'); + expect(subCheckbox1).to.be.empty; }); it('should mark only outer group children as disabled', async () => { nestedElement.toggleAttribute('disabled', true); await waitForLitRender(nestedElement); - const checkboxes: (SbbCheckboxPanelElement | SbbCheckboxElement)[] = Array.from( - nestedElement.querySelectorAll('sbb-checkbox-panel, sbb-checkbox'), - ); + const checkboxes = Array.from(nestedElement.querySelectorAll('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.stories.ts b/src/elements/selection-panel/selection-panel.stories.ts similarity index 64% rename from src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts rename to src/elements/selection-panel/selection-panel.stories.ts index abcf65a8e8..f869b97391 100644 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts +++ b/src/elements/selection-panel/selection-panel.stories.ts @@ -12,20 +12,19 @@ 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 '../popover.js'; import '../radio-button.js'; +import '../popover.js'; import '../title.js'; -import readme from './readme.md?raw'; - const color: InputType = { control: { type: 'inline-radio', @@ -91,7 +90,9 @@ const suffixAndSubtext = (): TemplateResult => html` - CHF 40.00 + + CHF 40.00 + `; @@ -110,12 +111,13 @@ const WithCheckboxTemplate = ({ disabledInput, ...args }: Args): TemplateResult => html` - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value one ${suffixAndSubtext()} + ${innerContent()} - + `; const WithRadioButtonTemplate = ({ @@ -123,12 +125,13 @@ const WithRadioButtonTemplate = ({ disabledInput, ...args }: Args): TemplateResult => html` - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value one ${suffixAndSubtext()} + ${innerContent()} - + `; const WithCheckboxGroupTemplate = ({ @@ -137,24 +140,23 @@ const WithCheckboxGroupTemplate = ({ ...args }: Args): TemplateResult => html` - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value one ${suffixAndSubtext()} ${innerContent()} - + - - - Value two ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value two ${suffixAndSubtext()} ${innerContent()} - + - - Value three ${suffixAndSubtext()} ${cardBadge()} + + ${cardBadge()} + Value three ${suffixAndSubtext()} ${innerContent()} - + `; @@ -169,26 +171,27 @@ const WithRadioButtonGroupTemplate = ({ horizontal-from="large" ?allow-empty-selection=${allowEmptySelection} > - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value one ${suffixAndSubtext()} + ${innerContent()} - + - - - Value two ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value two ${suffixAndSubtext()} + ${innerContent()} - + - - - Value three ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value three ${suffixAndSubtext()} ${innerContent()} - + `; @@ -198,30 +201,33 @@ const TicketsOptionsExampleTemplate = ({ ...args }: Args): TemplateResult => html` - - - Saving ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Saving ${suffixAndSubtext()}
Non-Flex - - CHF 0.00 + No refund possible + + + CHF 0.00 + Semi-Flex - - + CHF 5.00 + Partial refund possible + + + + CHF 5.00 + -
+
1 x 0 x Supersaver ticket, Half-Fare Card${' '} Simple popover
- + - - - City offer ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + City offer ${suffixAndSubtext()}
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
- + `; @@ -288,25 +295,25 @@ const NestedRadioTemplate = ({ ...args }: Args): TemplateResult => html` - - + + Main Option 1 - + Suboption 1 Suboption 2 - + - - + + Main Option 2 - + Suboption 1 Suboption 2 - + `; @@ -316,25 +323,21 @@ 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 - + `; @@ -363,24 +366,23 @@ const WithCheckboxesErrorMessageTemplate = ({ } }} > - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value one ${suffixAndSubtext()} ${innerContent()} - + - - - Value two ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value two ${suffixAndSubtext()} ${innerContent()} - + - - Value three ${suffixAndSubtext()} ${cardBadge()} + + ${cardBadge()} + Value three ${suffixAndSubtext()} ${innerContent()} - + ${sbbFormError} `; @@ -409,31 +411,79 @@ const WithRadiosErrorMessageTemplate = ({ } }} > - - - Value one ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value one ${suffixAndSubtext()} + ${innerContent()} - + - - - Value two ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + + Value two ${suffixAndSubtext()} + ${innerContent()} - + - - - Value three ${suffixAndSubtext()} ${cardBadge()} - + + ${cardBadge()} + Value three ${suffixAndSubtext()} ${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, @@ -615,6 +665,24 @@ 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, @@ -639,17 +707,17 @@ const meta: Meta = { chromatic: { delay: 9000, fixedHeight: '14500px' }, actions: { handles: [ - SbbSelectionExpansionPanelElement.events.didOpen, - SbbSelectionExpansionPanelElement.events.didClose, - SbbSelectionExpansionPanelElement.events.willOpen, - SbbSelectionExpansionPanelElement.events.willClose, + SbbSelectionPanelElement.events.didOpen, + SbbSelectionPanelElement.events.didClose, + SbbSelectionPanelElement.events.willOpen, + SbbSelectionPanelElement.events.willClose, ], }, docs: { extractComponentDescription: () => readme, }, }, - title: 'elements/sbb-selection-expansion-panel', + title: 'elements/sbb-selection-panel', }; export default meta; diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.ts b/src/elements/selection-panel/selection-panel.ts similarity index 73% rename from src/elements/selection-expansion-panel/selection-expansion-panel.ts rename to src/elements/selection-panel/selection-panel.ts index 9030d5ae11..13b4c9e82d 100644 --- a/src/elements/selection-expansion-panel/selection-expansion-panel.ts +++ b/src/elements/selection-panel/selection-panel.ts @@ -2,34 +2,29 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { SbbCheckboxPanelElement } from '../checkbox.js'; -import { - SbbConnectedAbortController, - SbbLanguageController, - SbbSlotStateController, -} from '../core/controllers.js'; +import type { SbbCheckboxElement } from '../checkbox.js'; +import { SbbConnectedAbortController, 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 { SbbHydrationMixin } from '../core/mixins.js'; -import type { SbbRadioButtonPanelElement } from '../radio-button.js'; +import type { SbbRadioButtonElement } from '../radio-button.js'; -import style from './selection-expansion-panel.scss?lit&inline'; +import style from './selection-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-expansion-panel`. + * @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 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-expansion-panel') -export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElement) { +@customElement('sbb-selection-panel') +export class SbbSelectionPanelElement extends 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 = { @@ -73,35 +68,35 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem /** Emits whenever the content section starts the opening transition. */ private _willOpen: EventEmitter = new EventEmitter( this, - SbbSelectionExpansionPanelElement.events.willOpen, + SbbSelectionPanelElement.events.willOpen, ); /** Emits whenever the content section is opened. */ private _didOpen: EventEmitter = new EventEmitter( this, - SbbSelectionExpansionPanelElement.events.didOpen, + SbbSelectionPanelElement.events.didOpen, ); /** Emits whenever the content section begins the closing transition. */ private _willClose: EventEmitter = new EventEmitter( this, - SbbSelectionExpansionPanelElement.events.willClose, + SbbSelectionPanelElement.events.willClose, ); /** Emits whenever the content section is closed. */ private _didClose: EventEmitter = new EventEmitter( this, - SbbSelectionExpansionPanelElement.events.didClose, + SbbSelectionPanelElement.events.didClose, ); - private _language = new SbbLanguageController(this); private _abort = new SbbConnectedAbortController(this); private _initialized: boolean = false; /** * Whether it has an expandable content + * @internal */ - private get _hasContent(): boolean { + public get hasContent(): boolean { // We cannot use the NamedSlots because it's too slow to initialize return this.querySelectorAll?.('[slot="content"]').length > 0; } @@ -113,12 +108,11 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem 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 { @@ -136,12 +130,11 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem } 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 { @@ -168,7 +161,11 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem } private _initFromInput(event: Event): void { - const input = event.target as SbbCheckboxPanelElement | SbbRadioButtonPanelElement; + const input = event.target as SbbCheckboxElement | SbbRadioButtonElement; + + if (!input.isSelectionPanelInput) { + return; + } this._checked = input.checked; this._disabled = input.disabled; @@ -176,6 +173,12 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem } 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; @@ -197,38 +200,22 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem } } - 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)} > -
+
@@ -241,6 +228,6 @@ export class SbbSelectionExpansionPanelElement extends SbbHydrationMixin(LitElem declare global { interface HTMLElementTagNameMap { // eslint-disable-next-line @typescript-eslint/naming-convention - 'sbb-selection-expansion-panel': SbbSelectionExpansionPanelElement; + 'sbb-selection-panel': SbbSelectionPanelElement; } }