From e113c6a5405a5caa3b79cdc3aa5e8c889943b63a Mon Sep 17 00:00:00 2001 From: Tommaso Menga Date: Tue, 19 Nov 2024 12:07:00 +0100 Subject: [PATCH 01/23] feat(sbb-radio-button, sbb-radio-button-panel): implement native form support (#3160) BREAKING CHANGE: Removed `SbbRadioButtonGroupEventDetail` from the `change`, `input` and `didChange` events of the `sbb-radio-button-group`. As an alternative to `event.detail.value` use `radioButtonGroup.value` --- .../autocomplete-grid.snapshot.spec.snap.js | 56 +- src/elements/core/mixins.ts | 1 + .../core/mixins/form-associated-mixin.ts | 2 +- .../form-associated-radio-button-mixin.ts | 315 +++++++++ .../common/radio-button-common.scss | 4 +- .../common/radio-button-common.spec.ts | 635 ++++++++++++++++++ .../common/radio-button-common.ts | 130 ++-- .../radio-button-group.snapshot.spec.snap.js | 65 +- .../radio-button-group.snapshot.spec.ts | 9 +- .../radio-button-group.spec.ts | 331 +++++---- .../radio-button-group.stories.ts | 8 + .../radio-button-group/radio-button-group.ts | 219 ++---- .../radio-button/radio-button-group/readme.md | 32 +- .../radio-button-panel.snapshot.spec.snap.js | 298 +++++++- .../radio-button-panel.snapshot.spec.ts | 25 +- .../radio-button-panel.spec.ts | 11 +- .../radio-button-panel.ssr.spec.ts | 29 +- .../radio-button-panel.stories.ts | 43 +- .../radio-button-panel/radio-button-panel.ts | 48 +- .../radio-button/radio-button-panel/readme.md | 51 +- .../radio-button.snapshot.spec.snap.js | 148 +++- .../radio-button.snapshot.spec.ts | 17 +- .../radio-button/radio-button.spec.ts | 157 +---- .../radio-button/radio-button.ssr.spec.ts | 29 +- .../radio-button/radio-button.stories.ts | 48 +- .../radio-button/radio-button/radio-button.ts | 17 +- .../radio-button/radio-button/readme.md | 50 +- .../selection-expansion-panel.spec.ts | 1 + .../selection-expansion-panel.stories.ts | 12 +- 29 files changed, 2077 insertions(+), 714 deletions(-) create mode 100644 src/elements/core/mixins/form-associated-radio-button-mixin.ts create mode 100644 src/elements/radio-button/common/radio-button-common.spec.ts diff --git a/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js index e98ede7d13..2b26399813 100644 --- a/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js +++ b/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js @@ -1,12 +1,8 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["sbb-autocomplete-grid Safari DOM"] = -` +snapshots["sbb-autocomplete-grid Chrome-Firefox DOM"] = +` `; -/* end snapshot sbb-autocomplete-grid Safari DOM */ +/* end snapshot sbb-autocomplete-grid Chrome-Firefox DOM */ -snapshots["sbb-autocomplete-grid Safari Shadow DOM"] = +snapshots["sbb-autocomplete-grid Chrome-Firefox Shadow DOM"] = `
@@ -79,7 +75,11 @@ snapshots["sbb-autocomplete-grid Safari Shadow DOM"] =
-
+
@@ -87,10 +87,14 @@ snapshots["sbb-autocomplete-grid Safari Shadow DOM"] =
`; -/* end snapshot sbb-autocomplete-grid Safari Shadow DOM */ +/* end snapshot sbb-autocomplete-grid Chrome-Firefox Shadow DOM */ -snapshots["sbb-autocomplete-grid Chrome-Firefox DOM"] = -` +snapshots["sbb-autocomplete-grid Safari DOM"] = +` `; -/* end snapshot sbb-autocomplete-grid Chrome-Firefox DOM */ +/* end snapshot sbb-autocomplete-grid Safari DOM */ -snapshots["sbb-autocomplete-grid Chrome-Firefox Shadow DOM"] = +snapshots["sbb-autocomplete-grid Safari Shadow DOM"] = `
@@ -163,11 +167,7 @@ snapshots["sbb-autocomplete-grid Chrome-Firefox Shadow DOM"] =
-
+
@@ -175,16 +175,16 @@ snapshots["sbb-autocomplete-grid Chrome-Firefox Shadow DOM"] =
`; -/* end snapshot sbb-autocomplete-grid Chrome-Firefox Shadow DOM */ +/* end snapshot sbb-autocomplete-grid Safari Shadow DOM */ -snapshots["sbb-autocomplete-grid Chrome-Firefox A11y tree Chrome"] = +snapshots["sbb-autocomplete-grid Chrome-Firefox A11y tree Firefox"] = `

{ - "role": "WebArea", + "role": "document", "name": "", "children": [ { - "role": "text", + "role": "statictext", "name": "​" }, { @@ -197,16 +197,16 @@ snapshots["sbb-autocomplete-grid Chrome-Firefox A11y tree Chrome"] = }

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

{ - "role": "document", + "role": "WebArea", "name": "", "children": [ { - "role": "statictext", + "role": "text", "name": "​" }, { @@ -219,5 +219,5 @@ snapshots["sbb-autocomplete-grid Chrome-Firefox A11y tree Firefox"] = }

`; -/* end snapshot sbb-autocomplete-grid Chrome-Firefox A11y tree Firefox */ +/* end snapshot sbb-autocomplete-grid Chrome-Firefox A11y tree Chrome */ diff --git a/src/elements/core/mixins.ts b/src/elements/core/mixins.ts index dfb85b5223..3524825a79 100644 --- a/src/elements/core/mixins.ts +++ b/src/elements/core/mixins.ts @@ -2,6 +2,7 @@ export * from './mixins/constructor.js'; export * from './mixins/disabled-mixin.js'; export * from './mixins/form-associated-checkbox-mixin.js'; export * from './mixins/form-associated-mixin.js'; +export * from './mixins/form-associated-radio-button-mixin.js'; export * from './mixins/hydration-mixin.js'; export * from './mixins/named-slot-list-mixin.js'; export * from './mixins/negative-mixin.js'; diff --git a/src/elements/core/mixins/form-associated-mixin.ts b/src/elements/core/mixins/form-associated-mixin.ts index 1dd4c97fc0..24573f5e53 100644 --- a/src/elements/core/mixins/form-associated-mixin.ts +++ b/src/elements/core/mixins/form-associated-mixin.ts @@ -3,7 +3,7 @@ import { property, state } from 'lit/decorators.js'; import type { AbstractConstructor } from './constructor.js'; -export declare abstract class SbbFormAssociatedMixinType { +export declare abstract class SbbFormAssociatedMixinType extends LitElement { public get form(): HTMLFormElement | null; public get name(): string; public set name(value: string); diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts new file mode 100644 index 0000000000..3fd0d1d3ee --- /dev/null +++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts @@ -0,0 +1,315 @@ +import type { LitElement, PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { getNextElementIndex, interactivityChecker, isArrowKeyPressed } from '../a11y.js'; +import { SbbConnectedAbortController } from '../controllers.js'; +import { forceType } from '../decorators.js'; + +import type { Constructor } from './constructor.js'; +import { SbbDisabledMixin, type SbbDisabledMixinType } from './disabled-mixin.js'; +import { + type FormRestoreReason, + type FormRestoreState, + SbbFormAssociatedMixin, + type SbbFormAssociatedMixinType, +} from './form-associated-mixin.js'; +import { SbbRequiredMixin, type SbbRequiredMixinType } from './required-mixin.js'; + +/** + * A static registry that holds a collection of grouped `radio-buttons`. + * Groups of radio buttons are local to the form they belong (or the `renderRoot` if they're not part of any form) + * Multiple groups of radio with the same name can coexist (as long as they belong to a different form / renderRoot) + * It is mainly used to support the standalone groups of radios. + * @internal + */ +export const radioButtonRegistry = new WeakMap< + Node, + Map> +>(); + +export declare class SbbFormAssociatedRadioButtonMixinType + extends SbbFormAssociatedMixinType + implements Partial, Partial +{ + public checked: boolean; + public disabled: boolean; + public required: boolean; + + protected associatedRadioButtons?: Set; + protected abort: SbbConnectedAbortController; + + public formResetCallback(): void; + public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void; + + protected isDisabledExternally(): boolean; + protected isRequiredExternally(): boolean; + protected withUserInteraction?(): void; + protected updateFormValue(): void; + protected updateFocusableRadios(): void; + protected emitChangeEvents(): void; + protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise; +} + +/** + * The SbbFormAssociatedRadioButtonMixin enables native form support for radio controls. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbFormAssociatedRadioButtonMixin = >( + superClass: T, +): Constructor & T => { + class SbbFormAssociatedRadioButtonElement + extends SbbDisabledMixin(SbbRequiredMixin(SbbFormAssociatedMixin(superClass))) + implements Partial + { + /** + * Whether the radio button is checked. + */ + @forceType() + @property({ type: Boolean }) + public accessor checked: boolean = false; + + protected abort = new SbbConnectedAbortController(this); + + /** + * Set of radio buttons that belongs to the same group of `this`. + * Assume them ordered in DOM order + */ + protected associatedRadioButtons?: Set; + private _radioButtonGroupsMap?: Map>; + private _didLoad: boolean = false; + + protected constructor() { + super(); + /** @internal */ + this.internals.role = 'radio'; + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._connectToRegistry(); + this.addEventListener('keydown', (e) => this._handleArrowKeyDown(e), { + signal: this.abort.signal, + }); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._disconnectFromRegistry(); + } + + /** + * Is called whenever the form is being reset. + * @internal + */ + public override formResetCallback(): void { + this.checked = this.hasAttribute('checked'); + } + + /** + * Called when the browser is trying to restore element’s state to state in which case + * reason is “restore”, or when the browser is trying to fulfill autofill on behalf of + * user in which case reason is “autocomplete”. + * @internal + */ + public override formStateRestoreCallback( + state: FormRestoreState | null, + _reason: FormRestoreReason, + ): void { + if (state) { + this.checked = state === this.value; + } + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + // On 'name' change, move 'this' to the new registry + if (changedProperties.has('name')) { + this._disconnectFromRegistry(); + this._connectToRegistry(); + if (this.checked) { + this._deselectGroupedRadios(); + } + this.updateFocusableRadios(); + } + + if (changedProperties.has('checked')) { + this.toggleAttribute('data-checked', this.checked); + this.internals.ariaChecked = this.checked.toString(); + this.updateFormValueOnCheckedChange(); + if (this.checked) { + this._deselectGroupedRadios(); + } + this.updateFocusableRadios(); + } + + if (changedProperties.has('disabled')) { + this.updateFocusableRadios(); + } + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this._didLoad = true; + this.updateFocusableRadios(); + } + + /** + * Called on `value` change + * If 'checked', update the value. Otherwise, do nothing. + */ + protected override updateFormValue(): void { + if (this.checked) { + this.internals.setFormValue(this.value); + } + } + + /** + * Called on `checked` change + * If 'checked', update the value. Otherwise, reset it. + */ + protected updateFormValueOnCheckedChange(): void { + this.internals.setFormValue(this.checked ? this.value : null); + } + + /** + * Only a single radio should be focusable in the group. Defined as: + * - the checked radio; + * - the first non-disabled radio in DOM order; + */ + protected updateFocusableRadios(): void { + if (!this._didLoad) { + return; + } + const radios = this._interactableGroupedRadios(); + const checkedIndex = radios.findIndex((r) => r.checked && !r.disabled && !r.formDisabled); + const focusableIndex = + checkedIndex !== -1 + ? checkedIndex + : radios.findIndex((r) => !r.disabled && !r.formDisabled); // Get the first focusable radio + + if (focusableIndex !== -1) { + radios[focusableIndex].tabIndex = 0; + radios.splice(focusableIndex, 1); + } + + // Reset tabIndex on other radios + radios.forEach((r) => r.removeAttribute('tabindex')); + } + + protected async navigateByKeyboard(next: SbbFormAssociatedRadioButtonElement): Promise { + next.checked = true; + this.emitChangeEvents(); + + await next.updateComplete; + next.focus(); + } + + protected emitChangeEvents(): void { + // Manually dispatch events to simulate a user interaction + this.dispatchEvent( + new InputEvent('input', { bubbles: true, cancelable: true, composed: true }), + ); + this.dispatchEvent(new Event('change', { bubbles: true })); + } + + /** + * Add `this` to the radioButton registry + */ + private _connectToRegistry(): void { + if (!this.name) { + return; + } + + const root = this.form ?? this.getRootNode(); + this._radioButtonGroupsMap = radioButtonRegistry.get(root); + + // Initialize the 'root' map entry + if (!this._radioButtonGroupsMap) { + this._radioButtonGroupsMap = new Map(); + radioButtonRegistry.set(root, this._radioButtonGroupsMap); + } + + this.associatedRadioButtons = this._radioButtonGroupsMap.get( + this.name, + ) as unknown as Set; + + // Initialize the group set + if (!this.associatedRadioButtons) { + this.associatedRadioButtons = new Set(); + this._radioButtonGroupsMap.set( + this.name, + this.associatedRadioButtons as unknown as Set, + ); + } + + // Insert the new radio into the set and sort following the DOM order. + // Since the order of a 'Set' is the insert order, we have to empty it and re-insert radios in order + const entries = Array.from(this.associatedRadioButtons); + this.associatedRadioButtons.clear(); + + // Find `this` position and insert it + const index = entries.findIndex( + (r) => this.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING, + ); + if (index !== -1) { + entries.splice(index, 0, this); + } else { + entries.push(this); + } + + // Repopulate the Set + entries.forEach((r) => this.associatedRadioButtons!.add(r)); + } + + /** + * Remove `this` from the radioButton registry and, if the group is empty, delete the entry from the groups Map + */ + private _disconnectFromRegistry(): void { + this.associatedRadioButtons?.delete(this); + + if (this.associatedRadioButtons?.size === 0) { + this._radioButtonGroupsMap?.delete(this.name); + } + + this.associatedRadioButtons = undefined; + this._radioButtonGroupsMap = undefined; + } + + /** + * Return a list of 'interactable' grouped radios, ordered in DOM order + */ + private _interactableGroupedRadios(): SbbFormAssociatedRadioButtonElement[] { + return Array.from(this.associatedRadioButtons ?? []).filter((el) => + interactivityChecker.isVisible(el), + ); + } + + /** + * Deselect other radio of the same group + */ + private _deselectGroupedRadios(): void { + Array.from(this.associatedRadioButtons ?? []) + .filter((r) => r !== this) + .forEach((r) => (r.checked = false)); + } + + private async _handleArrowKeyDown(evt: KeyboardEvent): Promise { + if (!isArrowKeyPressed(evt)) { + return; + } + evt.preventDefault(); + + const enabledRadios = this._interactableGroupedRadios().filter( + (r) => !r.disabled && !r.formDisabled, + ); + const current: number = enabledRadios.indexOf(this); + const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length); + + await this.navigateByKeyboard(enabledRadios[nextIndex]); + } + } + + return SbbFormAssociatedRadioButtonElement as unknown as Constructor & + T; +}; diff --git a/src/elements/radio-button/common/radio-button-common.scss b/src/elements/radio-button/common/radio-button-common.scss index f90731b6a5..faa7ba162f 100644 --- a/src/elements/radio-button/common/radio-button-common.scss +++ b/src/elements/radio-button/common/radio-button-common.scss @@ -30,7 +30,7 @@ } } -:host([checked]) { +:host([data-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 @@ -43,7 +43,7 @@ } // Disabled definitions have to be after checked definitions -:host([disabled]) { +: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; diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts new file mode 100644 index 0000000000..fedd394ef3 --- /dev/null +++ b/src/elements/radio-button/common/radio-button-common.spec.ts @@ -0,0 +1,635 @@ +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, isWebkit } from '../../core/dom.js'; +import { radioButtonRegistry } from '../../core/mixins.js'; +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForCondition, waitForLitRender } 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'; + +interface CheckboxAccessibilitySnapshot { + checked: boolean; + role: string; + disabled: boolean; + required: boolean; +} + +describe(`radio-button common behaviors`, () => { + ['sbb-radio-button', 'sbb-radio-button-panel'].forEach((selector) => { + const tagSingle = unsafeStatic(selector); + + describe(`${selector} general`, () => { + let element: SbbRadioButtonElement | SbbRadioButtonPanelElement; + + beforeEach(async () => { + /* eslint-disable lit/binding-positions */ + element = await fixture(html`<${tagSingle} name="name" value="value">Label`); + await waitForLitRender(element); + }); + + 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} name="name2"> +
+
`, + ); + 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 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('value', 'restore'); + await waitForLitRender(element); + + expect(element.checked).to.be.true; + + element.formStateRestoreCallback('anotherValue', '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({ press: ' ' }); + + expect(inputSpy.count).to.be.equal(0); + expect(changeSpy.count).to.be.equal(0); + expect(element.checked).to.be.false; + }); + }); + }); + + describe('compare to native', () => { + let elements: (SbbRadioButtonElement | SbbRadioButtonPanelElement)[], + nativeElements: HTMLInputElement[], + form: HTMLFormElement, + fieldset: HTMLFieldSetElement, + nativeFieldset: HTMLFieldSetElement, + inputSpy: EventSpy, + changeSpy: EventSpy, + nativeInputSpy: EventSpy, + nativeChangeSpy: EventSpy; + + const compareToNative = async (): Promise => { + elements.forEach((radio, i) => { + expect(radio.checked, `radio ${radio.value} checked`).to.be.equal( + nativeElements[i].checked, + ); + }); + + // Events + expect(inputSpy.count, `'input' event`).to.be.equal(nativeInputSpy.count); + expect(changeSpy.count, `'change' event`).to.be.equal(nativeChangeSpy.count); + + // Form state should always correspond to checked property. + const formData = new FormData(form); + expect(formData.get('sbb-group-1'), 'form data - group 1').to.be.equal( + formData.get('native-group-1'), + ); + expect(formData.get('sbb-group-2'), 'form data - group 2').to.be.equal( + formData.get('native-group-2'), + ); + }; + + ['sbb-radio-button', 'sbb-radio-button-panel'].forEach((selector) => { + describe(selector, () => { + const tagSingle = unsafeStatic(selector); + + describe('general behavior', () => { + beforeEach(async () => { + form = await fixture(html` +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 + + <${tagSingle} value="4" name="sbb-group-2">1 + <${tagSingle} value="5" name="sbb-group-2">2 +
+
+ + + + + + +
+
`); + + elements = Array.from(form.querySelectorAll(selector)); + nativeElements = Array.from(form.querySelectorAll('input')); + fieldset = form.querySelector('#sbb-set')!; + nativeFieldset = form.querySelector('#native-set')!; + + inputSpy = new EventSpy('input', fieldset); + changeSpy = new EventSpy('change', fieldset); + nativeInputSpy = new EventSpy('input', nativeFieldset); + nativeChangeSpy = new EventSpy('change', nativeFieldset); + + await waitForLitRender(form); + }); + + it('should find connected form', () => { + expect(elements[0].form).to.be.equal(form); + }); + + it('first elements of groups should be focusable', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[1].tabIndex).to.be.equal(-1); + expect(elements[2].tabIndex).to.be.equal(-1); + expect(elements[3].tabIndex).to.be.equal(0); + expect(elements[4].tabIndex).to.be.equal(-1); + await compareToNative(); + }); + + it('should select on click', async () => { + elements[1].click(); + await waitForLitRender(form); + expect(document.activeElement === elements[1]).to.be.true; + + nativeElements[1].click(); + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + expect(elements[1].checked).to.be.true; + await compareToNative(); + }); + + it('should reflect state after programmatic change', async () => { + elements[1].checked = true; + nativeElements[1].checked = true; + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + await compareToNative(); + }); + + it('should reset on form reset', async () => { + elements[1].checked = true; + nativeElements[1].checked = true; + await waitForLitRender(form); + + form.reset(); + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[1].tabIndex).to.be.equal(-1); + expect(elements[1].checked).to.be.false; + await compareToNative(); + }); + + it('should restore default on form reset', async () => { + elements[1].toggleAttribute('checked', true); + nativeElements[1].toggleAttribute('checked', true); + await waitForLitRender(form); + + elements[0].click(); + nativeElements[0].click(); + await waitForLitRender(form); + + form.reset(); + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + expect(elements[0].checked).to.be.false; + expect(elements[1].checked).to.be.true; + await compareToNative(); + }); + + it('should restore on form restore', async () => { + // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. + elements[0].formStateRestoreCallback('2', 'restore'); + elements[1].formStateRestoreCallback('2', 'restore'); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[1].checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + }); + + it('should handle adding a new radio to the group', async () => { + elements[0].checked = true; + await waitForLitRender(form); + + // Create and add a new checked radio to the group + const newRadio = document.createElement(selector) as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + newRadio.setAttribute('name', 'sbb-group-1'); + newRadio.setAttribute('value', '4'); + newRadio.toggleAttribute('checked', true); + fieldset.appendChild(newRadio); + + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(newRadio.checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(newRadio.tabIndex).to.be.equal(0); + }); + + it('should handle moving a radio between the groups', async () => { + elements[0].checked = true; + nativeElements[0].checked = true; + elements[3].checked = true; + nativeElements[3].checked = true; + + await waitForLitRender(form); + + elements[3].name = elements[0].name; + nativeElements[3].name = nativeElements[0].name; + + await waitForLitRender(form); + + // When moving a checked radio to a group, it has priority and becomes the new checked + expect(elements[0].checked).to.be.false; + expect(elements[3].checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[3].tabIndex).to.be.equal(0); + await compareToNative(); + }); + + describe('keyboard interaction', () => { + it('should select on space key', async () => { + elements[0].focus(); + await sendKeys({ press: 'Space' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.true; + }); + + it('should select and wrap on arrow keys', async () => { + elements[1].checked = true; + await waitForLitRender(form); + elements[1].focus(); + + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[1].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowUp' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[1].checked).to.be.true; + expect(document.activeElement === elements[1]).to.be.true; + + // Execute same steps on native and compare the outcome + nativeElements[1].focus(); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowLeft' }); + await sendKeys({ press: 'ArrowUp' }); + + // On webkit, native radios do not wrap + if (!isWebkit) { + await compareToNative(); + } + }); + + it('should handle keyboard interaction outside of a form', async () => { + // Move the radios outside the form + form.parentElement!.append(fieldset); + await waitForLitRender(fieldset); + + elements[0].focus(); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(fieldset); + expect(elements[0].checked).to.be.false; + expect(elements[1].checked).to.be.true; + expect(document.activeElement === elements[1]).to.be.true; + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(fieldset); + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + + it('should skip disabled elements on arrow keys', async () => { + elements[1].disabled = true; + await waitForLitRender(form); + + elements[0].focus(); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + + it('should skip non-visible elements on arrow keys', async () => { + elements[1].style.display = 'none'; + await waitForLitRender(form); + + elements[0].focus(); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + }); + + describe('disabled state', () => { + it('should result :disabled', async () => { + elements[0].disabled = true; + await waitForLitRender(form); + + expect(elements[0]).to.match(':disabled'); + expect(elements[1].tabIndex).to.be.equal(0); + }); + + it('should result :disabled if a fieldSet is', async () => { + fieldset.toggleAttribute('disabled', true); + await waitForLitRender(form); + + expect(elements[0]).to.match(':disabled'); + }); + + it('should do nothing when clicked', async () => { + elements[0].disabled = true; + await waitForLitRender(form); + + elements[0].click(); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + }); + + it('should update tabindex when the first element is disabled', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + elements[0].disabled = true; + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + }); + }); + }); + + describe('multiple groups with the same name', () => { + let root: HTMLElement; + let form2: HTMLFormElement; + + beforeEach(async () => { + root = await fixture(html` +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 +
+
+ `); + + form = root.querySelector('form#main')!; + form2 = root.querySelector('form#secondary')!; + elements = Array.from(root.querySelectorAll(selector)); + await waitForLitRender(root); + }); + + it('groups should be independent', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[3].tabIndex).to.be.equal(0); + + // Check the first element of each group + elements[0].click(); + elements[3].click(); + await waitForLitRender(root); + + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[0].checked).to.be.true; + expect(elements[3].tabIndex).to.be.equal(0); + expect(elements[3].checked).to.be.true; + }); + + it('groups should be independent when keyboard navigated', async () => { + elements[0].focus(); + + await sendKeys({ press: 'ArrowUp' }); + await waitForLitRender(root); + + expect(elements[2].tabIndex).to.be.equal(0); + expect(elements[2].checked).to.be.true; + expect(elements[5].tabIndex).to.be.equal(-1); + expect(elements[5].checked).to.be.false; + }); + + describe('radioButtonRegistry', () => { + it('should be in the correct state', async () => { + const group1Set = radioButtonRegistry.get(form)!.get('sbb-group-1')!; + const group2Set = radioButtonRegistry.get(form2)!.get('sbb-group-1')!; + const group1Radios = Array.from(form.querySelectorAll(selector)); + const group2Radios = Array.from(form2.querySelectorAll(selector)); + + // Assert the order is the correct + expect(group1Set.size).to.be.equal(3); + expect(group2Set.size).to.be.equal(3); + + expect(Array.from(group1Set)).contains.members(group1Radios); + expect(Array.from(group2Set)).contains.members(group2Radios); + }); + + it('should be sorted in DOM order', async () => { + const group1Set = radioButtonRegistry.get(form)!.get('sbb-group-1')!; + let group1Radios = Array.from(form.querySelectorAll(selector)); + + // Assert the order is the correct + expect(group1Set.size).to.be.equal(3); + Array.from(group1Set).forEach((r, i) => expect(group1Radios[i] === r).to.be.true); + + // Move the first radio to the last position + form.append(group1Radios[0]); + group1Radios = Array.from(form.querySelectorAll(selector)); + + // Assert the order is the correct + expect(group1Set.size).to.be.equal(3); + Array.from(group1Set).forEach((r, i) => expect(group1Radios[i] === r).to.be.true); + }); + + it('should remove empty entries from the registry', async () => { + const group2Set = radioButtonRegistry.get(form2)!.get('sbb-group-1')!; + + // Remove the second radio group from the DOM + form2.remove(); + await waitForLitRender(root); + + expect(group2Set.size).to.be.equal(0); + expect(radioButtonRegistry.get(form)!.get('sbb-group-1')?.size).to.be.equal(3); + expect(radioButtonRegistry.get(form2)!.get('sbb-group-1')).to.be.undefined; + }); + }); + }); + }); + }); + }); +}); diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts index 2ce2dc0c0a..7e487a354c 100644 --- a/src/elements/radio-button/common/radio-button-common.ts +++ b/src/elements/radio-button/common/radio-button-common.ts @@ -1,16 +1,19 @@ import type { LitElement, PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; -import { SbbConnectedAbortController } from '../../core/controllers.js'; -import { forceType, hostAttributes } from '../../core/decorators.js'; -import { setOrRemoveAttribute } from '../../core/dom.js'; -import { EventEmitter, HandlerRepository, formElementHandlerAspect } from '../../core/eventing.js'; +import { setModalityOnNextFocus } from '../../core/a11y.js'; +import { EventEmitter } from '../../core/eventing.js'; import type { SbbCheckedStateChange, SbbDisabledStateChange, SbbStateChange, } from '../../core/interfaces.js'; -import type { AbstractConstructor } from '../../core/mixins.js'; +import { + type AbstractConstructor, + type Constructor, + SbbFormAssociatedRadioButtonMixin, + type SbbFormAssociatedRadioButtonMixinType, +} from '../../core/mixins.js'; import type { SbbRadioButtonGroupElement } from '../radio-button-group.js'; export type SbbRadioButtonSize = 'xs' | 's' | 'm'; @@ -20,32 +23,24 @@ export type SbbRadioButtonStateChange = Extract< SbbDisabledStateChange | SbbCheckedStateChange >; -export declare class SbbRadioButtonCommonElementMixinType { +export declare class SbbRadioButtonCommonElementMixinType extends SbbFormAssociatedRadioButtonMixinType { public get allowEmptySelection(): boolean; public set allowEmptySelection(boolean); - public accessor 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 = >( +export const SbbRadioButtonCommonElementMixin = >( superClass: T, ): AbstractConstructor & T => { - @hostAttributes({ - role: 'radio', - }) abstract class SbbRadioButtonCommonElement - extends superClass + extends SbbFormAssociatedRadioButtonMixin(superClass) implements Partial { public static readonly events = { + change: 'change', + input: 'input', stateChange: 'stateChange', } as const; @@ -61,37 +56,6 @@ export const SbbRadioButtonCommonElementMixin = 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(); + /** + * Set the radio-button as 'checked'; if 'allowEmptySelection', toggle the checked property. + * In both cases it emits the change events. + */ + public select(): void { + if (this.disabled || this.formDisabled) { + return; + } + + if (this.allowEmptySelection) { + this.checked = !this.checked; + this.emitChangeEvents(); + } else if (!this.checked) { + this.checked = true; + this.emitChangeEvents(); + } } 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 { + protected override isDisabledExternally(): boolean { + return this.group?.disabled ?? false; + } + + protected override isRequiredExternally(): boolean { + return this.group?.required ?? false; + } + + private async _handleClick(event: Event): Promise { event.preventDefault(); this.select(); + + /** + * Since only a single radio of a group is focusable at any time, it is possible that the one clicked does not have 'tabindex=0'. + * To cover that, we await the next render (which will make the 'checked' radio focusable) and focus the clicked radio + */ + await this.updateComplete; // Wait for 'tabindex' to be updated + setModalityOnNextFocus(this); + this.focus(); } private _handleKeyDown(evt: KeyboardEvent): void { if (evt.code === 'Space') { + evt.preventDefault(); this.select(); } } 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..0d3d1e1313 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 @@ -3,9 +3,38 @@ export const snapshots = {}; snapshots["sbb-radio-button-group renders DOM"] = ` + + 1 + + + 2 + + + 3 + `; /* end snapshot sbb-radio-button-group renders DOM */ @@ -26,7 +55,24 @@ snapshots["sbb-radio-button-group renders A11y tree Chrome"] = `

{ "role": "WebArea", - "name": "" + "name": "", + "children": [ + { + "role": "radio", + "name": "1", + "checked": false + }, + { + "role": "radio", + "name": "2", + "checked": true + }, + { + "role": "radio", + "name": "3", + "checked": false + } + ] }

`; @@ -36,7 +82,22 @@ snapshots["sbb-radio-button-group renders A11y tree Firefox"] = `

{ "role": "document", - "name": "" + "name": "", + "children": [ + { + "role": "radio", + "name": "1" + }, + { + "role": "radio", + "name": "2", + "checked": true + }, + { + "role": "radio", + "name": "3" + } + ] }

`; 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..71e331e700 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 @@ -6,13 +6,20 @@ import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; import type { SbbRadioButtonGroupElement } from './radio-button-group.js'; import './radio-button-group.js'; +import '../radio-button.js'; describe(`sbb-radio-button-group`, () => { let element: SbbRadioButtonGroupElement; describe('renders', () => { beforeEach(async () => { - element = await fixture(html``); + element = await fixture( + html` + 1 + 2 + 3 + `, + ); }); it('DOM', async () => { 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 03c5d6756f..1b56ef0898 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,5 +1,4 @@ import { assert, expect } from '@open-wc/testing'; -import { sendKeys } from '@web/test-runner-commands'; import { html, unsafeStatic } from 'lit/static-html.js'; import { fixture } from '../../core/testing/private.js'; @@ -7,261 +6,249 @@ import { EventSpy, waitForLitRender } from '../../core/testing.js'; import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js'; import type { SbbRadioButtonElement } from '../radio-button.js'; +import { SbbRadioButtonGroupElement } from './radio-button-group.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('events', () => { + describe('general behavior', () => { + let radios: (SbbRadioButtonElement | SbbRadioButtonPanelElement)[]; + beforeEach(async () => { /* eslint-disable lit/binding-positions */ - 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 three - <${tagSingle} id="sbb-radio-4" value="Value four">Value four - - `, - ); + 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 three + + `); + radios = Array.from(element.querySelectorAll(selector)); + + await waitForLitRender(element); }); 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'); + it('should set name on each radios', async () => { + const name = element.name; + radios.forEach((r) => expect(r.name).to.equal(name)); + }); - radio.click(); + it('should update the name of each radios', async () => { + element.name = 'group-1'; await waitForLitRender(element); - expect(radio).to.have.attribute('checked'); - expect(firstRadio).not.to.have.attribute('checked'); + radios.forEach((r) => expect(r.name).to.equal('group-1')); }); - it('renders', () => { - assert.instanceOf(element, SbbRadioButtonGroupElement); - }); + it('selects radio on click', async () => { + const radio = radios[0]; + expect(element.value).to.null; - 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; + radio.click(); + await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); + expect(radio.checked).to.be.true; + expect(element.value).to.be.equal(radio.value); + }); - radio.click(); - await waitForLitRender(element); + it('selects radio when value is set', async () => { + const radio = radios[0]; + expect(element.value).to.null; + element.value = radio.value; - expect(radio).to.have.attribute('checked'); - expect(firstRadio).not.to.have.attribute('checked'); - }); + await waitForLitRender(element); - 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'); + expect(radio.checked).to.be.true; + expect(element.value).to.be.equal(radio.value); + }); - checkedRadio.click(); - await changeSpy.calledOnce(); - expect(changeSpy.count).to.be.equal(1); - await inputSpy.calledOnce(); - expect(inputSpy.count).to.be.equal(1); + it('should ignore disabled radios', async () => { + const radio = radios[0]; + radio.checked = true; + radio.disabled = true; + await waitForLitRender(element); - firstRadio.click(); - await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); - }); + expect(element.value).to.be.null; + }); - 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; + it('should update disabled on children', async () => { + element.disabled = true; + await waitForLitRender(element); - disabledRadio.click(); - await waitForLitRender(element); + radios.forEach((r) => expect(r.disabled).to.be.true); - expect(disabledRadio).not.to.have.attribute('checked'); - expect(firstRadio).to.have.attribute('checked'); - }); + element.disabled = false; + await waitForLitRender(element); + expect(radios[0].disabled).to.be.false; + expect(radios[1].disabled).to.be.false; + expect(radios[2].disabled).to.be.true; + }); - it('should update tabIndex on disabled child change', async () => { - const firstRadio = element.querySelector('#sbb-radio-1') as - | SbbRadioButtonElement - | SbbRadioButtonPanelElement; + it('should update required on children', async () => { + element.required = true; + await waitForLitRender(element); - const secondRadio = element.querySelector('#sbb-radio-2') as - | SbbRadioButtonElement - | SbbRadioButtonPanelElement; + radios.forEach((r) => expect(r.required).to.be.true); - expect(firstRadio.tabIndex).to.be.equal(0); - expect(secondRadio.tabIndex).to.be.equal(-1); + element.required = false; + await waitForLitRender(element); + radios.forEach((r) => expect(r.required).to.be.false); + }); - firstRadio.disabled = true; - await waitForLitRender(element); + it('should update size on children', async () => { + element.size = 's'; + await waitForLitRender(element); - expect(firstRadio.tabIndex).to.be.equal(-1); - expect(secondRadio.tabIndex).to.be.equal(0); - }); + radios.forEach((r) => expect(r.size).to.be.equal('s')); + }); - 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); + it('preserves radio button disabled state after being disabled from group', async () => { + const firstRadio = radios[0]; + const disabledRadio = radios[2]; - disabledRadio.click(); - await waitForLitRender(element); - expect(disabledRadio).not.to.have.attribute('checked'); - expect(firstRadio).to.have.attribute('checked'); + element.disabled = true; + await waitForLitRender(element); + radios.forEach((r) => expect(r.disabled).to.be.true); - secondRadio.click(); - await waitForLitRender(element); - expect(secondRadio).not.to.have.attribute('checked'); + element.disabled = false; + await waitForLitRender(element); + expect(firstRadio.disabled).to.be.false; + expect(disabledRadio.disabled).to.be.true; + }); - element.disabled = false; - await waitForLitRender(element); + describe('events', () => { + it('dispatches event on radio change', async () => { + const radio = radios[1]; + const changeSpy = new EventSpy('change'); + const inputSpy = new EventSpy('input'); - disabledRadio.click(); + radio.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 changeSpy.calledOnce(); + await inputSpy.calledOnce(); - await sendKeys({ press: 'ArrowLeft' }); - await waitForLitRender(element); + const changeEvent = changeSpy.lastEvent!; + expect(changeSpy.count).to.be.equal(1); + expect(changeEvent.target === radio).to.be.true; - const radio = element.querySelector('#sbb-radio-4'); - expect(radio).to.have.attribute('checked'); + const inputEvent = changeSpy.lastEvent!; + expect(inputSpy.count).to.be.equal(1); + expect(inputEvent.target === radio).to.be.true; - firstRadio.click(); + // A click on a checked radio should not emit any event + radio.click(); await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); + expect(changeSpy.count).to.be.equal(1); + expect(inputSpy.count).to.be.equal(1); }); - 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'); + it('does not select disabled radio on click', async () => { + const changeSpy = new EventSpy('change'); + const inputSpy = new EventSpy('input'); + const disabledRadio = radios[2]; - firstRadio.click(); + disabledRadio.click(); await waitForLitRender(element); - expect(firstRadio).to.have.attribute('checked'); + expect(disabledRadio.checked).to.be.false; + expect(changeSpy.count).to.be.equal(0); + expect(inputSpy.count).to.be.equal(0); }); + }); + }); - 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; - - 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); + describe('with init properties', () => { + let radios: (SbbRadioButtonElement | SbbRadioButtonPanelElement)[]; - const radio = element.querySelector('#sbb-radio-1'); - expect(radio).to.have.attribute('checked'); + 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 + + `); + radios = Array.from(element.querySelectorAll(selector)); + await waitForLitRender(element); + }); - firstRadio.click(); - await waitForLitRender(element); + it('should correctly set the init state', async () => { + radios.forEach((r) => expect(r.name).to.be.equal('group-2')); + expect(radios[0].checked).to.be.false; + expect(radios[0].tabIndex).to.be.equal(-1); - expect(firstRadio).to.have.attribute('checked'); - }); + expect(radios[1].checked).to.be.true; + expect(radios[1].tabIndex).to.be.equal(0); }); }); - describe('initialization', () => { + describe('value preservation', () => { beforeEach(async () => { element = await fixture(html` +

Other content

`); }); - it('should preserve value when no radios were slotted but slotchange was triggered', () => { + it('should preserve value when no radios match the group value', () => { expect(element.value).to.equal('Value one'); }); it('should restore value when radios are slotted', async () => { + const radioTwo = document.createElement('sbb-radio-button'); + radioTwo.value = 'Value two'; + element.appendChild(radioTwo); + await waitForLitRender(element); + expect(element.value).to.equal('Value one'); + + const radioOne = document.createElement('sbb-radio-button'); + radioOne.value = 'Value one'; + element.appendChild(radioOne); + await waitForLitRender(element); + + expect(element.value).to.equal('Value one'); + expect(radioOne.checked).to.be.true; + }); + + it('checked radios should have priority over group value', async () => { const radioOne = document.createElement('sbb-radio-button'); radioOne.value = 'Value one'; const radioTwo = document.createElement('sbb-radio-button'); radioTwo.value = 'Value two'; + radioTwo.checked = true; element.appendChild(radioTwo); element.appendChild(radioOne); await waitForLitRender(element); - expect(element.value).to.equal('Value one'); - expect(radioOne).to.have.attribute('checked'); + expect(element.value).to.equal('Value two'); + expect(radioOne.checked).to.be.false; + expect(radioTwo.checked).to.be.true; + }); + + it('user interaction should have priority over group value', async () => { + const radioOne = element.querySelector( + 'sbb-radio-button[value="42"]', + )!; + radioOne.click(); + + await waitForLitRender(element); + + expect(element.value).to.equal('42'); }); }); }); 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 8c4335990d..862200a5c8 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 @@ -39,6 +39,12 @@ const value: InputType = { }, }; +const name: InputType = { + control: { + type: 'text', + }, +}; + const required: InputType = { control: { type: 'boolean', @@ -86,6 +92,7 @@ const ariaLabel: InputType = { const defaultArgTypes: ArgTypes = { value, + name, required, disabled, 'allow-empty-selection': allowEmptySelection, @@ -97,6 +104,7 @@ const defaultArgTypes: ArgTypes = { const defaultArgs: Args = { value: 'Value two', + name: undefined, required: false, disabled: false, 'allow-empty-selection': false, 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 5f239ed78e..871ec403a1 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 @@ -2,31 +2,25 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { getNextElementIndex, isArrowKeyPressed } from '../../core/a11y.js'; import { SbbConnectedAbortController } from '../../core/controllers.js'; import { forceType, hostAttributes, slotState } from '../../core/decorators.js'; import { EventEmitter } from '../../core/eventing.js'; -import type { SbbHorizontalFrom, SbbOrientation, SbbStateChange } from '../../core/interfaces.js'; +import type { SbbHorizontalFrom, SbbOrientation } from '../../core/interfaces.js'; import { SbbDisabledMixin } from '../../core/mixins.js'; -import type { SbbRadioButtonStateChange, SbbRadioButtonSize } from '../common.js'; +import type { SbbRadioButtonSize } from '../common.js'; import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js'; import type { SbbRadioButtonElement } from '../radio-button.js'; import style from './radio-button-group.scss?lit&inline'; -export type SbbRadioButtonGroupEventDetail = { - value: any | null; - radioButton: SbbRadioButtonElement | SbbRadioButtonPanelElement; -}; +let nextId = 0; /** * It can be used as a container for one or more `sbb-radio-button`. * * @slot - Use the unnamed slot to add `sbb-radio-button` elements to the `sbb-radio-button-group`. * @slot error - Use this to provide a `sbb-form-error` to show an error message. - * @event {CustomEvent} didChange - Deprecated. Only used for React. Will probably be removed once React 19 is available. Emits whenever the `sbb-radio-group` value changes. - * @event {CustomEvent} change - Emits whenever the `sbb-radio-group` value changes. - * @event {CustomEvent} input - Emits whenever the `sbb-radio-group` value changes. + * @event {CustomEvent} didChange - Deprecated. Only used for React. Will probably be removed once React 19 is available. Emits whenever the `sbb-radio-group` value changes. */ export @customElement('sbb-radio-button-group') @@ -59,7 +53,28 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { /** * The value of the radio group. */ - @property() public accessor value: any | null; + @property() + public set value(val: any | null) { + this._fallbackValue = val; + if (!this._didLoad) { + return; + } + if (!val) { + this.radioButtons.forEach((r) => (r.checked = false)); + return; + } + const toCheck = this.radioButtons.find((r) => r.value === val); + if (toCheck) { + toCheck.checked = true; + } + } + public get value(): any | null { + return this.radioButtons.find((r) => r.checked && !r.disabled)?.value ?? this._fallbackValue; + } + /** + * Used to preserve the `value` in case the radios are not yet 'loaded' + */ + private _fallbackValue: any | null = null; /** * Size variant. @@ -78,6 +93,10 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { @property({ reflect: true }) public accessor orientation: SbbOrientation = 'horizontal'; + @forceType() + @property() + public accessor name: string = `sbb-radio-button-group-${++nextId}`; + /** * List of contained radio buttons. */ @@ -90,13 +109,6 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { ).filter((el) => el.closest?.('sbb-radio-button-group') === this); } - private get _enabledRadios(): (SbbRadioButtonElement | SbbRadioButtonPanelElement)[] | undefined { - if (!this.disabled) { - return this.radioButtons.filter((r) => !r.disabled); - } - } - - private _hasSelectionExpansionPanelElement: boolean = false; private _didLoad = false; private _abort = new SbbConnectedAbortController(this); @@ -104,62 +116,29 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { * Emits whenever the `sbb-radio-group` value changes. * @deprecated only used for React. Will probably be removed once React 19 is available. */ - private _didChange: EventEmitter = new EventEmitter( + private _didChange: EventEmitter = new EventEmitter( this, SbbRadioButtonGroupElement.events.didChange, ); - /** - * Emits whenever the `sbb-radio-group` value changes. - */ - private _change: EventEmitter = new EventEmitter( - this, - SbbRadioButtonGroupElement.events.change, - ); - - /** - * Emits whenever the `sbb-radio-group` value changes. - */ - private _input: EventEmitter = new EventEmitter( - this, - SbbRadioButtonGroupElement.events.input, - ); - public override connectedCallback(): void { super.connectedCallback(); const signal = this._abort.signal; - this.addEventListener( - 'stateChange', - (e: CustomEvent) => - this._onRadioButtonChange(e as CustomEvent), - { - signal, - passive: true, - }, - ); - 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._updateRadios(this.value); + + this.addEventListener('change', (e: Event) => this._onRadioChange(e), { + signal, + }); } public override willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); - if (changedProperties.has('value')) { - this._valueChanged(this.value); - } if (changedProperties.has('disabled')) { - for (const radio of this.radioButtons) { - radio.tabIndex = this._getRadioTabIndex(radio); - radio.requestUpdate?.('disabled'); - } - this._setFocusableRadio(); + this.radioButtons.forEach((r) => r.requestUpdate?.('disabled')); } if (changedProperties.has('required')) { this.radioButtons.forEach((r) => r.requestUpdate?.('required')); @@ -167,124 +146,58 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) { if (changedProperties.has('size')) { this.radioButtons.forEach((r) => r.requestUpdate?.('size')); } - } - - private _valueChanged(value: any | undefined): void { - for (const radio of this.radioButtons) { - radio.checked = radio.value === value; - radio.tabIndex = this._getRadioTabIndex(radio); + if (changedProperties.has('name')) { + this._updateRadiosName(); } - this._setFocusableRadio(); } - protected override firstUpdated(changedProperties: PropertyValues): void { + protected override async firstUpdated(changedProperties: PropertyValues): Promise { super.firstUpdated(changedProperties); - this._didLoad = true; - this._updateRadios(this.value); - } - public override disconnectedCallback(): void { - super.disconnectedCallback(); + await this.updateComplete; + this._updateRadioState(); } - private _onRadioButtonChange(event: CustomEvent): void { - event.stopPropagation(); + private _onRadioChange(event: Event): void { + const target = event.target! as SbbRadioButtonElement | SbbRadioButtonPanelElement; - if (!this._didLoad) { + // Only filter radio-buttons event + if (target.localName !== 'sbb-radio-button' && target.localName !== 'sbb-radio-button-panel') { return; } - if (event.detail.type === 'disabled') { - this._updateRadios(this.value); - } else if (event.detail.type === 'checked') { - const radioButton = event.target as SbbRadioButtonElement; - - if (event.detail.checked) { - this.value = radioButton.value; - this._emitChange(radioButton, this.value); - } else if (this.allowEmptySelection) { - this.value = this.radioButtons.find((radio) => radio.checked)?.value; - if (!this.value) { - this._emitChange(radioButton); - } - } - } - } - - private _emitChange(radioButton: SbbRadioButtonElement, value?: string): void { - this._change.emit({ value, radioButton }); - this._input.emit({ value, radioButton }); - this._didChange.emit({ value, radioButton }); - } - - private _updateRadios(initValue?: string): void { - if (!this._didLoad) { - return; - } - - const radioButtons = this.radioButtons; - - this.value = initValue ?? radioButtons.find((radio) => radio.checked)?.value ?? this.value; - - for (const radio of radioButtons) { - radio.checked = radio.value === this.value; - radio.tabIndex = this._getRadioTabIndex(radio); - } - - this._setFocusableRadio(); - } - - private _setFocusableRadio(): void { - const checked = this.radioButtons.find((radio) => radio.checked && !radio.disabled); - - const enabledRadios = this._enabledRadios; - if (!checked && enabledRadios?.length) { - enabledRadios[0].tabIndex = 0; - } + this._fallbackValue = null; // Since the user interacted, the fallbackValue logic does not apply anymore + this._didChange.emit(); } - private _getRadioTabIndex(radio: SbbRadioButtonElement | SbbRadioButtonPanelElement): number { - const isEnabled = !radio.disabled && !this.disabled; - - return (radio.checked || this._hasSelectionExpansionPanelElement) && isEnabled ? 0 : -1; + /** + * Proxy 'name' to child radio-buttons + */ + private _updateRadiosName(): void { + this.radioButtons.forEach((r) => (r.name = this.name)); } - private _handleKeyDown(evt: KeyboardEvent): void { - const enabledRadios = this._enabledRadios; - - if ( - !enabledRadios || - !enabledRadios.length || - // 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') - ) { - return; - } - - if (!isArrowKeyPressed(evt)) { - return; - } - - const current: number = enabledRadios.findIndex( - (e: SbbRadioButtonElement | SbbRadioButtonPanelElement) => e === evt.target, - ); - const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length); - - if (!this._hasSelectionExpansionPanelElement) { - enabledRadios[nextIndex].select(); + /** + * Re-trigger the setter and update the checked state of the radios. + * Mainly used to cover cases where the setter is called before the radios are loaded + */ + private _updateRadioState(): void { + if (this._fallbackValue) { + // eslint-disable-next-line no-self-assign + this.value = this.value; } - - enabledRadios[nextIndex].focus(); - evt.preventDefault(); } protected override render(): TemplateResult { return html`
- this._updateRadios()}> + { + this._updateRadiosName(); + this._updateRadioState(); + }} + >
diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md index 0f0d708890..c2aeeda198 100644 --- a/src/elements/radio-button/radio-button-group/readme.md +++ b/src/elements/radio-button/radio-button-group/readme.md @@ -1,10 +1,11 @@ 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. +Individual radio-buttons inside of a radio-group will inherit the `name` of the group. ```html - + Option one Option two Option three @@ -69,24 +70,23 @@ 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 \| null` | `null` | 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. | -| `value` | `value` | public | `any \| null` | | 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 \| null` | `null` | Overrides the behaviour of `orientation` property. | +| `name` | `name` | public | `string` | `` `sbb-radio-button-group-${++nextId}` `` | | +| `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. | +| `value` | `value` | public | `any \| null` | | The value of the radio group. | ## Events -| Name | Type | Description | Inherited From | -| ----------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `change` | `CustomEvent` | Emits whenever the `sbb-radio-group` value changes. | | -| `didChange` | `CustomEvent` | Deprecated. Only used for React. Will probably be removed once React 19 is available. Emits whenever the `sbb-radio-group` value changes. | | -| `input` | `CustomEvent` | Emits whenever the `sbb-radio-group` value changes. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `didChange` | `CustomEvent` | Deprecated. Only used for React. Will probably be removed once React 19 is available. Emits whenever the `sbb-radio-group` value changes. | | ## Slots 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 index 94bc56a5db..f5b0760c69 100644 --- 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 @@ -3,11 +3,10 @@ export const snapshots = {}; snapshots["sbb-radio-button-panel should render unchecked DOM"] = ` Label @@ -28,13 +27,6 @@ snapshots["sbb-radio-button-panel should render unchecked Shadow DOM"] =
- @@ -50,13 +42,12 @@ snapshots["sbb-radio-button-panel should render unchecked Shadow DOM"] = snapshots["sbb-radio-button-panel should render checked DOM"] = ` Label @@ -77,14 +68,6 @@ snapshots["sbb-radio-button-panel should render checked Shadow DOM"] =
- @@ -98,7 +81,7 @@ 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 Chrome"] = +snapshots["sbb-radio-button-panel should render unchecked A11y tree Chrome"] = `

{ "role": "WebArea", @@ -106,16 +89,32 @@ snapshots["sbb-radio-button-panel Unchecked - A11y tree Chrome"] = "children": [ { "role": "radio", - "name": "Label", + "name": "Label Suffix Subtext", "checked": false } ] }

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

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

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

{ "role": "WebArea", @@ -123,16 +122,16 @@ snapshots["sbb-radio-button-panel Checked - A11y tree Chrome"] = "children": [ { "role": "radio", - "name": "Label", + "name": "Label Suffix Subtext", "checked": true } ] }

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

{ "role": "document", @@ -140,15 +139,34 @@ snapshots["sbb-radio-button-panel Unchecked - A11y tree Firefox"] = "children": [ { "role": "radio", - "name": "Label" + "name": "Label Suffix Subtext", + "checked": true } ] }

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

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

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

{ "role": "document", @@ -157,11 +175,227 @@ snapshots["sbb-radio-button-panel Checked - A11y tree Firefox"] = { "role": "radio", "name": "Label", + "disabled": true + } + ] +} +

+`; +/* end snapshot sbb-radio-button-panel Disabled - A11y tree Firefox */ + +snapshots["sbb-radio-button-panel renders DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-radio-button-panel renders DOM */ + +snapshots["sbb-radio-button-panel renders Shadow DOM"] = +` +`; +/* end snapshot sbb-radio-button-panel renders Shadow DOM */ + +snapshots["sbb-radio-button-panel renders checked DOM"] = +` + Label + + Subtext + + + Suffix + + +`; +/* end snapshot sbb-radio-button-panel renders checked DOM */ + +snapshots["sbb-radio-button-panel renders checked Shadow DOM"] = +` +`; +/* end snapshot sbb-radio-button-panel renders checked Shadow DOM */ + +snapshots["sbb-radio-button-panel renders A11y tree Chrome"] = +`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+`; +/* end snapshot sbb-radio-button-panel renders required - A11y tree Firefox */ 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 index afd8ec850d..3b11770541 100644 --- 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 @@ -8,10 +8,10 @@ import { SbbRadioButtonPanelElement } from './radio-button-panel.js'; describe('sbb-radio-button-panel', () => { let element: SbbRadioButtonPanelElement; - describe('should render unchecked', async () => { + describe('renders', async () => { beforeEach(async () => { element = (await fixture( - html` + html` Label Subtext Suffix @@ -27,12 +27,14 @@ describe('sbb-radio-button-panel', () => { it('Shadow DOM', async () => { await expect(element).shadowDom.to.be.equalSnapshot(); }); + + testA11yTreeSnapshot(); }); - describe('should render checked', async () => { + describe('renders checked', async () => { beforeEach(async () => { element = await fixture( - html` + html` Label Subtext Suffix @@ -47,15 +49,20 @@ describe('sbb-radio-button-panel', () => { it('Shadow DOM', async () => { await expect(element).shadowDom.to.be.equalSnapshot(); }); + + testA11yTreeSnapshot(); }); testA11yTreeSnapshot( - html`Label`, - 'Unchecked - A11y tree', + html`Label`, + 'renders disabled - A11y tree', ); - testA11yTreeSnapshot( - html`Label`, - 'Checked - A11y tree', + html``, + 'renders required - 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 index ab9446623c..9020f899ca 100644 --- 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 @@ -30,7 +30,8 @@ describe(`sbb-radio-button`, () => { element.click(); await waitForLitRender(element); - expect(element).to.have.attribute('checked'); + expect(element.checked).to.be.true; + expect(element).to.have.attribute('data-checked'); await stateChange.calledOnce(); expect(stateChange.count).to.be.equal(1); }); @@ -40,13 +41,13 @@ describe(`sbb-radio-button`, () => { element.click(); await waitForLitRender(element); - expect(element).to.have.attribute('checked'); + expect(element.checked).to.be.true; await stateChange.calledOnce(); expect(stateChange.count).to.be.equal(1); element.click(); await waitForLitRender(element); - expect(element).to.have.attribute('checked'); + expect(element.checked).to.be.true; await stateChange.calledOnce(); expect(stateChange.count).to.be.equal(1); }); @@ -57,13 +58,13 @@ describe(`sbb-radio-button`, () => { element.allowEmptySelection = true; element.click(); await waitForLitRender(element); - expect(element).to.have.attribute('checked'); + expect(element.checked).to.be.true; await stateChange.calledOnce(); expect(stateChange.count).to.be.equal(1); element.click(); await waitForLitRender(element); - expect(element).not.to.have.attribute('checked'); + expect(element.checked).to.be.false; await stateChange.calledTimes(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 index 3cb06155fa..edaf29b1ec 100644 --- 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 @@ -6,18 +6,37 @@ import { ssrHydratedFixture } from '../../core/testing/private.js'; import { SbbRadioButtonPanelElement } from './radio-button-panel.js'; describe(`sbb-radio-button-panel ssr`, () => { - let root: SbbRadioButtonPanelElement; - - beforeEach(async () => { - root = await ssrHydratedFixture( + it('renders', async () => { + const root = await ssrHydratedFixture( html`Value label`, { modules: ['./radio-button-panel.js'], }, ); + assert.instanceOf(root, SbbRadioButtonPanelElement); }); - it('renders', () => { + it('renders checked', async () => { + const root = await ssrHydratedFixture( + html`Value label`, + { + modules: ['./radio-button-panel.js'], + }, + ); + assert.instanceOf(root, SbbRadioButtonPanelElement); + }); + + it('renders standalone group', async () => { + const root = await ssrHydratedFixture( + html` + Value 1 + Value 2 + Value 3 + `, + { + modules: ['./radio-button-panel.js'], + }, + ); 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 index 965a05747d..4860798f6b 100644 --- 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 @@ -1,12 +1,15 @@ +import { withActions } from '@storybook/addon-actions/decorator'; import type { InputType } from '@storybook/types'; -import type { Args, ArgTypes, Meta, StoryObj } from '@storybook/web-components'; -import { html, type TemplateResult } from 'lit'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; import { sbbSpread } from '../../../storybook/helpers/spread.js'; import readme from './readme.md?raw'; import '../../icon.js'; +import '../../title.js'; import '../../card/card-badge.js'; import '../radio-button-panel.js'; @@ -85,7 +88,7 @@ const defaultArgs: Args = { const cardBadge = (): TemplateResult => html`%`; const DefaultTemplate = ({ labelBoldClass, ...args }: Args): TemplateResult => - html`${labelBoldClass ? html`Label` : 'Label'} Subtext @@ -103,6 +106,30 @@ const DefaultTemplate = ({ labelBoldClass, ...args }: Args): TemplateResult => ${cardBadge()} `; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const StandaloneTemplate = ({ value, ...args }: Args): TemplateResult => html` +
+ Group 1 + ${repeat( + new Array(3), + (_, i) => + html`
+ ${DefaultTemplate({ ...args, value: `value-${i + 1}`, name: `group-1` })} +
`, + )} +
+ + Group 2 + ${repeat( + new Array(4), + (_, i) => + html`
+ ${DefaultTemplate({ ...args, value: `value-${i + 1}`, name: `group-2` })} +
`, + )} + +`; + export const Default: StoryObj = { render: DefaultTemplate, argTypes: defaultArgTypes, @@ -151,8 +178,18 @@ export const CheckedBold: StoryObj = { args: { ...defaultArgs, checked: true, labelBoldClass: true }, }; +export const StandaloneGroup: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + const meta: Meta = { parameters: { + decorators: [withActions as Decorator], + actions: { + handles: ['change', 'input'], + }, docs: { extractComponentDescription: () => readme, }, 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 index d4b8946a4a..a68c565acf 100644 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.ts +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.ts @@ -11,6 +11,7 @@ import { customElement, property } from 'lit/decorators.js'; import { getOverride, slotState } from '../../core/decorators.js'; import { panelCommonStyle, + type SbbFormAssociatedRadioButtonMixinType, SbbPanelMixin, type SbbPanelSize, SbbUpdateSchedulerMixin, @@ -27,6 +28,9 @@ import '../../screen-reader-only.js'; * @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). + * @event {Event} change - Fired on change. + * @event {InputEvent} input - Fired on input. + * @overrideType value - string | null */ export @customElement('sbb-radio-button-panel') @@ -39,6 +43,8 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin( // FIXME using ...super.events requires: https://github.com/sbb-design-systems/lyne-components/issues/2600 public static readonly events = { stateChange: 'stateChange', + change: 'change', + input: 'input', panelConnected: 'panelConnected', } as const; @@ -47,6 +53,13 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin( @getOverride((i, v) => (i.group?.size ? (i.group.size === 'xs' ? 's' : i.group.size) : v)) public accessor size: SbbPanelSize = 'm'; + private _hasSelectionExpansionPanelElement: boolean = false; + + public override connectedCallback(): void { + super.connectedCallback(); + this._hasSelectionExpansionPanelElement = !!this.closest?.('sbb-selection-expansion-panel'); + } + protected override async willUpdate(changedProperties: PropertyValues): Promise { super.willUpdate(changedProperties); @@ -55,6 +68,31 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin( } } + /** + * As an exception, panels with an expansion-panel attached are always focusable + */ + protected override updateFocusableRadios(): void { + super.updateFocusableRadios(); + const radios = Array.from(this.associatedRadioButtons ?? []) as SbbRadioButtonPanelElement[]; + + radios + .filter((r) => !r.disabled && r._hasSelectionExpansionPanelElement) + .forEach((r) => (r.tabIndex = 0)); + } + + /** + * As an exception, radio-panels with an expansion-panel attached are not checked automatically when navigating by keyboard + */ + protected override async navigateByKeyboard( + next: SbbFormAssociatedRadioButtonMixinType, + ): Promise { + if (!this._hasSelectionExpansionPanelElement) { + await super.navigateByKeyboard(next); + } else { + next.focus(); + } + } + protected override render(): TemplateResult { return html`