From c2839c7b1008bf96ed0d645008443963c19a213e Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 4 Oct 2024 14:54:15 +0200
Subject: [PATCH 01/30] feat(sbb-radio-button): form association - first
implementation
---
src/elements/core/mixins.ts | 1 +
.../core/mixins/form-associated-mixin.ts | 7 +-
.../form-associated-radio-button-mixin.ts | 222 ++++++++++++++++++
.../common/radio-button-common.scss | 4 +-
.../common/radio-button-common.ts | 105 +++------
5 files changed, 260 insertions(+), 79 deletions(-)
create mode 100644 src/elements/core/mixins/form-associated-radio-button-mixin.ts
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 7ede1464f7..04f9d92842 100644
--- a/src/elements/core/mixins/form-associated-mixin.ts
+++ b/src/elements/core/mixins/form-associated-mixin.ts
@@ -122,9 +122,12 @@ export const SbbFormAssociatedMixin = >(
old: string | null,
value: string | null,
): void {
- if (name !== 'name' || old !== value) {
- super.attributeChangedCallback(name, old, value);
+ // For the 'name' changes we have to handle updates manually
+ if (name === 'name') {
+ this.requestUpdate(name, old);
+ return;
}
+ super.attributeChangedCallback(name, old, value);
}
/**
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..10c80a5974
--- /dev/null
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -0,0 +1,222 @@
+import type { LitElement, PropertyValues } from 'lit';
+import { property } from 'lit/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';
+
+export declare class SbbFormAssociatedRadioButtonMixinType
+ extends SbbFormAssociatedMixinType
+ implements Partial, Partial
+{
+ public checked: boolean;
+ public disabled: boolean;
+ public required: boolean;
+
+ public formResetCallback(): void;
+ public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void;
+
+ protected isDisabledExternally(): boolean;
+ protected isRequiredExternally(): boolean;
+ protected withUserInteraction?(): void;
+}
+
+/**
+ * TODO add docs (maybe move to new file)
+ * @internal
+ */
+export class RadioButtonRegistry {
+ private static _registry: { [x: string]: SbbFormAssociatedRadioButtonMixinType[] } = {};
+
+ private constructor() {}
+
+ public static addRadioToGroup(
+ radio: SbbFormAssociatedRadioButtonMixinType,
+ groupName: string,
+ ): void {
+ if (!this._registry[groupName]) {
+ this._registry[groupName] = [];
+ }
+ this._registry[groupName].push(radio);
+ }
+
+ public static removeRadioFromGroup(
+ radio: SbbFormAssociatedRadioButtonMixinType,
+ groupName: string,
+ ): void {
+ this._registry[groupName].splice(this._registry[groupName].indexOf(radio), 1);
+ }
+
+ public static getRadios(groupName: string): SbbFormAssociatedRadioButtonMixinType[] {
+ return this._registry[groupName];
+ }
+}
+
+/**
+ * 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.
+ */
+ @property({ type: Boolean })
+ public set checked(value: boolean) {
+ this._checked = value;
+ }
+ public get checked(): boolean {
+ return this._checked;
+ }
+ private _checked: boolean = false;
+
+ private _initialized: boolean = false;
+
+ protected constructor() {
+ super();
+ /** @internal */
+ this.internals.role = 'radio';
+ }
+
+ public override connectedCallback(): void {
+ super.connectedCallback();
+ this._connectToRegistry();
+ }
+
+ 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”.
+ * In the case of “restore”, state is a string, File, or FormData object
+ * previously set as the second argument to setFormValue.
+ *
+ * @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);
+
+ if (changedProperties.has('disabled')) {
+ // this.internals.ariaDisabled = this.disabled.toString(); // TODO probably not needed
+ }
+ if (changedProperties.has('required')) {
+ this.internals.ariaRequired = this.required.toString();
+ }
+
+ // On 'name' change, move 'this' to the new registry
+ if (changedProperties.has('name') && this._initialized) {
+ const oldName = changedProperties.get('name')!;
+ this._disconnectFromRegistry(oldName);
+ this._connectToRegistry();
+ if (this.checked) {
+ this._deselectGroupedRadios();
+ }
+ }
+
+ if (changedProperties.has('checked')) {
+ this.toggleAttribute('data-checked', this.checked);
+ this.internals.ariaChecked = this.checked.toString();
+ this.updateFormValueOnCheckedChange();
+ if (this.checked) {
+ this._deselectGroupedRadios();
+ }
+ }
+ }
+
+ protected override firstUpdated(changedProperties: PropertyValues): void {
+ super.firstUpdated(changedProperties);
+ this._initialized = true;
+ }
+
+ /**
+ * Called on `value` change
+ * If I'm checked, update the value. Otherwise, do nothing.
+ */
+ protected override updateFormValue(): void {
+ if (this.checked) {
+ this.internals.setFormValue(this.value);
+ }
+ }
+
+ /**
+ * Called on `checked` change
+ * If I'm checked, set the value. Otherwise, reset it.
+ */
+ protected updateFormValueOnCheckedChange(): void {
+ this.internals.setFormValue(this.checked ? this.value : null);
+ }
+
+ /**
+ * Add `this` to the radioButton registry
+ */
+ private _connectToRegistry(name = this.name): void {
+ if (!name) {
+ return;
+ }
+ RadioButtonRegistry.addRadioToGroup(
+ this as unknown as SbbFormAssociatedRadioButtonMixinType,
+ name,
+ );
+ }
+
+ /**
+ * Remove `this` from the radioButton registry
+ */
+ private _disconnectFromRegistry(name = this.name): void {
+ if (!name) {
+ return;
+ }
+ RadioButtonRegistry.removeRadioFromGroup(
+ this as unknown as SbbFormAssociatedRadioButtonMixinType,
+ name,
+ );
+ }
+
+ /**
+ * Deselect other radio of the same group
+ */
+ private _deselectGroupedRadios(): void {
+ RadioButtonRegistry.getRadios(this.name)
+ .filter((r) => r !== (this as unknown as SbbFormAssociatedRadioButtonMixinType))
+ .forEach((r) => (r.checked = false));
+ }
+
+ // TODO Keyboard interaction
+ // Suggestion: query for (this.form ?? document).queryAll('radio-button[name="this.name"]') and cycle through them
+ }
+
+ 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 12ff6e68d2..8d5c017fb0 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.ts b/src/elements/radio-button/common/radio-button-common.ts
index ac4dcf2bc3..e21e476d13 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -2,15 +2,18 @@ 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 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,29 +23,19 @@ 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 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 = {
@@ -61,35 +54,6 @@ export const SbbRadioButtonCommonElementMixin = ): 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}`);
- }
+ }
+
+ protected override isDisabledExternally(): boolean {
+ return this.group?.disabled ?? false;
+ }
+
+ protected override isRequiredExternally(): boolean {
+ return this.group?.required ?? false;
}
private _handleClick(event: Event): void {
From 283bfc8c1a2d1e6276a48ebe866d13186f0c1c4f Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 7 Oct 2024 16:25:51 +0200
Subject: [PATCH 02/30] feat(sbb-radio-button): implement standalone focus and
keyboard handling
---
.../core/mixins/form-associated-mixin.ts | 2 +-
.../form-associated-radio-button-mixin.ts | 97 ++++++++++++++++---
.../common/radio-button-common.ts | 4 +-
3 files changed, 86 insertions(+), 17 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-mixin.ts b/src/elements/core/mixins/form-associated-mixin.ts
index 04f9d92842..3712d90816 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 { Constructor } 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
index 10c80a5974..a1b12da58f 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -1,6 +1,9 @@
import type { LitElement, PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
+import { getNextElementIndex, isArrowKeyPressed } from '../a11y.js';
+import { SbbConnectedAbortController } from '../controllers.js';
+
import type { Constructor } from './constructor.js';
import { SbbDisabledMixin, type SbbDisabledMixinType } from './disabled-mixin.js';
import {
@@ -19,6 +22,8 @@ export declare class SbbFormAssociatedRadioButtonMixinType
public disabled: boolean;
public required: boolean;
+ protected abort: SbbConnectedAbortController;
+
public formResetCallback(): void;
public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void;
@@ -43,6 +48,10 @@ export class RadioButtonRegistry {
if (!this._registry[groupName]) {
this._registry[groupName] = [];
}
+ // Check for duplicates
+ if (this._registry[groupName].indexOf(radio) !== -1) {
+ return;
+ }
this._registry[groupName].push(radio);
}
@@ -50,7 +59,15 @@ export class RadioButtonRegistry {
radio: SbbFormAssociatedRadioButtonMixinType,
groupName: string,
): void {
- this._registry[groupName].splice(this._registry[groupName].indexOf(radio), 1);
+ const index = this._registry[groupName]?.indexOf(radio);
+ if (!this._registry[groupName] || index === -1) {
+ return;
+ }
+ this._registry[groupName].splice(index, 1);
+
+ if (this._registry[groupName].length === 0) {
+ delete this._registry[groupName];
+ }
}
public static getRadios(groupName: string): SbbFormAssociatedRadioButtonMixinType[] {
@@ -81,7 +98,7 @@ export const SbbFormAssociatedRadioButtonMixin = this._handleArrowKeyDown(e), { signal });
}
public override disconnectedCallback(): void {
@@ -111,9 +131,6 @@ export const SbbFormAssociatedRadioButtonMixin = ): void {
- super.firstUpdated(changedProperties);
- this._initialized = true;
- }
-
/**
* Called on `value` change
* If I'm checked, update the value. Otherwise, do nothing.
@@ -213,8 +228,64 @@ export const SbbFormAssociatedRadioButtonMixin = (r.checked = false));
}
- // TODO Keyboard interaction
- // Suggestion: query for (this.form ?? document).queryAll('radio-button[name="this.name"]') and cycle through them
+ /**
+ * Return the grouped radios in DOM order
+ */
+ private _orderedGrouperRadios(groupName = this.name): SbbFormAssociatedRadioButtonElement[] {
+ return Array.from(
+ (this.form ?? document).querySelectorAll(
+ `:is(sbb-radio-button, sbb-radio-button-panel)[name="${groupName}"]`,
+ ),
+ );
+ }
+
+ /**
+ * The focusable radio is the checked one or the first in DOM order
+ * TODO handle radio-button panel exception (they are always focusable? check _hasSelectionExpansionPanelElement in the group)
+ */
+ private _setFocusableRadio(): void {
+ const radios = this._orderedGrouperRadios();
+ const checkedIndex = radios.findIndex(
+ (radio) => radio.checked && !radio.disabled && !radio.formDisabled,
+ );
+
+ radios.forEach((r) => r.removeAttribute('tabindex'));
+ radios[checkedIndex !== -1 ? checkedIndex : 0].tabIndex = 0;
+ }
+
+ private async _handleArrowKeyDown(evt: KeyboardEvent): Promise {
+ if (!isArrowKeyPressed(evt)) {
+ return;
+ }
+
+ const enabledRadios = this._orderedGrouperRadios().filter(
+ (r) => !r.disabled && !r.formDisabled,
+ );
+ const current: number = enabledRadios.indexOf(this);
+ const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length);
+
+ // 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;
+ // }
+
+ // TODO
+ // if (!this._hasSelectionExpansionPanelElement) {
+ // enabledRadios[nextIndex].checked = true;
+ // }
+
+ enabledRadios[nextIndex].checked = true;
+ evt.preventDefault();
+
+ await enabledRadios[nextIndex].updateComplete;
+ enabledRadios[nextIndex].focus();
+ }
}
return SbbFormAssociatedRadioButtonElement as unknown as Constructor &
diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts
index e21e476d13..36660f5948 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -1,7 +1,6 @@
import type { LitElement, PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
-import { SbbConnectedAbortController } from '../../core/controllers.js';
import { EventEmitter, HandlerRepository, formElementHandlerAspect } from '../../core/eventing.js';
import type {
SbbCheckedStateChange,
@@ -62,7 +61,6 @@ export const SbbRadioButtonCommonElementMixin = this._handleClick(e), { signal });
this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal });
this._handlerRepository.connect();
From fa7d7f421442072b51cb699169aa17222a25c4a2 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 9 Oct 2024 12:01:11 +0200
Subject: [PATCH 03/30] feat(sbb-radio-button): remove native input
---
.../radio-button-panel/radio-button-panel.ts | 28 ++++++++++++-------
.../radio-button/radio-button/radio-button.ts | 12 +-------
2 files changed, 19 insertions(+), 21 deletions(-)
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 e5cf286c57..136d6d7101 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 { slotState } from '../../core/decorators.js';
import {
panelCommonStyle,
+ RadioButtonRegistry,
SbbPanelMixin,
type SbbPanelSize,
SbbUpdateSchedulerMixin,
@@ -53,6 +54,13 @@ export class SbbRadioButtonPanelElement extends SbbPanelMixin(
}
private _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);
@@ -61,6 +69,16 @@ export class SbbRadioButtonPanelElement extends SbbPanelMixin(
}
}
+ /**
+ * As an exception, panels with an expansion-panel attached are always focusable
+ */
+ protected override updateFocusableRadios(): void {
+ super.updateFocusableRadios();
+ const radios = RadioButtonRegistry.getRadios(this.name) as SbbRadioButtonPanelElement[];
+
+ radios.filter((r) => r._hasSelectionExpansionPanelElement).forEach((r) => (r.tabIndex = 0));
+ }
+
protected override render(): TemplateResult {
return html`
+`;
+/* 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 245e2d91b8..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
@@ -55,6 +55,14 @@ describe('sbb-radio-button-panel', () => {
testA11yTreeSnapshot(
html`Label`,
- 'Disabled - A11y tree',
+ 'renders disabled - A11y tree',
+ );
+ testA11yTreeSnapshot(
+ 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 f502726e1f..e9d181717c 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 waitForCondition(() => stateChange.events.length === 1);
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 waitForCondition(() => stateChange.events.length === 1);
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 waitForCondition(() => stateChange.events.length === 1);
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 waitForCondition(() => stateChange.events.length === 1);
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 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.stories.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.stories.ts
index f3c6920f4a..ac0df7fe36 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
@@ -178,6 +178,9 @@ export const StandaloneGroup: StoryObj = {
const meta: Meta = {
parameters: {
+ actions: {
+ handles: ['change', 'input'],
+ },
docs: {
extractComponentDescription: () => readme,
},
diff --git a/src/elements/radio-button/radio-button/__snapshots__/radio-button.snapshot.spec.snap.js b/src/elements/radio-button/radio-button/__snapshots__/radio-button.snapshot.spec.snap.js
index b5d7c10df2..8dd5821472 100644
--- a/src/elements/radio-button/radio-button/__snapshots__/radio-button.snapshot.spec.snap.js
+++ b/src/elements/radio-button/radio-button/__snapshots__/radio-button.snapshot.spec.snap.js
@@ -157,3 +157,37 @@ snapshots["sbb-radio-button native - A11y tree Firefox"] =
`;
/* end snapshot sbb-radio-button native - A11y tree Firefox */
+snapshots["sbb-radio-button renders required - A11y tree Chrome"] =
+`
+ {
+ "role": "WebArea",
+ "name": "",
+ "children": [
+ {
+ "role": "radio",
+ "name": "",
+ "checked": false
+ }
+ ]
+}
+
+`;
+/* end snapshot sbb-radio-button renders required - A11y tree Chrome */
+
+snapshots["sbb-radio-button renders required - A11y tree Firefox"] =
+`
+ {
+ "role": "document",
+ "name": "",
+ "children": [
+ {
+ "role": "radio",
+ "name": "",
+ "required": true
+ }
+ ]
+}
+
+`;
+/* end snapshot sbb-radio-button renders required - A11y tree Firefox */
+
diff --git a/src/elements/radio-button/radio-button/radio-button.snapshot.spec.ts b/src/elements/radio-button/radio-button/radio-button.snapshot.spec.ts
index 0578c58d6e..c4135d1ee4 100644
--- a/src/elements/radio-button/radio-button/radio-button.snapshot.spec.ts
+++ b/src/elements/radio-button/radio-button/radio-button.snapshot.spec.ts
@@ -36,4 +36,8 @@ describe(`sbb-radio-button`, () => {
html``,
'renders disabled - A11y tree',
);
+ testA11yTreeSnapshot(
+ html``,
+ 'renders required - A11y tree',
+ );
});
diff --git a/src/elements/radio-button/radio-button/radio-button.spec.ts b/src/elements/radio-button/radio-button/radio-button.spec.ts
index 6a7e47b9e6..0a871974e3 100644
--- a/src/elements/radio-button/radio-button/radio-button.spec.ts
+++ b/src/elements/radio-button/radio-button/radio-button.spec.ts
@@ -11,34 +11,14 @@ describe(`sbb-radio-button`, () => {
describe('general', () => {
beforeEach(async () => {
- element = await fixture(html`Value label`);
+ element = await fixture(
+ html`Value label`,
+ );
});
it('renders', async () => {
assert.instanceOf(element, SbbRadioButtonElement);
- });
-
- it('should have corresponding aria values set', async () => {
- expect(element).to.have.attribute('aria-checked', 'false');
- expect(element).to.have.attribute('aria-required', 'false');
- expect(element).not.to.have.attribute('aria-disabled');
- });
-
- it('should update aria values', async () => {
- element.checked = true;
- element.required = true;
- element.disabled = true;
-
- await waitForLitRender(element);
-
- expect(element).to.have.attribute('aria-checked', 'true');
- expect(element).to.have.attribute('aria-required', 'true');
- expect(element).to.have.attribute('aria-disabled', 'true');
- });
-
- 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;
+ expect(element.tabIndex).to.be.equal(0);
});
it('selects radio on click', async () => {
@@ -47,7 +27,8 @@ describe(`sbb-radio-button`, () => {
element.click();
await waitForLitRender(element);
- expect(element).to.have.attribute('checked');
+ expect(element).to.have.attribute('data-checked');
+ expect(element.checked).to.be.true;
await waitForCondition(() => stateChange.events.length === 1);
expect(stateChange.count).to.be.equal(1);
});
@@ -57,13 +38,13 @@ describe(`sbb-radio-button`, () => {
element.click();
await waitForLitRender(element);
- expect(element).to.have.attribute('checked');
+ expect(element.checked).to.be.true;
await waitForCondition(() => stateChange.events.length === 1);
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 waitForCondition(() => stateChange.events.length === 1);
expect(stateChange.count).to.be.equal(1);
});
@@ -74,121 +55,47 @@ 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 waitForCondition(() => stateChange.events.length === 1);
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 waitForCondition(() => stateChange.events.length === 2);
expect(stateChange.count).to.be.equal(2);
});
- it('should convert falsy checked to false', async () => {
- element.checked = true;
- (element.checked as any) = undefined;
-
- await waitForLitRender(element);
-
- expect(element.checked).to.equal(false);
- expect(element).to.have.attribute('aria-checked', 'false');
- });
-
- it('should convert truthy checked to true', async () => {
- element.checked = true;
- (element.checked as any) = 2;
-
- await waitForLitRender(element);
-
- expect(element.checked).to.equal(true);
- expect(element).to.have.attribute('aria-checked', 'true');
- });
-
- it('should convert falsy disabled to false', async () => {
- element.disabled = true;
- (element.disabled as any) = undefined;
-
- await waitForLitRender(element);
-
- expect(element.disabled).to.equal(false);
- expect(element).not.to.have.attribute('aria-disabled');
- });
-
- it('should convert truthy disabled to true', async () => {
- element.disabled = true;
- (element.disabled as any) = 2;
-
- await waitForLitRender(element);
-
- expect(element.disabled).to.equal(true);
- expect(element).to.have.attribute('aria-disabled', 'true');
- });
-
- it('should convert falsy required to false', async () => {
- element.required = true;
- (element.required as any) = undefined;
-
- await waitForLitRender(element);
-
- expect(element.required).to.equal(false);
- expect(element).to.have.attribute('aria-required', 'false');
- });
-
- it('should convert truthy required to true', async () => {
- element.required = true;
- (element.required as any) = 2;
-
- await waitForLitRender(element);
-
- expect(element.required).to.equal(true);
- expect(element).to.have.attribute('aria-required', 'true');
- });
-
- it('should convert falsy allowEmptySelection to false', async () => {
- element.allowEmptySelection = true;
- (element.allowEmptySelection as any) = undefined;
-
- await waitForLitRender(element);
-
- expect(element.allowEmptySelection).to.equal(false);
- });
-
- it('should convert truthy allowEmptySelection to true', async () => {
- element.allowEmptySelection = true;
- (element.allowEmptySelection as any) = 2;
+ it('should convert falsy to false', async () => {
+ element.checked = element.disabled = element.required = element.allowEmptySelection = true;
+ (element.checked as any) =
+ (element.disabled as any) =
+ (element.required as any) =
+ (element.allowEmptySelection as any) =
+ undefined;
await waitForLitRender(element);
- expect(element.allowEmptySelection).to.equal(true);
- });
- });
-
- describe('with initial attributes', () => {
- beforeEach(async () => {
- element = await fixture(
- html`
- Value label
- `,
- );
- });
-
- it('should have corresponding aria values set', async () => {
- expect(element).to.have.attribute('aria-checked', 'true');
- expect(element).to.have.attribute('aria-required', 'true');
- expect(element).to.have.attribute('aria-disabled', 'true');
+ expect(element.checked, 'checked').to.be.false;
+ expect(element.disabled, 'disabled').to.be.false;
+ expect(element.required, 'required').to.be.false;
+ expect(element.allowEmptySelection, 'allowEmptySelection').to.be.false;
});
- it('should update aria values', async () => {
- element.checked = false;
- element.required = false;
- element.disabled = false;
+ it('should convert truthy to true', async () => {
+ element.checked = element.disabled = element.required = element.allowEmptySelection = true;
+ (element.checked as any) =
+ (element.disabled as any) =
+ (element.required as any) =
+ (element.allowEmptySelection as any) =
+ 2;
await waitForLitRender(element);
- expect(element).to.have.attribute('aria-checked', 'false');
- expect(element).to.have.attribute('aria-required', 'false');
- expect(element).not.to.have.attribute('aria-disabled');
+ expect(element.checked, 'checked').to.be.true;
+ expect(element.disabled, 'disabled').to.be.true;
+ expect(element.required, 'required').to.be.true;
+ expect(element.allowEmptySelection, 'allowEmptySelection').to.be.true;
});
});
});
diff --git a/src/elements/radio-button/radio-button/radio-button.stories.ts b/src/elements/radio-button/radio-button/radio-button.stories.ts
index 7369bff384..93840154f4 100644
--- a/src/elements/radio-button/radio-button/radio-button.stories.ts
+++ b/src/elements/radio-button/radio-button/radio-button.stories.ts
@@ -174,6 +174,9 @@ export const StandaloneGroup: StoryObj = {
const meta: Meta = {
parameters: {
+ actions: {
+ handles: ['change', 'input'],
+ },
docs: {
extractComponentDescription: () => readme,
},
From d91015d5a193c832b35e41e45ebba7731a466abc Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 14 Oct 2024 14:57:34 +0200
Subject: [PATCH 08/30] docs(sbb-radio-button, sbb-radio-button-panel): update
docs
---
.../radio-button/radio-button-group/readme.md | 21 +++++++------
.../radio-button/radio-button-panel/readme.md | 31 ++++++++++++-------
.../radio-button/radio-button/readme.md | 27 ++++++++++------
3 files changed, 49 insertions(+), 30 deletions(-)
diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md
index 6e9ce2aa9d..e9d5d2aa50 100644
--- a/src/elements/radio-button/radio-button-group/readme.md
+++ b/src/elements/radio-button/radio-button-group/readme.md
@@ -69,16 +69,17 @@ 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. |
-| `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. |
+| `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
diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md
index f6adba7bd3..26b91ef59e 100644
--- a/src/elements/radio-button/radio-button-panel/readme.md
+++ b/src/elements/radio-button/radio-button-panel/readme.md
@@ -55,17 +55,19 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
## 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 | `SbbPanelSize` | `'m'` | Size variant. |
-| `value` | `value` | public | `string \| undefined` | | Value of radio button. |
+| 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 component is disabled. |
+| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. |
+| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
+| `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 | `SbbPanelSize` | `'m'` | Size variant. |
+| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
## Methods
@@ -73,6 +75,13 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
| -------- | ------- | ----------- | ---------- | ------ | -------------------------------- |
| `select` | public | | | `void` | SbbRadioButtonCommonElementMixin |
+## Events
+
+| Name | Type | Description | Inherited From |
+| -------- | ------------ | ----------- | --------------------------------- |
+| `change` | `Event` | | SbbFormAssociatedRadioButtonMixin |
+| `input` | `InputEvent` | | SbbFormAssociatedRadioButtonMixin |
+
## Slots
| Name | Description |
diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md
index b9f6539792..e88f1cfc66 100644
--- a/src/elements/radio-button/radio-button/readme.md
+++ b/src/elements/radio-button/radio-button/readme.md
@@ -51,15 +51,17 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
## Properties
-| Name | Attribute | Privacy | Type | Default | Description |
-| --------------------- | ----------------------- | ------- | ------------------------------------ | ------- | ---------------------------------------------- |
-| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. |
-| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. |
-| `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'` | Size variant. |
-| `value` | `value` | public | `string \| undefined` | | Value of radio button. |
+| Name | Attribute | Privacy | Type | Default | Description |
+| --------------------- | ----------------------- | ------- | ------------------------------------ | ------- | ----------------------------------------------------------- |
+| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. |
+| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. |
+| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. |
+| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. |
+| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
+| `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 | `SbbRadioButtonSize` | `'m'` | Size variant. |
+| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
## Methods
@@ -67,6 +69,13 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
| -------- | ------- | ----------- | ---------- | ------ | -------------------------------- |
| `select` | public | | | `void` | SbbRadioButtonCommonElementMixin |
+## Events
+
+| Name | Type | Description | Inherited From |
+| -------- | ------------ | ----------- | --------------------------------- |
+| `change` | `Event` | | SbbFormAssociatedRadioButtonMixin |
+| `input` | `InputEvent` | | SbbFormAssociatedRadioButtonMixin |
+
## Slots
| Name | Description |
From eff70da7f3185c8027dce3b479a82f85471f06fc Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 14 Oct 2024 16:58:07 +0200
Subject: [PATCH 09/30] docs(sbb-radio-button, sbb-radio-button-panel): update
storybook
---
.../radio-button-panel/radio-button-panel.stories.ts | 4 +++-
.../radio-button/radio-button/radio-button.stories.ts | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
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 ac0df7fe36..8a1f7db016 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,5 +1,6 @@
+import { withActions } from '@storybook/addon-actions/decorator';
import type { InputType } from '@storybook/types';
-import type { Args, ArgTypes, Meta, StoryObj } from '@storybook/web-components';
+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';
@@ -178,6 +179,7 @@ export const StandaloneGroup: StoryObj = {
const meta: Meta = {
parameters: {
+ decorators: [withActions as Decorator],
actions: {
handles: ['change', 'input'],
},
diff --git a/src/elements/radio-button/radio-button/radio-button.stories.ts b/src/elements/radio-button/radio-button/radio-button.stories.ts
index 93840154f4..8bc665a4e8 100644
--- a/src/elements/radio-button/radio-button/radio-button.stories.ts
+++ b/src/elements/radio-button/radio-button/radio-button.stories.ts
@@ -1,5 +1,6 @@
+import { withActions } from '@storybook/addon-actions/decorator';
import type { InputType } from '@storybook/types';
-import type { Meta, StoryObj, ArgTypes, Args } 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 { repeat } from 'lit/directives/repeat.js';
@@ -173,6 +174,7 @@ export const StandaloneGroup: StoryObj = {
};
const meta: Meta = {
+ decorators: [withActions as Decorator],
parameters: {
actions: {
handles: ['change', 'input'],
From cb0572f5d9f25a2c1deac6cb47785375502039d8 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 14 Oct 2024 17:09:06 +0200
Subject: [PATCH 10/30] feat(sbb-radio-button, sbb-radio-button-panel): add
'change' and 'input' events
---
.../radio-button/common/radio-button-common.ts | 10 ++++------
.../radio-button-panel/radio-button-panel.ts | 4 ++++
src/elements/radio-button/radio-button-panel/readme.md | 8 ++++----
src/elements/radio-button/radio-button/radio-button.ts | 4 ++++
src/elements/radio-button/radio-button/readme.md | 8 ++++----
5 files changed, 20 insertions(+), 14 deletions(-)
diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts
index 743558d919..9131b98f08 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -39,6 +39,8 @@ export const SbbRadioButtonCommonElementMixin =
{
public static readonly events = {
+ change: 'change',
+ input: 'input',
stateChange: 'stateChange',
} as const;
@@ -92,8 +94,10 @@ export const SbbRadioButtonCommonElementMixin =
Date: Mon, 14 Oct 2024 17:09:28 +0200
Subject: [PATCH 11/30] feat(sbb-radio-button-group): add 'change' and 'input'
events
---
.../radio-button-group/radio-button-group.ts | 59 ++++++++-----------
1 file changed, 26 insertions(+), 33 deletions(-)
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 de04174998..0e63f83900 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
@@ -5,9 +5,9 @@ import { customElement, property } from 'lit/decorators.js';
import { SbbConnectedAbortController } from '../../core/controllers.js';
import { 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';
@@ -136,19 +136,17 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
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.toggleAttribute(
'data-has-panel',
!!this.querySelector?.('sbb-selection-expansion-panel, sbb-radio-button-panel'),
);
+
+ this.addEventListener('input', (e: Event) => this._onRadioChange('input', e), {
+ signal,
+ });
+ this.addEventListener('change', (e: Event) => this._onRadioChange('change', e), {
+ signal,
+ });
}
public override willUpdate(changedProperties: PropertyValues): void {
@@ -178,35 +176,30 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
}
}
- private _onRadioButtonChange(event: CustomEvent): void {
- event.stopPropagation();
+ /**
+ * Blocks native 'input' and 'change' events fired by the radio-buttons to fire an enriched version of them.
+ * Made to maintain retro compatibility.
+ */
+ private _onRadioChange(eventName: 'change' | 'input', event: Event): void {
+ const target = event.target! as HTMLElement;
- if (!this._didLoad) {
+ // Only filter radio-buttons event
+ if (target.localName !== 'sbb-radio-button' && target.localName !== 'sbb-radio-button-group') {
return;
}
- if (event.detail.type === 'checked') {
- const radioButton = event.target as SbbRadioButtonElement;
-
- // TODO
- 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);
- }
- }
+ event.stopPropagation();
+ if (eventName === 'change') {
+ this._change.emit({ value: this.value, target: event.target });
+ this._didChange.emit({ value: this.value, target: event.target });
+ } else if (eventName === 'input') {
+ this._input.emit({ value: this.value, target: event.target });
}
}
- private _emitChange(radioButton: SbbRadioButtonElement, value?: string): void {
- this._change.emit({ value, radioButton });
- this._input.emit({ value, radioButton });
- this._didChange.emit({ value, radioButton });
- }
-
+ /**
+ * Proxy 'name' to child radio-buttons
+ */
private _updateRadiosName(): void {
this.radioButtons.forEach((r) => (r.name = this.name));
}
From e93c6dff283ff1f7222494487b18941c3fb320f4 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 12:13:49 +0200
Subject: [PATCH 12/30] test(sbb-radio-button-group): add spec tests
---
.../form-associated-radio-button-mixin.ts | 4 +
.../common/radio-button-common.spec.ts | 16 +-
.../common/radio-button-common.ts | 4 +
.../radio-button-group.spec.ts | 301 ++++++++----------
.../radio-button-group.stories.ts | 8 +
.../radio-button-group/radio-button-group.ts | 17 +-
6 files changed, 173 insertions(+), 177 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 1780720ad7..0135a7598a 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -167,6 +167,10 @@ export const SbbFormAssociatedRadioButtonMixin = {
await sendKeys({ press: 'ArrowLeft' });
await sendKeys({ press: 'ArrowUp' });
- await compareToNative();
+ // On webkit, native radios do not wrap
+ if (!isWebkit) {
+ await compareToNative();
+ }
});
it('should skip disabled elements on arrow keys', async () => {
@@ -475,6 +478,15 @@ describe(`radio-button common behaviors`, () => {
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);
+ });
});
});
});
diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts
index 9131b98f08..8fa320a4ef 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -87,6 +87,10 @@ export const SbbRadioButtonCommonElementMixin = this.requestUpdate(p));
}
+ /**
+ * Set the radio-button as 'checked'. If 'allowEmptySelection', toggle the checked property.
+ * Emits events
+ */
public select(): void {
if (this.disabled || this.formDisabled) {
return;
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 a1e2899478..23177e75b7 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,235 +6,199 @@ import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing
import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js';
import type { SbbRadioButtonElement } from '../radio-button.js';
+import {
+ SbbRadioButtonGroupElement,
+ type SbbRadioButtonGroupEventDetail,
+} 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}>
- <${tagSingle} id="sbb-radio-2" value="Value two">Value two${tagSingle}>
- <${tagSingle} id="sbb-radio-3" value="Value three" disabled
- >Value three${tagSingle}
- >
- <${tagSingle} id="sbb-radio-4" value="Value four">Value four${tagSingle}>
-
- `,
- );
+ element = await fixture(html`
+
+ <${tagSingle} id="sbb-radio-1" value="Value one">Value one${tagSingle}>
+ <${tagSingle} id="sbb-radio-2" value="Value two">Value two${tagSingle}>
+ <${tagSingle} id="sbb-radio-3" value="Value three" disabled>Value three${tagSingle}>
+
+ `);
+ 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 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);
+ 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 waitForCondition(() => changeSpy.events.length === 1);
+ await waitForCondition(() => inputSpy.events.length === 1);
- await sendKeys({ press: 'ArrowLeft' });
- await waitForLitRender(element);
+ const changeEvent = changeSpy.lastEvent as CustomEvent;
+ expect(changeSpy.count).to.be.equal(1);
+ expect(changeEvent.detail.value).to.be.equal(radio.value);
+ expect(changeEvent.detail.radioButton === radio).to.be.true;
- const radio = element.querySelector('#sbb-radio-4');
- expect(radio).to.have.attribute('checked');
+ const inputEvent = changeSpy.lastEvent as CustomEvent;
+ expect(inputSpy.count).to.be.equal(1);
+ expect(inputEvent.detail.value).to.be.equal(radio.value);
+ expect(inputEvent.detail.radioButton === 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}>
+ <${tagSingle} id="sbb-radio-2" value="Value two">Value two${tagSingle}>
+
+ `);
+ 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', () => {
+ // TODO Discuss the 'value' preservation
+ describe.skip('initialization', () => {
beforeEach(async () => {
element = await fixture(html`
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 0e63f83900..3825144d9c 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
@@ -66,10 +66,15 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
this.radioButtons.forEach((r) => (r.checked = false));
return;
}
- this.radioButtons.find((r) => r.value === val)?.select();
+ 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;
+ return (
+ this.radioButtons.find((r) => r.checked && !r.disabled)?.value ?? this.getAttribute('value')
+ );
}
private _initValue: any | null;
@@ -184,16 +189,16 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
const target = event.target! as HTMLElement;
// Only filter radio-buttons event
- if (target.localName !== 'sbb-radio-button' && target.localName !== 'sbb-radio-button-group') {
+ if (target.localName !== 'sbb-radio-button' && target.localName !== 'sbb-radio-button-panel') {
return;
}
event.stopPropagation();
if (eventName === 'change') {
- this._change.emit({ value: this.value, target: event.target });
- this._didChange.emit({ value: this.value, target: event.target });
+ this._change.emit({ value: this.value, radioButton: event.target });
+ this._didChange.emit({ value: this.value, radioButton: event.target });
} else if (eventName === 'input') {
- this._input.emit({ value: this.value, target: event.target });
+ this._input.emit({ value: this.value, radioButton: event.target });
}
}
From 66dc63a12dcb1dcc581610673748db6f84f1877e Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 12:17:23 +0200
Subject: [PATCH 13/30] docs(sbb-radio-button, sbb-radio-button-group): update
with 'standalone' radios
---
.../radio-button/radio-button-group/readme.md | 3 ++-
.../radio-button/radio-button-panel/readme.md | 20 ++++++++++++----
.../radio-button/radio-button/readme.md | 23 ++++++++++++-------
3 files changed, 32 insertions(+), 14 deletions(-)
diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md
index e9d5d2aa50..15e3cfd206 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
diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md
index 33d8984ac6..874b1d0479 100644
--- a/src/elements/radio-button/radio-button-panel/readme.md
+++ b/src/elements/radio-button/radio-button-panel/readme.md
@@ -1,12 +1,22 @@
-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.
+The `sbb-radio-button-panel` component provides the same functionality as a native `` enhanced with the selection panel design and functionalities.
+
+Radio-buttons should typically be placed inside a [sbb-radio-button-group](/docs/elements-sbb-radio-button-sbb-radio-button-group--docs) component
+in order to display a radio input within a group. Individual radio-buttons inside of a radio-group will inherit the `name` of the group.
```html
-
+
Option one
Option two
```
+In cases where that's not possible, you can define a group of radios using the same `name` property
+
+```html
+Option one
+Option two
+```
+
## Slots
It is possible to provide a label via an unnamed slot;
@@ -71,9 +81,9 @@ 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 | Set the radio-button as 'checked'. If 'allowEmptySelection', toggle the checked property. Emits events | | `void` | SbbRadioButtonCommonElementMixin |
## Events
diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md
index 461d4925d4..683e653dfd 100644
--- a/src/elements/radio-button/radio-button/readme.md
+++ b/src/elements/radio-button/radio-button/readme.md
@@ -1,15 +1,22 @@
-The `sbb-radio-button` component provides the same functionality
-as a native `` enhanced with the SBB Design: use multiple `sbb-radio-button` components
-inside a [sbb-radio-button-group](/docs/elements-sbb-radio-button-sbb-radio-button-group--docs) component
-in order to display a radio input within a group.
+The `sbb-radio-button` component provides the same functionality as a native `` enhanced with the SBB Design.
+
+Radio-buttons should typically be placed inside a [sbb-radio-button-group](/docs/elements-sbb-radio-button-sbb-radio-button-group--docs) component
+in order to display a radio input within a group. Individual radio-buttons inside of a radio-group will inherit the `name` of the group.
```html
-
+
Option one
Option two
```
+In cases where that's not possible, you can define a group of radios using the same `name` property
+
+```html
+Option one
+Option two
+```
+
## States
It is possible to display the component in `disabled` or `checked` state by using the self-named properties.
@@ -65,9 +72,9 @@ 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 | Set the radio-button as 'checked'. If 'allowEmptySelection', toggle the checked property. Emits events | | `void` | SbbRadioButtonCommonElementMixin |
## Events
From 90f94c2bfd6854d75d91563decddea75a01b9ecf Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 12:33:20 +0200
Subject: [PATCH 14/30] test(sbb-radio-button): add spec tests
---
.../common/radio-button-common.spec.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index 83a407ad20..edab2ac292 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -432,6 +432,25 @@ describe(`radio-button common behaviors`, () => {
}
});
+ 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);
From 4d4a87df7b0dccb38424785d710cf898edde66ff Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 14:46:50 +0200
Subject: [PATCH 15/30] feat(sbb-radio-button-panel): handle expansion-panel
keyboard navigation exception
---
.../form-associated-radio-button-mixin.ts | 15 ++++++++++-----
.../radio-button-panel.stories.ts | 17 ++++++++++++-----
.../radio-button-panel/radio-button-panel.ts | 18 +++++++++++++++++-
3 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 0135a7598a..a484b5f60f 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -32,6 +32,7 @@ export declare class SbbFormAssociatedRadioButtonMixinType
protected withUserInteraction?(): void;
protected updateFocusableRadios(): void;
protected emitChangeEvents(): void;
+ protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise;
}
/**
@@ -212,6 +213,14 @@ export const SbbFormAssociatedRadioButtonMixin = {
+ next.checked = true;
+ this.emitChangeEvents();
+
+ await next.updateComplete;
+ next.focus();
+ }
+
protected emitChangeEvents(): void {
// Manually dispatch events to simulate a user interaction
this.dispatchEvent(
@@ -278,11 +287,7 @@ export const SbbFormAssociatedRadioButtonMixin =
const StandaloneTemplate = ({ value, ...args }: Args): TemplateResult => html`
`;
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 5d215ebced..b9911d2697 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
@@ -12,6 +12,7 @@ import { slotState } from '../../core/decorators.js';
import {
panelCommonStyle,
RadioButtonRegistry,
+ type SbbFormAssociatedRadioButtonMixinType,
SbbPanelMixin,
type SbbPanelSize,
SbbUpdateSchedulerMixin,
@@ -80,7 +81,22 @@ export class SbbRadioButtonPanelElement extends SbbPanelMixin(
super.updateFocusableRadios();
const radios = (RadioButtonRegistry.getRadios(this.name) || []) as SbbRadioButtonPanelElement[];
- radios.filter((r) => r._hasSelectionExpansionPanelElement).forEach((r) => (r.tabIndex = 0));
+ 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 {
From 42b126d9ba3f3cbc6d02e77ef78cd5cb9685e702 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 15:33:12 +0200
Subject: [PATCH 16/30] docs(sbb-radio-button): update docs
---
.../radio-button/radio-button-panel/readme.md | 29 ++++++++++---------
.../radio-button/radio-button/readme.md | 25 +++++++++-------
2 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md
index 874b1d0479..269ccd4408 100644
--- a/src/elements/radio-button/radio-button-panel/readme.md
+++ b/src/elements/radio-button/radio-button-panel/readme.md
@@ -61,23 +61,26 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
```
+
## 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 component is disabled. |
-| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. |
-| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
-| `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 | `SbbPanelSize` | `'m'` | Size variant. |
-| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
+| 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 component is disabled. |
+| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. |
+| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
+| `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 | `SbbPanelSize` | `'m'` | Size variant. |
+| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
## Methods
diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md
index 683e653dfd..f46c9dd66b 100644
--- a/src/elements/radio-button/radio-button/readme.md
+++ b/src/elements/radio-button/radio-button/readme.md
@@ -54,21 +54,24 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
```
+
## Properties
-| Name | Attribute | Privacy | Type | Default | Description |
-| --------------------- | ----------------------- | ------- | ------------------------------------ | ------- | ----------------------------------------------------------- |
-| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. |
-| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. |
-| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. |
-| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. |
-| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
-| `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 | `SbbRadioButtonSize` | `'m'` | Size variant. |
-| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
+| Name | Attribute | Privacy | Type | Default | Description |
+| --------------------- | ----------------------- | ------- | ------------------------------------ | ------- | -------------------------------------------------------------- |
+| `allowEmptySelection` | `allow-empty-selection` | public | `boolean` | `false` | Whether the radio can be deselected. |
+| `checked` | `checked` | public | `boolean` | `false` | Whether the radio button is checked. |
+| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. |
+| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. |
+| `group` | - | public | `SbbRadioButtonGroupElement \| null` | `null` | Reference to the connected radio button group. |
+| `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 | `SbbRadioButtonSize` | `'m'` | Size variant. |
+| `value` | `value` | public | `string \| null` | `null` | Value of the form element. |
## Methods
From c872f43a815e38de92af6410cc83c1bce2557f5e Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 15:36:13 +0200
Subject: [PATCH 17/30] docs(sbb-radio-button): update docs
---
src/elements/radio-button/radio-button-panel/readme.md | 2 +-
src/elements/radio-button/radio-button/readme.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md
index 269ccd4408..0d0802d487 100644
--- a/src/elements/radio-button/radio-button-panel/readme.md
+++ b/src/elements/radio-button/radio-button-panel/readme.md
@@ -1,7 +1,7 @@
The `sbb-radio-button-panel` component provides the same functionality as a native `` enhanced with the selection panel design and functionalities.
Radio-buttons should typically be placed inside a [sbb-radio-button-group](/docs/elements-sbb-radio-button-sbb-radio-button-group--docs) component
-in order to display a radio input within a group. Individual radio-buttons inside of a radio-group will inherit the `name` of the group.
+in order to display a radio input within a group. Individual radio-buttons inside a radio-group will inherit the `name` of the group.
```html
diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md
index f46c9dd66b..c9f19e0360 100644
--- a/src/elements/radio-button/radio-button/readme.md
+++ b/src/elements/radio-button/radio-button/readme.md
@@ -1,7 +1,7 @@
The `sbb-radio-button` component provides the same functionality as a native `` enhanced with the SBB Design.
Radio-buttons should typically be placed inside a [sbb-radio-button-group](/docs/elements-sbb-radio-button-sbb-radio-button-group--docs) component
-in order to display a radio input within a group. Individual radio-buttons inside of a radio-group will inherit the `name` of the group.
+in order to display a radio input within a group. Individual radio-buttons inside a radio-group will inherit the `name` of the group.
```html
From 80fe9aab1f4bd960645d26b1bdd8e31668eb3a3b Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 15:45:29 +0200
Subject: [PATCH 18/30] chore(sbb-radio-button): fix linting
---
src/elements/core/mixins/form-associated-radio-button-mixin.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index a484b5f60f..3a3b9ebf48 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -30,6 +30,7 @@ export declare class SbbFormAssociatedRadioButtonMixinType
protected isDisabledExternally(): boolean;
protected isRequiredExternally(): boolean;
protected withUserInteraction?(): void;
+ protected updateFormValue(): void;
protected updateFocusableRadios(): void;
protected emitChangeEvents(): void;
protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise;
From 04328fd951444332c0248a012187eb69b3ff7487 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Fri, 18 Oct 2024 16:39:24 +0200
Subject: [PATCH 19/30] feat(sbb-radio-button-panel): handle selection by
keyboard
---
.../form-associated-radio-button-mixin.ts | 19 ++++++++++---------
.../selection-expansion-panel.spec.ts | 1 +
2 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 3a3b9ebf48..5be948c3af 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -200,18 +200,19 @@ export const SbbFormAssociatedRadioButtonMixin = r.removeAttribute('tabindex'));
-
const checkedIndex = radios.findIndex((r) => r.checked && !r.disabled && !r.formDisabled);
- if (checkedIndex !== -1) {
- radios[checkedIndex].tabIndex = 0;
- return;
+ 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);
}
- const firstFocusable = radios.findIndex((r) => !r.disabled && !r.formDisabled);
- if (firstFocusable !== -1) {
- radios[firstFocusable].tabIndex = 0;
- }
+ // Reset tabIndex on other radios
+ radios.forEach((r) => r.removeAttribute('tabindex'));
}
protected async navigateByKeyboard(next: SbbFormAssociatedRadioButtonElement): Promise {
diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts
index 9243ee9ac0..3c0b3bf6c0 100644
--- a/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts
+++ b/src/elements/selection-expansion-panel/selection-expansion-panel.spec.ts
@@ -311,6 +311,7 @@ describe(`sbb-selection-expansion-panel`, () => {
await sendKeys({ press: ' ' });
expect(secondInput.checked).to.be.true;
expect(firstInput.checked).to.be.false;
+ expect(document.activeElement!.id).to.be.equal(secondInput.id);
});
it('wraps around on arrow key navigation', async () => {
From 4c2884430ea8d7bc5bc429c6534046c298965f46 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 21 Oct 2024 14:15:54 +0200
Subject: [PATCH 20/30] feat(sbb-radio-button-group): handle value preservation
---
.../radio-button-group.spec.ts | 39 ++++++++++++++++---
.../radio-button-group/radio-button-group.ts | 37 +++++++++++++-----
2 files changed, 61 insertions(+), 15 deletions(-)
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 23177e75b7..8c1e52ab7d 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
@@ -197,34 +197,63 @@ import '../radio-button-panel.js';
});
});
- // TODO Discuss the 'value' preservation
- describe.skip('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.ts b/src/elements/radio-button/radio-button-group/radio-button-group.ts
index 3825144d9c..2d5451252c 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
@@ -58,8 +58,8 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
*/
@property()
public set value(val: any | null) {
+ this._fallbackValue = val;
if (!this._didLoad) {
- this._initValue = val;
return;
}
if (!val) {
@@ -72,11 +72,12 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
}
}
public get value(): any | null {
- return (
- this.radioButtons.find((r) => r.checked && !r.disabled)?.value ?? this.getAttribute('value')
- );
+ return this.radioButtons.find((r) => r.checked && !r.disabled)?.value ?? this._fallbackValue;
}
- private _initValue: any | null;
+ /**
+ * Used to preserve the `value` in case the radios are not yet 'loaded'
+ */
+ private _fallbackValue: any | null = null;
/**
* Size variant.
@@ -176,9 +177,7 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
this._didLoad = true;
await this.updateComplete;
- if (this._initValue) {
- this.value = this._initValue;
- }
+ this._updateRadioState();
}
/**
@@ -186,7 +185,7 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
* Made to maintain retro compatibility.
*/
private _onRadioChange(eventName: 'change' | 'input', event: Event): void {
- const target = event.target! as HTMLElement;
+ const target = event.target! as SbbRadioButtonElement | SbbRadioButtonPanelElement;
// Only filter radio-buttons event
if (target.localName !== 'sbb-radio-button' && target.localName !== 'sbb-radio-button-panel') {
@@ -194,6 +193,8 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
}
event.stopPropagation();
+ this._fallbackValue = null; // Since the user interacted, the fallbackValue logic does not apply anymore
+
if (eventName === 'change') {
this._change.emit({ value: this.value, radioButton: event.target });
this._didChange.emit({ value: this.value, radioButton: event.target });
@@ -209,10 +210,26 @@ export class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
this.radioButtons.forEach((r) => (r.name = this.name));
}
+ /**
+ * 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;
+ }
+ }
+
protected override render(): TemplateResult {
return html`
- this._updateRadiosName()}>
+ {
+ this._updateRadiosName();
+ this._updateRadioState();
+ }}
+ >
From 56d5ba4195643676f5900f50111e050dcb1a5da1 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 23 Oct 2024 10:03:03 +0200
Subject: [PATCH 21/30] test(sbb-radio-button): enhance ssr test
---
.../form-associated-radio-button-mixin.ts | 10 +++++++
.../radio-button-panel.ssr.spec.ts | 29 +++++++++++++++----
.../radio-button/radio-button.ssr.spec.ts | 29 +++++++++++++++----
3 files changed, 58 insertions(+), 10 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index d5b9588ff8..b75acfa8f8 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -99,6 +99,7 @@ export const SbbFormAssociatedRadioButtonMixin = ): void {
+ super.firstUpdated(changedProperties);
+ this._didLoad = true;
+ this.updateFocusableRadios();
+ }
+
/**
* Called on `value` change
* If I'm checked, update the value. Otherwise, do nothing.
@@ -195,6 +202,9 @@ export const SbbFormAssociatedRadioButtonMixin = r.checked && !r.disabled && !r.formDisabled);
const focusableIndex =
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/radio-button.ssr.spec.ts b/src/elements/radio-button/radio-button/radio-button.ssr.spec.ts
index 4ee75ed7fe..c0b9741454 100644
--- a/src/elements/radio-button/radio-button/radio-button.ssr.spec.ts
+++ b/src/elements/radio-button/radio-button/radio-button.ssr.spec.ts
@@ -6,18 +6,37 @@ import { ssrHydratedFixture } from '../../core/testing/private.js';
import { SbbRadioButtonElement } from './radio-button.js';
describe(`sbb-radio-button ssr`, () => {
- let root: SbbRadioButtonElement;
-
- beforeEach(async () => {
- root = await ssrHydratedFixture(
+ it('renders', async () => {
+ const root = await ssrHydratedFixture(
html`Value label`,
{
modules: ['./radio-button.js'],
},
);
+ assert.instanceOf(root, SbbRadioButtonElement);
});
- it('renders', () => {
+ it('renders checked', async () => {
+ const root = await ssrHydratedFixture(
+ html`Value label`,
+ {
+ modules: ['./radio-button.js'],
+ },
+ );
+ assert.instanceOf(root, SbbRadioButtonElement);
+ });
+
+ it('renders standalone group', async () => {
+ const root = await ssrHydratedFixture(
+ html`
+ Value 1
+ Value 2
+ Value 3
+ `,
+ {
+ modules: ['./radio-button.js'],
+ },
+ );
assert.instanceOf(root, SbbRadioButtonElement);
});
});
From d05caca6aea2096971d191c994787cef103f695e Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 23 Oct 2024 10:23:36 +0200
Subject: [PATCH 22/30] fix(sbb-radio-button): pr feedbacks
---
.../form-associated-radio-button-mixin.ts | 24 +++++++++++++------
.../common/radio-button-common.ts | 4 ++--
.../radio-button/radio-button-panel/readme.md | 6 ++---
.../radio-button/radio-button/readme.md | 6 ++---
4 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index b75acfa8f8..1990137507 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -38,7 +38,8 @@ export declare class SbbFormAssociatedRadioButtonMixinType
}
/**
- * TODO add docs (maybe move to new file)
+ * A static registry that holds a collection of `radio-buttons`, grouped by `name`.
+ * It is mainly used to support the standalone groups of radios.
* @internal
*/
export class RadioButtonRegistry {
@@ -46,6 +47,9 @@ export class RadioButtonRegistry {
private constructor() {}
+ /**
+ * Adds @radio to the @groupName group. Checks for duplicates
+ */
public static addRadioToGroup(
radio: SbbFormAssociatedRadioButtonMixinType,
groupName: string,
@@ -60,6 +64,9 @@ export class RadioButtonRegistry {
this._registry[groupName].push(radio);
}
+ /**
+ * Removes @radio from the @groupName group.
+ */
public static removeRadioFromGroup(
radio: SbbFormAssociatedRadioButtonMixinType,
groupName: string,
@@ -75,6 +82,9 @@ export class RadioButtonRegistry {
}
}
+ /**
+ * Return an array of radios that belong to @groupName
+ */
public static getRadios(groupName: string): SbbFormAssociatedRadioButtonMixinType[] {
return this._registry[groupName] ?? [];
}
@@ -180,7 +190,7 @@ export const SbbFormAssociatedRadioButtonMixin = r.checked && !r.disabled && !r.formDisabled);
const focusableIndex =
checkedIndex !== -1
@@ -275,7 +285,7 @@ export const SbbFormAssociatedRadioButtonMixin = (
`:is(sbb-radio-button, sbb-radio-button-panel)[name="${groupName}"]`,
@@ -289,7 +299,7 @@ export const SbbFormAssociatedRadioButtonMixin = !r.disabled && !r.formDisabled,
);
const current: number = enabledRadios.indexOf(this);
diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts
index 8fa320a4ef..5065e03e2f 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -88,8 +88,8 @@ export const SbbRadioButtonCommonElementMixin =
Date: Wed, 30 Oct 2024 12:01:21 +0100
Subject: [PATCH 23/30] fix(sbb-radio-button): minor pr fixes
---
.../core/mixins/form-associated-radio-button-mixin.ts | 6 +++---
src/elements/radio-button/common/radio-button-common.ts | 6 +++++-
.../radio-button-panel/radio-button-panel.stories.ts | 2 +-
.../radio-button/radio-button-panel/radio-button-panel.ts | 1 +
src/elements/radio-button/radio-button-panel/readme.md | 3 ---
src/elements/radio-button/radio-button/radio-button.ts | 1 +
src/elements/radio-button/radio-button/readme.md | 3 ---
7 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 1990137507..aed7b325c9 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -120,9 +120,9 @@ export const SbbFormAssociatedRadioButtonMixin = this._handleArrowKeyDown(e), { signal });
+ this.addEventListener('keydown', (e) => this._handleArrowKeyDown(e), {
+ signal: this.abort.signal,
+ });
}
public override disconnectedCallback(): void {
diff --git a/src/elements/radio-button/common/radio-button-common.ts b/src/elements/radio-button/common/radio-button-common.ts
index 5065e03e2f..7e487a354c 100644
--- a/src/elements/radio-button/common/radio-button-common.ts
+++ b/src/elements/radio-button/common/radio-button-common.ts
@@ -132,7 +132,11 @@ export const SbbRadioButtonCommonElementMixin = html`%`;
const DefaultTemplate = ({ labelBoldClass, ...args }: Args): TemplateResult =>
- html`${labelBoldClass ? html`Label` : 'Label'}
Subtext
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 ddfc395721..224947ccb5 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
@@ -31,6 +31,7 @@ import '../../screen-reader-only.js';
* @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')
diff --git a/src/elements/radio-button/radio-button-panel/readme.md b/src/elements/radio-button/radio-button-panel/readme.md
index 035ddbe287..42e972f771 100644
--- a/src/elements/radio-button/radio-button-panel/readme.md
+++ b/src/elements/radio-button/radio-button-panel/readme.md
@@ -61,9 +61,6 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
```
-
## Properties
diff --git a/src/elements/radio-button/radio-button/radio-button.ts b/src/elements/radio-button/radio-button/radio-button.ts
index 101ac70bab..191cb3dbbc 100644
--- a/src/elements/radio-button/radio-button/radio-button.ts
+++ b/src/elements/radio-button/radio-button/radio-button.ts
@@ -17,6 +17,7 @@ import radioButtonStyle from './radio-button.scss?lit&inline';
* @slot - Use the unnamed slot to add content to the radio label.
* @event {Event} change - Fired on change.
* @event {InputEvent} input - Fired on input.
+ * @overrideType value - string | null
*/
export
@customElement('sbb-radio-button')
diff --git a/src/elements/radio-button/radio-button/readme.md b/src/elements/radio-button/radio-button/readme.md
index 771e0fe865..eede67914c 100644
--- a/src/elements/radio-button/radio-button/readme.md
+++ b/src/elements/radio-button/radio-button/readme.md
@@ -54,9 +54,6 @@ The component's label can be displayed in bold using the `sbb-text--bold` class
```
-
## Properties
From 623d70b182416c61dc87c0148d4c61472f2c8e10 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 30 Oct 2024 14:32:10 +0100
Subject: [PATCH 24/30] fix(sbb-radio-button): ignore non-visible radios
---
.../form-associated-radio-button-mixin.ts | 4 ++--
.../common/radio-button-common.spec.ts | 20 +++++++++++++++++++
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index aed7b325c9..2113db7c91 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -1,7 +1,7 @@
import type { LitElement, PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
-import { getNextElementIndex, isArrowKeyPressed } from '../a11y.js';
+import { getNextElementIndex, interactivityChecker, isArrowKeyPressed } from '../a11y.js';
import { SbbConnectedAbortController } from '../controllers.js';
import { forceType } from '../decorators.js';
@@ -290,7 +290,7 @@ export const SbbFormAssociatedRadioButtonMixin = (
`:is(sbb-radio-button, sbb-radio-button-panel)[name="${groupName}"]`,
),
- );
+ ).filter((el) => interactivityChecker.isVisible(el));
}
private async _handleArrowKeyDown(evt: KeyboardEvent): Promise {
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index edab2ac292..126cd7c4c0 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -470,6 +470,26 @@ describe(`radio-button common behaviors`, () => {
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', () => {
From f6a467e8d0749fe5c4c34d1d3ee5d741e6eac4a5 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 30 Oct 2024 17:10:19 +0100
Subject: [PATCH 25/30] fix(sbb-radio-button): handle groups with the same name
---
.../form-associated-radio-button-mixin.ts | 69 ++-
.../common/radio-button-common.spec.ts | 548 ++++++++++--------
.../radio-button-panel/radio-button-panel.ts | 3 +-
3 files changed, 344 insertions(+), 276 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 2113db7c91..9578f93d7b 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -37,56 +37,74 @@ export declare class SbbFormAssociatedRadioButtonMixinType
protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise;
}
+type RadioButtonGroup = {
+ name: string;
+ form: HTMLFormElement | null;
+ radios: SbbFormAssociatedRadioButtonMixinType[];
+};
+
/**
- * A static registry that holds a collection of `radio-buttons`, grouped by `name`.
+ * A static registry that holds a collection of `radio-buttons`, grouped by `name + form`.
* It is mainly used to support the standalone groups of radios.
+ * The identifier of a group is composed of the couple 'name + form' because multiple radios with the same name can coexist (as long as they belong to different forms)
* @internal
*/
export class RadioButtonRegistry {
- private static _registry: { [x: string]: SbbFormAssociatedRadioButtonMixinType[] } = {};
+ private static _registry: RadioButtonGroup[] = [];
private constructor() {}
/**
- * Adds @radio to the @groupName group. Checks for duplicates
+ * Adds @radio to the '@groupName + @form' group. Checks for duplicates
*/
public static addRadioToGroup(
radio: SbbFormAssociatedRadioButtonMixinType,
groupName: string,
+ form = radio.form,
): void {
- if (!this._registry[groupName]) {
- this._registry[groupName] = [];
+ let group = this._registry.find((g) => g.name === groupName && g.form === form);
+
+ // If it does not exist, initializes it
+ if (!group) {
+ group = { name: groupName, form: form, radios: [] };
+ this._registry.push(group);
}
+
// Check for duplicates
- if (this._registry[groupName].indexOf(radio) !== -1) {
+ if (group.radios.indexOf(radio) !== -1) {
return;
}
- this._registry[groupName].push(radio);
+ group.radios.push(radio);
}
/**
- * Removes @radio from the @groupName group.
+ * Removes @radio from the group it belongs.
*/
- public static removeRadioFromGroup(
- radio: SbbFormAssociatedRadioButtonMixinType,
- groupName: string,
- ): void {
- const index = this._registry[groupName]?.indexOf(radio);
- if (!this._registry[groupName] || index === -1) {
+ public static removeRadioFromGroup(radio: SbbFormAssociatedRadioButtonMixinType): void {
+ // Find the group where @radio belongs
+ const groupIndex = this._registry.findIndex((g) => g.radios.find((r) => r === radio));
+ const group = this._registry[groupIndex];
+ if (!group) {
return;
}
- this._registry[groupName].splice(index, 1);
- if (this._registry[groupName].length === 0) {
- delete this._registry[groupName];
+ // Remove @radio from the group
+ group.radios.splice(group.radios.indexOf(radio), 1);
+
+ // If the group is empty, clear it
+ if (group.radios.length === 0) {
+ this._registry.splice(groupIndex, 1);
}
}
/**
- * Return an array of radios that belong to @groupName
+ * Return an array of radios that belong to the group '@groupName + @form'
*/
- public static getRadios(groupName: string): SbbFormAssociatedRadioButtonMixinType[] {
- return this._registry[groupName] ?? [];
+ public static getRadios(
+ groupName: string,
+ form: HTMLFormElement | null,
+ ): SbbFormAssociatedRadioButtonMixinType[] {
+ return this._registry.find((g) => g.name === groupName && g.form === form)?.radios ?? [];
}
}
@@ -158,8 +176,7 @@ export const SbbFormAssociatedRadioButtonMixin = r !== (this as unknown as SbbFormAssociatedRadioButtonMixinType))
.forEach((r) => (r.checked = false));
}
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index 126cd7c4c0..416a2aa232 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -219,312 +219,366 @@ describe(`radio-button common behaviors`, () => {
describe(selector, () => {
const tagSingle = unsafeStatic(selector);
- beforeEach(async () => {
- form = await fixture(html`
- `);
-
- 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);
+ describe('general behavior', () => {
+ beforeEach(async () => {
+ form = await fixture(html`
+ `);
+
+ 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);
- 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);
- });
+ await waitForLitRender(form);
+ });
- 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;
+ it('should find connected form', () => {
+ expect(elements[0].form).to.be.equal(form);
+ });
- await waitForLitRender(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();
+ });
- elements[3].name = elements[0].name;
- nativeElements[3].name = nativeElements[0].name;
+ it('should select on click', async () => {
+ elements[1].click();
+ await waitForLitRender(form);
+ expect(document.activeElement === elements[1]).to.be.true;
- await waitForLitRender(form);
+ nativeElements[1].click();
+ 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();
- });
+ 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();
+ });
- describe('keyboard interaction', () => {
- it('should select on space key', async () => {
- elements[0].focus();
- await sendKeys({ press: 'Space' });
+ it('should reflect state after programmatic change', async () => {
+ elements[1].checked = true;
+ nativeElements[1].checked = true;
await waitForLitRender(form);
- expect(elements[0].checked).to.be.true;
+ expect(elements[0].tabIndex).to.be.equal(-1);
+ expect(elements[1].tabIndex).to.be.equal(0);
+ await compareToNative();
});
- it('should select and wrap on arrow keys', async () => {
+ it('should reset on form reset', async () => {
elements[1].checked = true;
+ nativeElements[1].checked = true;
await waitForLitRender(form);
- elements[1].focus();
- await sendKeys({ press: 'ArrowRight' });
+ 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;
- expect(elements[2].checked).to.be.true;
- expect(document.activeElement === elements[2]).to.be.true;
+ await compareToNative();
+ });
- await sendKeys({ press: 'ArrowDown' });
+ it('should restore default on form reset', async () => {
+ elements[1].toggleAttribute('checked', true);
+ nativeElements[1].toggleAttribute('checked', true);
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;
+ elements[0].click();
+ nativeElements[0].click();
+ await waitForLitRender(form);
- await sendKeys({ press: 'ArrowLeft' });
+ 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[2].checked).to.be.true;
- expect(document.activeElement === elements[2]).to.be.true;
+ expect(elements[1].checked).to.be.true;
+ await compareToNative();
+ });
- await sendKeys({ press: 'ArrowUp' });
+ 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[2].checked).to.be.false;
+ expect(elements[0].checked).to.be.false;
expect(elements[1].checked).to.be.true;
- expect(document.activeElement === elements[1]).to.be.true;
+ expect(elements[0].tabIndex).to.be.equal(-1);
+ expect(elements[1].tabIndex).to.be.equal(0);
+ });
- // 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' });
+ it('should handle adding a new radio to the group', async () => {
+ elements[0].checked = true;
+ await waitForLitRender(form);
- // On webkit, native radios do not wrap
- if (!isWebkit) {
- await compareToNative();
- }
- });
+ // 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);
- it('should handle keyboard interaction outside of a form', async () => {
- // Move the radios outside the form
- form.parentElement!.append(fieldset);
- await waitForLitRender(fieldset);
+ await waitForLitRender(form);
- 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;
+ expect(newRadio.checked).to.be.true;
+ expect(elements[0].tabIndex).to.be.equal(-1);
+ expect(newRadio.tabIndex).to.be.equal(0);
});
- it('should skip disabled elements on arrow keys', async () => {
- elements[1].disabled = true;
- await waitForLitRender(form);
+ 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;
- 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;
+ elements[3].name = elements[0].name;
+ nativeElements[3].name = nativeElements[0].name;
- 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;
+ // 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();
});
- it('should skip non-visible elements on arrow keys', async () => {
- elements[1].style.display = 'none';
- await waitForLitRender(form);
+ 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;
+ });
+ });
- elements[0].focus();
- await sendKeys({ press: 'ArrowRight' });
- await waitForLitRender(form);
+ describe('disabled state', () => {
+ it('should result :disabled', async () => {
+ elements[0].disabled = true;
+ 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;
+ expect(elements[0]).to.match(':disabled');
+ expect(elements[1].tabIndex).to.be.equal(0);
+ });
- await sendKeys({ press: 'ArrowLeft' });
- await waitForLitRender(form);
+ it('should result :disabled if a fieldSet is', async () => {
+ fieldset.toggleAttribute('disabled', true);
+ 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;
- });
- });
+ expect(elements[0]).to.match(':disabled');
+ });
- describe('disabled state', () => {
- it('should result :disabled', async () => {
- elements[0].disabled = true;
- await waitForLitRender(form);
+ it('should do nothing when clicked', async () => {
+ elements[0].disabled = true;
+ await waitForLitRender(form);
- expect(elements[0]).to.match(':disabled');
- expect(elements[1].tabIndex).to.be.equal(0);
- });
+ elements[0].click();
+ await waitForLitRender(form);
- it('should result :disabled if a fieldSet is', async () => {
- fieldset.toggleAttribute('disabled', true);
- await waitForLitRender(form);
+ expect(elements[0].checked).to.be.false;
+ });
- expect(elements[0]).to.match(':disabled');
+ 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);
+ });
});
+ });
- it('should do nothing when clicked', async () => {
- elements[0].disabled = true;
- await waitForLitRender(form);
+ describe('multiple groups with the same name', () => {
+ let root: HTMLElement;
+
+ beforeEach(async () => {
+ root = await fixture(html`
+
+
+
+
+ `);
+
+ form = root.querySelector('form#main')!;
+ 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();
- await waitForLitRender(form);
+ elements[3].click();
+ await waitForLitRender(root);
- expect(elements[0].checked).to.be.false;
+ 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('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);
+ it('groups should be independent when keyboard navigated', async () => {
+ elements[0].focus();
- expect(elements[0].tabIndex).to.be.equal(-1);
- expect(elements[1].tabIndex).to.be.equal(0);
+ 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;
});
});
});
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 224947ccb5..f5daca38fd 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
@@ -74,7 +74,8 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin(
*/
protected override updateFocusableRadios(): void {
super.updateFocusableRadios();
- const radios = (RadioButtonRegistry.getRadios(this.name) || []) as SbbRadioButtonPanelElement[];
+ const radios = (RadioButtonRegistry.getRadios(this.name, this.form) ||
+ []) as SbbRadioButtonPanelElement[];
radios
.filter((r) => !r.disabled && r._hasSelectionExpansionPanelElement)
From e4e2d2bcf798e7b514c9473473a7d9992af278c7 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Mon, 11 Nov 2024 10:13:37 +0100
Subject: [PATCH 26/30] feat(sbb-radio-button-group)!: removed details from
`change` and `input` events
---
.../radio-button-group.spec.ts | 15 +++----
.../radio-button-group/radio-button-group.ts | 45 ++-----------------
.../selection-expansion-panel.stories.ts | 12 +++--
3 files changed, 14 insertions(+), 58 deletions(-)
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 8c1e52ab7d..1ae800f33b 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
@@ -6,10 +6,7 @@ import { waitForCondition, waitForLitRender, EventSpy } from '../../core/testing
import type { SbbRadioButtonPanelElement } from '../radio-button-panel.js';
import type { SbbRadioButtonElement } from '../radio-button.js';
-import {
- SbbRadioButtonGroupElement,
- type SbbRadioButtonGroupEventDetail,
-} from './radio-button-group.js';
+import { SbbRadioButtonGroupElement } from './radio-button-group.js';
import '../radio-button.js';
import '../radio-button-panel.js';
@@ -140,15 +137,13 @@ import '../radio-button-panel.js';
await waitForCondition(() => changeSpy.events.length === 1);
await waitForCondition(() => inputSpy.events.length === 1);
- const changeEvent = changeSpy.lastEvent as CustomEvent;
+ const changeEvent = changeSpy.lastEvent!;
expect(changeSpy.count).to.be.equal(1);
- expect(changeEvent.detail.value).to.be.equal(radio.value);
- expect(changeEvent.detail.radioButton === radio).to.be.true;
+ expect(changeEvent.target === radio).to.be.true;
- const inputEvent = changeSpy.lastEvent as CustomEvent;
+ const inputEvent = changeSpy.lastEvent!;
expect(inputSpy.count).to.be.equal(1);
- expect(inputEvent.detail.value).to.be.equal(radio.value);
- expect(inputEvent.detail.radioButton === radio).to.be.true;
+ expect(inputEvent.target === radio).to.be.true;
// A click on a checked radio should not emit any event
radio.click();
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 f064a6e0ea..4ee3a3fd2a 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
@@ -13,11 +13,6 @@ 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;
/**
@@ -26,8 +21,6 @@ let nextId = 0;
* @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.
*/
export
@customElement('sbb-radio-button-group')
@@ -123,27 +116,11 @@ 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;
@@ -152,10 +129,7 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
!!this.querySelector?.('sbb-selection-expansion-panel, sbb-radio-button-panel'),
);
- this.addEventListener('input', (e: Event) => this._onRadioChange('input', e), {
- signal,
- });
- this.addEventListener('change', (e: Event) => this._onRadioChange('change', e), {
+ this.addEventListener('change', (e: Event) => this._onRadioChange(e), {
signal,
});
}
@@ -185,11 +159,7 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
this._updateRadioState();
}
- /**
- * Blocks native 'input' and 'change' events fired by the radio-buttons to fire an enriched version of them.
- * Made to maintain retro compatibility.
- */
- private _onRadioChange(eventName: 'change' | 'input', event: Event): void {
+ private _onRadioChange(event: Event): void {
const target = event.target! as SbbRadioButtonElement | SbbRadioButtonPanelElement;
// Only filter radio-buttons event
@@ -197,15 +167,8 @@ class SbbRadioButtonGroupElement extends SbbDisabledMixin(LitElement) {
return;
}
- event.stopPropagation();
this._fallbackValue = null; // Since the user interacted, the fallbackValue logic does not apply anymore
-
- if (eventName === 'change') {
- this._change.emit({ value: this.value, radioButton: event.target });
- this._didChange.emit({ value: this.value, radioButton: event.target });
- } else if (eventName === 'input') {
- this._input.emit({ value: this.value, radioButton: event.target });
- }
+ this._didChange.emit();
}
/**
diff --git a/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts b/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts
index 4043613723..9c765f0585 100644
--- a/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts
+++ b/src/elements/selection-expansion-panel/selection-expansion-panel.stories.ts
@@ -8,10 +8,7 @@ import { styleMap } from 'lit/directives/style-map.js';
import { sbbSpread } from '../../storybook/helpers/spread.js';
import type { SbbFormErrorElement } from '../form-error.js';
-import type {
- SbbRadioButtonGroupElement,
- SbbRadioButtonGroupEventDetail,
-} from '../radio-button.js';
+import type { SbbRadioButtonGroupElement } from '../radio-button.js';
import { SbbSelectionExpansionPanelElement } from '../selection-expansion-panel.js';
import '../card.js';
@@ -433,11 +430,12 @@ const WithRadiosErrorMessageTemplate = ({
size=${size}
allow-empty-selection
id="sbb-radio-group"
- @change=${(event: CustomEvent) => {
- if (event.detail.value) {
+ @change=${(event: Event) => {
+ const group = event.currentTarget as SbbRadioButtonGroupElement;
+ if (group.value) {
sbbFormError.remove();
} else {
- (event.currentTarget as SbbRadioButtonGroupElement).append(sbbFormError);
+ group.append(sbbFormError);
}
}}
>
From 0a1c48a8ff1588127314f799bc3c0e5f27fbb99c Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 13 Nov 2024 12:33:05 +0100
Subject: [PATCH 27/30] refactor(radio-button-registry): now use 'WeakMap'
---
.../form-associated-radio-button-mixin.ts | 179 ++++++++----------
.../common/radio-button-common.spec.ts | 1 -
.../radio-button/radio-button-group/readme.md | 2 -
.../radio-button-panel/radio-button-panel.ts | 4 +-
4 files changed, 78 insertions(+), 108 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 9578f93d7b..315246329f 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -15,6 +15,18 @@ import {
} 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
+ */
+const radioButtonRegistry = new WeakMap<
+ Node,
+ Map>
+>();
+
export declare class SbbFormAssociatedRadioButtonMixinType
extends SbbFormAssociatedMixinType
implements Partial, Partial
@@ -23,6 +35,7 @@ export declare class SbbFormAssociatedRadioButtonMixinType
public disabled: boolean;
public required: boolean;
+ protected associatedRadioButtons?: Set;
protected abort: SbbConnectedAbortController;
public formResetCallback(): void;
@@ -37,77 +50,6 @@ export declare class SbbFormAssociatedRadioButtonMixinType
protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise;
}
-type RadioButtonGroup = {
- name: string;
- form: HTMLFormElement | null;
- radios: SbbFormAssociatedRadioButtonMixinType[];
-};
-
-/**
- * A static registry that holds a collection of `radio-buttons`, grouped by `name + form`.
- * It is mainly used to support the standalone groups of radios.
- * The identifier of a group is composed of the couple 'name + form' because multiple radios with the same name can coexist (as long as they belong to different forms)
- * @internal
- */
-export class RadioButtonRegistry {
- private static _registry: RadioButtonGroup[] = [];
-
- private constructor() {}
-
- /**
- * Adds @radio to the '@groupName + @form' group. Checks for duplicates
- */
- public static addRadioToGroup(
- radio: SbbFormAssociatedRadioButtonMixinType,
- groupName: string,
- form = radio.form,
- ): void {
- let group = this._registry.find((g) => g.name === groupName && g.form === form);
-
- // If it does not exist, initializes it
- if (!group) {
- group = { name: groupName, form: form, radios: [] };
- this._registry.push(group);
- }
-
- // Check for duplicates
- if (group.radios.indexOf(radio) !== -1) {
- return;
- }
- group.radios.push(radio);
- }
-
- /**
- * Removes @radio from the group it belongs.
- */
- public static removeRadioFromGroup(radio: SbbFormAssociatedRadioButtonMixinType): void {
- // Find the group where @radio belongs
- const groupIndex = this._registry.findIndex((g) => g.radios.find((r) => r === radio));
- const group = this._registry[groupIndex];
- if (!group) {
- return;
- }
-
- // Remove @radio from the group
- group.radios.splice(group.radios.indexOf(radio), 1);
-
- // If the group is empty, clear it
- if (group.radios.length === 0) {
- this._registry.splice(groupIndex, 1);
- }
- }
-
- /**
- * Return an array of radios that belong to the group '@groupName + @form'
- */
- public static getRadios(
- groupName: string,
- form: HTMLFormElement | null,
- ): SbbFormAssociatedRadioButtonMixinType[] {
- return this._registry.find((g) => g.name === groupName && g.form === form)?.radios ?? [];
- }
-}
-
/**
* The SbbFormAssociatedRadioButtonMixin enables native form support for radio controls.
*/
@@ -126,6 +68,11 @@ export const SbbFormAssociatedRadioButtonMixin = ;
protected abort = new SbbConnectedAbortController(this);
private _didLoad: boolean = false;
@@ -232,14 +179,11 @@ export const SbbFormAssociatedRadioButtonMixin = 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) {
+ const radios = this._interactableGroupedRadios();
+ const checkedIndex = radios.findIndex((r) => r.checked);
+ const focusableIndex = checkedIndex !== -1 ? checkedIndex : 0;
+
+ if (radios[focusableIndex]) {
radios[focusableIndex].tabIndex = 0;
radios.splice(focusableIndex, 1);
}
@@ -267,43 +211,76 @@ export const SbbFormAssociatedRadioButtonMixin = ;
+
+ // Initialize the group set
+ if (!this.associatedRadioButtons) {
+ this.associatedRadioButtons = new Set();
+ nameMap.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
*/
private _disconnectFromRegistry(): void {
- RadioButtonRegistry.removeRadioFromGroup(
- this as unknown as SbbFormAssociatedRadioButtonMixinType,
- );
+ this.associatedRadioButtons?.delete(this);
+ this.associatedRadioButtons = undefined;
}
/**
- * Deselect other radio of the same group
+ * Return a list of 'interactable' grouped radios, ordered in DOM order
*/
- private _deselectGroupedRadios(): void {
- RadioButtonRegistry.getRadios(this.name, this.form)
- .filter((r) => r !== (this as unknown as SbbFormAssociatedRadioButtonMixinType))
- .forEach((r) => (r.checked = false));
+ private _interactableGroupedRadios(): SbbFormAssociatedRadioButtonElement[] {
+ return Array.from(this.associatedRadioButtons!).filter(
+ (el) => interactivityChecker.isVisible(el) && !el.disabled && !el.formDisabled,
+ );
}
/**
- * Return the grouped radios in DOM order
+ * Deselect other radio of the same group
*/
- private _orderedGroupedRadios(groupName = this.name): SbbFormAssociatedRadioButtonElement[] {
- return Array.from(
- (this.form ?? document).querySelectorAll(
- `:is(sbb-radio-button, sbb-radio-button-panel)[name="${groupName}"]`,
- ),
- ).filter((el) => interactivityChecker.isVisible(el));
+ private _deselectGroupedRadios(): void {
+ Array.from(this.associatedRadioButtons!)
+ .filter((r) => r !== this)
+ .forEach((r) => (r.checked = false));
}
private async _handleArrowKeyDown(evt: KeyboardEvent): Promise {
@@ -312,9 +289,7 @@ export const SbbFormAssociatedRadioButtonMixin = !r.disabled && !r.formDisabled,
- );
+ const enabledRadios = this._interactableGroupedRadios();
const current: number = enabledRadios.indexOf(this);
const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length);
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index 416a2aa232..5112e335dc 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -524,7 +524,6 @@ describe(`radio-button common behaviors`, () => {
elements[0].disabled = true;
await waitForLitRender(form);
- expect(elements[0].tabIndex).to.be.equal(-1);
expect(elements[1].tabIndex).to.be.equal(0);
});
});
diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md
index f88182a030..4b5d177dbd 100644
--- a/src/elements/radio-button/radio-button-group/readme.md
+++ b/src/elements/radio-button/radio-button-group/readme.md
@@ -86,9 +86,7 @@ In order to ensure readability for screen-readers, please provide an `aria-label
| 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. | |
## Slots
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 f5daca38fd..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,7 +11,6 @@ import { customElement, property } from 'lit/decorators.js';
import { getOverride, slotState } from '../../core/decorators.js';
import {
panelCommonStyle,
- RadioButtonRegistry,
type SbbFormAssociatedRadioButtonMixinType,
SbbPanelMixin,
type SbbPanelSize,
@@ -74,8 +73,7 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin(
*/
protected override updateFocusableRadios(): void {
super.updateFocusableRadios();
- const radios = (RadioButtonRegistry.getRadios(this.name, this.form) ||
- []) as SbbRadioButtonPanelElement[];
+ const radios = Array.from(this.associatedRadioButtons ?? []) as SbbRadioButtonPanelElement[];
radios
.filter((r) => !r.disabled && r._hasSelectionExpansionPanelElement)
From 696facbae0c4cf0003b891fa9f3d1266dfacea1e Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 13 Nov 2024 13:40:57 +0100
Subject: [PATCH 28/30] fix(sbb-radio-button): minor fixes
---
.../form-associated-radio-button-mixin.ts | 19 ++++++++++++-------
.../common/radio-button-common.spec.ts | 1 +
.../radio-button-group/radio-button-group.ts | 2 +-
.../radio-button/radio-button-group/readme.md | 6 +++---
4 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index 315246329f..ceebeff896 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -180,10 +180,13 @@ export const SbbFormAssociatedRadioButtonMixin = r.checked);
- const focusableIndex = checkedIndex !== -1 ? checkedIndex : 0;
+ 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 (radios[focusableIndex]) {
+ if (focusableIndex !== -1) {
radios[focusableIndex].tabIndex = 0;
radios.splice(focusableIndex, 1);
}
@@ -269,8 +272,8 @@ export const SbbFormAssociatedRadioButtonMixin = interactivityChecker.isVisible(el) && !el.disabled && !el.formDisabled,
+ return Array.from(this.associatedRadioButtons ?? []).filter((el) =>
+ interactivityChecker.isVisible(el),
);
}
@@ -278,7 +281,7 @@ export const SbbFormAssociatedRadioButtonMixin = r !== this)
.forEach((r) => (r.checked = false));
}
@@ -289,7 +292,9 @@ export const SbbFormAssociatedRadioButtonMixin = !r.disabled && !r.formDisabled,
+ );
const current: number = enabledRadios.indexOf(this);
const nextIndex: number = getNextElementIndex(evt, current, enabledRadios.length);
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index 5112e335dc..416a2aa232 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -524,6 +524,7 @@ describe(`radio-button common behaviors`, () => {
elements[0].disabled = true;
await waitForLitRender(form);
+ expect(elements[0].tabIndex).to.be.equal(-1);
expect(elements[1].tabIndex).to.be.equal(0);
});
});
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 4ee3a3fd2a..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
@@ -20,7 +20,7 @@ let nextId = 0;
*
* @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} 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')
diff --git a/src/elements/radio-button/radio-button-group/readme.md b/src/elements/radio-button/radio-button-group/readme.md
index 4b5d177dbd..c2aeeda198 100644
--- a/src/elements/radio-button/radio-button-group/readme.md
+++ b/src/elements/radio-button/radio-button-group/readme.md
@@ -84,9 +84,9 @@ In order to ensure readability for screen-readers, please provide an `aria-label
## Events
-| 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. | |
+| 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
From b82a9972499cc0d265ec64461623fdf0cc870001 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Wed, 13 Nov 2024 17:29:25 +0100
Subject: [PATCH 29/30] test(radio-button-registry): add spec tests
---
.../form-associated-radio-button-mixin.ts | 26 ++++++----
.../common/radio-button-common.spec.ts | 47 +++++++++++++++++++
2 files changed, 64 insertions(+), 9 deletions(-)
diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
index ceebeff896..3fd0d1d3ee 100644
--- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts
+++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts
@@ -22,7 +22,7 @@ import { SbbRequiredMixin, type SbbRequiredMixinType } from './required-mixin.js
* It is mainly used to support the standalone groups of radios.
* @internal
*/
-const radioButtonRegistry = new WeakMap<
+export const radioButtonRegistry = new WeakMap<
Node,
Map>
>();
@@ -68,12 +68,14 @@ export const SbbFormAssociatedRadioButtonMixin = ;
- protected abort = new SbbConnectedAbortController(this);
+ private _radioButtonGroupsMap?: Map>;
private _didLoad: boolean = false;
protected constructor() {
@@ -220,22 +222,22 @@ export const SbbFormAssociatedRadioButtonMixin = ;
// Initialize the group set
if (!this.associatedRadioButtons) {
this.associatedRadioButtons = new Set();
- nameMap.set(
+ this._radioButtonGroupsMap.set(
this.name,
this.associatedRadioButtons as unknown as Set,
);
@@ -261,11 +263,17 @@ export const SbbFormAssociatedRadioButtonMixin = {
describe('multiple groups with the same name', () => {
let root: HTMLElement;
+ let form2: HTMLFormElement;
beforeEach(async () => {
root = await fixture(html`
@@ -550,6 +552,7 @@ describe(`radio-button common behaviors`, () => {
`);
form = root.querySelector('form#main')!;
+ form2 = root.querySelector('form#secondary')!;
elements = Array.from(root.querySelectorAll(selector));
await waitForLitRender(root);
});
@@ -580,6 +583,50 @@ describe(`radio-button common behaviors`, () => {
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 group1Set = radioButtonRegistry.get(form)!.get('sbb-group-1')!;
+
+ form.remove();
+ await waitForLitRender(root);
+
+ expect(group1Set.size).to.be.equal(0);
+ expect(radioButtonRegistry.get(form)!.get('sbb-group-1')).to.be.undefined;
+ expect(radioButtonRegistry.get(form2)!.get('sbb-group-1')).to.be.undefined;
+ });
+ });
});
});
});
From 514d19d2494ff3508e3304743f8e58da86cf6d65 Mon Sep 17 00:00:00 2001
From: Tommmaso Menga
Date: Thu, 14 Nov 2024 09:11:51 +0100
Subject: [PATCH 30/30] test(radio-button-registry): add spec tests
---
.../radio-button/common/radio-button-common.spec.ts | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts
index 136a9f8555..fedd394ef3 100644
--- a/src/elements/radio-button/common/radio-button-common.spec.ts
+++ b/src/elements/radio-button/common/radio-button-common.spec.ts
@@ -617,13 +617,14 @@ describe(`radio-button common behaviors`, () => {
});
it('should remove empty entries from the registry', async () => {
- const group1Set = radioButtonRegistry.get(form)!.get('sbb-group-1')!;
+ const group2Set = radioButtonRegistry.get(form2)!.get('sbb-group-1')!;
- form.remove();
+ // Remove the second radio group from the DOM
+ form2.remove();
await waitForLitRender(root);
- expect(group1Set.size).to.be.equal(0);
- expect(radioButtonRegistry.get(form)!.get('sbb-group-1')).to.be.undefined;
+ 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;
});
});