diff --git a/src/elements/checkbox/checkbox-panel/readme.md b/src/elements/checkbox/checkbox-panel/readme.md index 37d678e681..23536063b9 100644 --- a/src/elements/checkbox/checkbox-panel/readme.md +++ b/src/elements/checkbox/checkbox-panel/readme.md @@ -71,23 +71,26 @@ The component provides the same accessibility features as the native checkbox. Always provide an accessible label via `aria-label` for checkboxes without descriptive text content. If you don't want the label to appear next to the checkbox, you can use `aria-label` to specify an appropriate label. + ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| --------------- | --------------- | ------- | --------------------------------- | --------- | ----------------------------------------------------------- | -| `borderless` | `borderless` | public | `boolean` | `false` | Whether the unselected panel has a border. | -| `checked` | `checked` | public | `boolean` | `false` | Whether the checkbox is checked. | -| `color` | `color` | public | `'white' \| 'milk'` | `'white'` | The background color of the panel. | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. | -| `group` | - | public | `SbbCheckboxGroupElement \| null` | `null` | Reference to the connected checkbox group. | -| `indeterminate` | `indeterminate` | public | `boolean` | `false` | Whether the checkbox is indeterminate. | -| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | -| `required` | `required` | public | `boolean` | `false` | Whether the component is required. | -| `size` | `size` | public | `SbbPanelSize` | `'m'` | Size variant. | -| `value` | `value` | public | `string \| null` | `null` | Value of the form element. | +| Name | Attribute | Privacy | Type | Default | Description | +| --------------- | --------------- | ------- | --------------------------------- | --------- | -------------------------------------------------------------- | +| `borderless` | `borderless` | public | `boolean` | `false` | Whether the unselected panel has a border. | +| `checked` | `checked` | public | `boolean` | `false` | Whether the checkbox is checked. | +| `color` | `color` | public | `'white' \| 'milk'` | `'white'` | The background color of the panel. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. | +| `group` | - | public | `SbbCheckboxGroupElement \| null` | `null` | Reference to the connected checkbox group. | +| `indeterminate` | `indeterminate` | public | `boolean` | `false` | Whether the checkbox is indeterminate. | +| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | +| `required` | `required` | public | `boolean` | `false` | Whether the component is required. | +| `size` | `size` | public | `SbbPanelSize` | `'m'` | Size variant. | +| `value` | `value` | public | `string \| null` | `null` | Value of the form element. | ## Events diff --git a/src/elements/checkbox/checkbox/readme.md b/src/elements/checkbox/checkbox/readme.md index 389f60190c..ce419ee5ea 100644 --- a/src/elements/checkbox/checkbox/readme.md +++ b/src/elements/checkbox/checkbox/readme.md @@ -77,6 +77,9 @@ If you don't want the label to appear next to the checkbox, you can use `aria-la ``` + ## Properties @@ -85,7 +88,7 @@ If you don't want the label to appear next to the checkbox, you can use `aria-la | --------------- | ---------------- | ------- | --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | | `checked` | `checked` | public | `boolean` | `false` | Whether the checkbox is checked. | | `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of internals target element. | +| `form` | - | public | `HTMLFormElement \| null` | | Returns the form owner of the internals of the target element. | | `group` | - | public | `SbbCheckboxGroupElement \| null` | `null` | Reference to the connected checkbox group. | | `iconName` | `icon-name` | public | `string \| undefined` | | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | | `iconPlacement` | `icon-placement` | public | `SbbIconPlacement` | `'end'` | The label position relative to the labelIcon. Defaults to end | diff --git a/src/elements/core/mixins/form-associated-checkbox-mixin.ts b/src/elements/core/mixins/form-associated-checkbox-mixin.ts index 9d2eeae27c..d651ee40ed 100644 --- a/src/elements/core/mixins/form-associated-checkbox-mixin.ts +++ b/src/elements/core/mixins/form-associated-checkbox-mixin.ts @@ -35,6 +35,7 @@ export declare abstract class SbbFormAssociatedCheckboxMixinType protected isDisabledExternally(): boolean; protected isRequiredExternally(): boolean; protected withUserInteraction?(): void; + protected updateFormValue(): void; } /** diff --git a/src/elements/core/mixins/form-associated-mixin.ts b/src/elements/core/mixins/form-associated-mixin.ts index 7ede1464f7..b25472b884 100644 --- a/src/elements/core/mixins/form-associated-mixin.ts +++ b/src/elements/core/mixins/form-associated-mixin.ts @@ -1,15 +1,15 @@ import type { LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; -import type { Constructor } from './constructor.js'; +import type { AbstractConstructor } from './constructor.js'; -export declare abstract class SbbFormAssociatedMixinType { +export declare abstract class SbbFormAssociatedMixinType { public get form(): HTMLFormElement | null; public get name(): string; public set name(value: string); public get type(): string; - public get value(): string | null; - public set value(value: string | null); + public get value(): V | null; + public set value(value: V | null); public get validity(): ValidityState; public get validationMessage(): string; @@ -29,24 +29,24 @@ export declare abstract class SbbFormAssociatedMixinType { reason: FormRestoreReason, ): void; - protected updateFormValue(): void; + protected abstract updateFormValue(): void; } /** * The FormAssociatedMixin enables native form support for custom controls. */ // eslint-disable-next-line @typescript-eslint/naming-convention -export const SbbFormAssociatedMixin = >( +export const SbbFormAssociatedMixin = , V = string>( superClass: T, -): Constructor & T => { +): AbstractConstructor> & T => { abstract class SbbFormAssociatedElement extends superClass - implements Partial + implements Partial> { public static formAssociated = true; /** - * Returns the form owner of internals target element. + * Returns the form owner of the internals of the target element. */ public get form(): HTMLFormElement | null { return this.internals.form; @@ -73,14 +73,14 @@ export const SbbFormAssociatedMixin = >( /** Value of the form element. */ @property() - public set value(value: string | null) { + public set value(value: V | null) { this._value = value; this.updateFormValue(); } - public get value(): string | null { + public get value(): V | null { return this._value; } - private _value: string | null = null; + private _value: V | null = null; /** * Returns the ValidityState object for internals target element. @@ -192,12 +192,15 @@ export const SbbFormAssociatedMixin = >( reason: FormRestoreReason, ): void; - /** Should be called when form value is changed. */ - protected updateFormValue(): void { - this.internals.setFormValue(this.value); - } + /** + * Should be called when form value is changed. + * Adapts and sets the formValue in the supported format (string | FormData | File | null) + * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue + */ + protected abstract updateFormValue(): void; } - return SbbFormAssociatedElement as unknown as Constructor & T; + return SbbFormAssociatedElement as unknown as AbstractConstructor> & + T; }; /** diff --git a/src/elements/core/mixins/required-mixin.ts b/src/elements/core/mixins/required-mixin.ts index 476df7f145..b9191a86a5 100644 --- a/src/elements/core/mixins/required-mixin.ts +++ b/src/elements/core/mixins/required-mixin.ts @@ -15,7 +15,8 @@ export declare class SbbRequiredMixinType { */ // eslint-disable-next-line @typescript-eslint/naming-convention export const SbbRequiredMixin = < - T extends AbstractConstructor, + T extends AbstractConstructor>, + V, >( superClass: T, ): AbstractConstructor & T => { diff --git a/src/elements/select/readme.md b/src/elements/select/readme.md index 39c418ec57..2f93be7a00 100644 --- a/src/elements/select/readme.md +++ b/src/elements/select/readme.md @@ -111,20 +111,25 @@ Opened panel: | ShiftUp Arrow | If `multiple`, moves to the next non-disabled option and toggle its selection. | | Any char or number | If exists, select the first non-disabled matching option after the selected value. | + ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ------------- | ------------- | ------- | --------------------------------- | ------- | ------------------------------------------------------------------------ | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `isOpen` | - | public | `boolean` | | Whether the element is open. | -| `multiple` | `multiple` | public | `boolean` | `false` | Whether the select allows for multiple selection. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | -| `placeholder` | `placeholder` | public | `string \| undefined` | | The placeholder used if no value has been selected. | -| `readonly` | `readonly` | public | `boolean` | `false` | Whether the select is readonly. | -| `required` | `required` | public | `boolean` | `false` | Whether the select is required. | -| `value` | `value` | public | `string \| string[] \| undefined` | | The value of the select component. If `multiple` is true, it's an array. | +| Name | Attribute | Privacy | Type | Default | Description | +| ------------- | ------------- | ------- | ---------------------------- | ------- | -------------------------------------------------------------- | +| `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. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | +| `multiple` | `multiple` | public | `boolean` | `false` | Whether the select allows for multiple selection. | +| `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `placeholder` | `placeholder` | public | `string \| undefined` | | The placeholder used if no value has been selected. | +| `readonly` | `readonly` | public | `boolean` | `false` | Whether the select is readonly. | +| `required` | `required` | public | `boolean` | `false` | Whether the component is required. | +| `value` | `value` | public | `string \| string[] \| null` | `null` | Value of the form element. | ## Methods diff --git a/src/elements/select/select.spec.ts b/src/elements/select/select.spec.ts index 6b45d56260..cc67d631ac 100644 --- a/src/elements/select/select.spec.ts +++ b/src/elements/select/select.spec.ts @@ -10,389 +10,550 @@ import { SbbOptionElement } from '../option.js'; import { SbbSelectElement } from './select.js'; describe(`sbb-select`, () => { - let element: SbbSelectElement, - focusableElement: HTMLElement, - firstOption: SbbOptionElement, - secondOption: SbbOptionElement, - thirdOption: SbbOptionElement, - displayValue: HTMLElement, - comboBoxElement: HTMLElement; - - beforeEach(async () => { - const root = await fixture(html` -
- - First - Second - Third - -
- `); - element = root.querySelector('sbb-select')!; - - comboBoxElement = root.querySelector('[role="combobox"]')!; - focusableElement = comboBoxElement; - firstOption = element.querySelector('#option-1')!; - secondOption = element.querySelector('#option-2')!; - thirdOption = element.querySelector('#option-3')!; - displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger')!; + describe('common behavior', () => { + let element: SbbSelectElement, + focusableElement: HTMLElement, + firstOption: SbbOptionElement, + secondOption: SbbOptionElement, + thirdOption: SbbOptionElement, + displayValue: HTMLElement, + comboBoxElement: HTMLElement; + + beforeEach(async () => { + const root = await fixture(html` +
+ + First + Second + Third + +
+ `); + element = root.querySelector('sbb-select')!; + + comboBoxElement = root.querySelector('[role="combobox"]')!; + focusableElement = comboBoxElement; + firstOption = element.querySelector('#option-1')!; + secondOption = element.querySelector('#option-2')!; + thirdOption = element.querySelector('#option-3')!; + displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger')!; + }); + + it('renders', async () => { + assert.instanceOf(element, SbbSelectElement); + assert.instanceOf(firstOption, SbbOptionElement); + }); + + it('opens and closes the dialog', async () => { + const willOpen = new EventSpy(SbbSelectElement.events.willOpen); + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + const willClose = new EventSpy(SbbSelectElement.events.willClose); + const didClose = new EventSpy(SbbSelectElement.events.didClose); + element.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + await waitForCondition(() => willOpen.events.length === 1); + expect(willOpen.count).to.be.equal(1); + await waitForCondition(() => didOpen.events.length === 1); + + expect(didOpen.count).to.be.equal(1); + await waitForLitRender(element); + + expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); + + element.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + await waitForCondition(() => willClose.events.length === 1); + expect(willClose.count).to.be.equal(1); + await waitForCondition(() => didClose.events.length === 1); + + expect(didClose.count).to.be.equal(1); + await waitForLitRender(element); + + expect(comboBoxElement).to.have.attribute('aria-expanded', 'false'); + }); + + it('displays placeholder if no value is set and there is no selected element', async () => { + expect(element.value).to.be.null; + const placeholder = element.shadowRoot!.querySelector('.sbb-select__trigger--placeholder'); + expect(placeholder).not.to.be.null; + expect(placeholder).to.have.trimmed.text('Placeholder'); + }); + + it("displays value if it's set, or placeholder if value doesn't match available options", async () => { + expect(displayValue).to.have.trimmed.text('Placeholder'); + + element.value = '1'; + await waitForLitRender(element); + expect(displayValue).to.have.trimmed.text('First'); + expect(firstOption).to.have.attribute('selected'); + expect(secondOption).not.to.have.attribute('selected'); + expect(thirdOption).not.to.have.attribute('selected'); + + element.value = '000000000'; + await waitForLitRender(element); + expect(displayValue).to.have.trimmed.text('Placeholder'); + expect(firstOption).not.to.have.attribute('selected'); + expect(secondOption).not.to.have.attribute('selected'); + expect(thirdOption).not.to.have.attribute('selected'); + }); + + it("displays joined string if both multiple and value props are set, or placeholder if value doesn't match available options", async () => { + expect(displayValue).to.have.trimmed.text('Placeholder'); + element.toggleAttribute('multiple', true); + element.value = ['1', '3']; + await waitForLitRender(element); + expect(displayValue).to.have.trimmed.text('First, Third'); + expect(firstOption).to.have.attribute('selected'); + expect(secondOption).not.to.have.attribute('selected'); + expect(thirdOption).to.have.attribute('selected'); + + /** + * Custom implementation + * If an invalid value is set, we keep it and show the empty placeholder. + * Meanwhile, the native select ignores it and set and empty value. + */ + element.value = '000000000'; + await waitForLitRender(element); + expect(element.value).to.be.equal('000000000'); + expect(displayValue).to.have.trimmed.text('Placeholder'); + expect(firstOption).not.to.have.attribute('selected'); + expect(secondOption).not.to.have.attribute('selected'); + expect(thirdOption).not.to.have.attribute('selected'); + }); + + it("displays value if it's set with 'wrong' selected attributes on sbb-options", async () => { + const root = await fixture(html` +
+ + First + Second + Third + +
+ `); + element = root.querySelector('sbb-select')!; + + const displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger'); + const firstOption = element.querySelector('#option-1'); + const secondOption = element.querySelector('#option-2'); + const thirdOption = element.querySelector('#option-3'); + + expect(element.value).to.be.equal('2'); + expect(displayValue).to.have.trimmed.text('Second'); + expect(firstOption).not.to.have.attribute('selected'); + expect(secondOption).to.have.attribute('selected'); + expect(thirdOption).not.to.have.attribute('selected'); + }); + + it('display selected sbb-option if no value is set, then handles selection', async () => { + const root = await fixture(html` +
+ + First + Second + Third + +
+ `); + element = root.querySelector('sbb-select')!; + comboBoxElement = root.querySelector('[role="combobox"]')!; + focusableElement = comboBoxElement; + + const displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger'); + expect(displayValue).to.have.trimmed.text('First'); + expect(element.value).to.be.equal('1'); + + const willOpen = new EventSpy(SbbSelectElement.events.willOpen); + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + element.click(); + + await waitForCondition(() => willOpen.events.length === 1); + expect(willOpen.count).to.be.equal(1); + await waitForCondition(() => didOpen.events.length === 1); + + expect(didOpen.count).to.be.equal(1); + await waitForLitRender(element); + + firstOption = element.querySelector('#option-1')!; + expect(firstOption).not.to.have.attribute('data-active'); + expect(firstOption).to.have.attribute('selected'); + secondOption = element.querySelector('#option-2')!; + expect(secondOption).not.to.have.attribute('data-active'); + expect(secondOption).not.to.have.attribute('selected'); + + const selectionChange = new EventSpy(SbbOptionElement.events.selectionChange); + const optionSelected = new EventSpy(SbbOptionElement.events.optionSelected); + const willClose = new EventSpy(SbbSelectElement.events.willClose); + const didClose = new EventSpy(SbbSelectElement.events.didClose); + + secondOption.click(); + await waitForLitRender(element); + + // Event received, panel is closed + expect(selectionChange.count).to.be.equal(1); + expect(optionSelected.count).to.be.equal(1); + + await waitForCondition(() => willClose.events.length === 1); + expect(willClose.count).to.be.equal(1); + await waitForCondition(() => didClose.events.length === 1); + expect(didClose.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element.value).to.be.equal('2'); + expect(comboBoxElement).to.have.attribute('aria-expanded', 'false'); + }); + + it('handles selection in multiple', async () => { + element.toggleAttribute('multiple', true); + await waitForLitRender(element); + + const willOpen = new EventSpy(SbbSelectElement.events.willOpen); + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + element.dispatchEvent(new CustomEvent('click')); + + await waitForCondition(() => willOpen.events.length === 1); + expect(willOpen.count).to.be.equal(1); + await waitForCondition(() => didOpen.events.length === 1); + expect(didOpen.count).to.be.equal(1); + await waitForLitRender(element); + expect(firstOption).not.to.have.attribute('data-active'); + expect(firstOption).not.to.have.attribute('selected'); + expect(secondOption).not.to.have.attribute('data-active'); + expect(secondOption).not.to.have.attribute('selected'); + + const selectionChange = new EventSpy(SbbOptionElement.events.selectionChange); + firstOption.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + expect(selectionChange.count).to.be.equal(1); + expect(element.value).to.be.eql(['1']); + expect(displayValue).to.have.trimmed.text('First'); + + secondOption.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + expect(selectionChange.count).to.be.equal(2); + expect(element.value).to.be.eql(['1', '2']); + expect(displayValue).to.have.trimmed.text('First, Second'); + + firstOption.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + expect(element.value).to.be.eql(['2']); + secondOption.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + expect(element.value).to.be.eql([]); + expect(displayValue).to.have.trimmed.text('Placeholder'); + // Panel is still open + expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); + }); + + it('handles keypress on host', async () => { + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + const didClose = new EventSpy(SbbSelectElement.events.didClose); + + focusableElement.focus(); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(element); + await waitForCondition(() => didOpen.events.length === 1); + expect(didOpen.count).to.be.equal(1); + + focusableElement.focus(); + await sendKeys({ press: 'Escape' }); + await waitForLitRender(element); + await waitForCondition(() => didClose.events.length === 1); + expect(didClose.count).to.be.equal(1); + + focusableElement.focus(); + await sendKeys({ press: 'ArrowDown' }); + await waitForCondition(() => didOpen.events.length === 2); + expect(didOpen.count).to.be.equal(2); + + focusableElement.focus(); + await sendKeys({ press: tabKey }); + await waitForCondition(() => didClose.events.length === 2); + expect(didClose.count).to.be.equal(2); + + focusableElement.focus(); + await sendKeys({ press: 'F' }); + await waitForLitRender(element); + expect(didOpen.count).to.be.equal(2); + expect(didClose.count).to.be.equal(2); + expect(displayValue).to.have.trimmed.text('First'); + + await aTimeout(1100); // wait for the reset of _searchString timeout + + focusableElement.focus(); + await sendKeys({ press: 'S' }); + await waitForLitRender(element); + expect(didOpen.count).to.be.equal(2); + expect(didClose.count).to.be.equal(2); + expect(displayValue).to.have.trimmed.text('Second'); + }); + + it('handles keyboard selection', async () => { + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + focusableElement.focus(); + await sendKeys({ press: ' ' }); + await waitForCondition(() => didOpen.events.length === 1); + expect(didOpen.count).to.be.equal(1); + expect(firstOption).not.to.have.attribute('data-active'); + expect(firstOption).not.to.have.attribute('selected'); + + focusableElement.focus(); + await sendKeys({ press: 'ArrowDown' }); + expect(firstOption).to.have.attribute('data-active'); + expect(firstOption).to.have.attribute('selected'); + expect(element.value).to.be.equal('1'); + expect(displayValue).to.have.trimmed.text('First'); + expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); + + focusableElement.focus(); + await sendKeys({ press: 'T' }); + await waitForLitRender(element); + expect(didOpen.count).to.be.equal(1); + expect(displayValue).to.have.trimmed.text('Third'); + expect(thirdOption).to.have.attribute('data-active'); + expect(thirdOption).to.have.attribute('selected'); + expect(element.value).to.be.equal('3'); + + await aTimeout(1100); // wait for the reset of _searchString timeout + + focusableElement.focus(); + await sendKeys({ press: 'S' }); + await waitForLitRender(element); + expect(didOpen.count).to.be.equal(1); + expect(displayValue).to.have.trimmed.text('Second'); + expect(secondOption).to.have.attribute('data-active'); + expect(secondOption).to.have.attribute('selected'); + expect(element.value).to.be.equal('2'); + }); + + it('handles keyboard selection in multiple', async () => { + element.toggleAttribute('multiple', true); + await waitForLitRender(element); + + const didOpen = new EventSpy(SbbSelectElement.events.didOpen); + const didClose = new EventSpy(SbbSelectElement.events.didClose); + focusableElement.focus(); + await sendKeys({ press: 'ArrowUp' }); + await waitForCondition(() => didOpen.events.length === 1); + expect(didOpen.count).to.be.equal(1); + + expect(secondOption).not.to.have.attribute('data-active'); + expect(secondOption).not.to.have.attribute('selected'); + focusableElement.focus(); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + expect(secondOption).to.have.attribute('data-active'); + expect(secondOption).to.have.attribute('selected'); + expect(element.value).to.be.eql(['2']); + expect(displayValue).to.have.trimmed.text('Second'); + + await sendKeys({ press: 'Escape' }); + await waitForCondition(() => didClose.events.length === 1); + expect(didClose.count).to.be.equal(1); + + element.focus(); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + await waitForCondition(() => didOpen.events.length === 2); + expect(didOpen.count).to.be.equal(2); + expect(secondOption).not.to.have.attribute('data-active'); + expect(secondOption).to.have.attribute('selected'); + expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); + }); + + it('correctly forward focus and blur', async () => { + element.focus(); + await waitForLitRender(element); + expect(document.activeElement).to.have.attribute('role', 'combobox'); + + element.blur(); + await waitForLitRender(element); + expect(document.activeElement).not.to.have.attribute('role', 'combobox'); + }); + + it('does not open if prevented', async () => { + const willOpenEventSpy = new EventSpy(SbbSelectElement.events.willOpen); + + element.addEventListener(SbbSelectElement.events.willOpen, (ev) => ev.preventDefault()); + element.open(); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + + it('does not close if prevented', async () => { + const didOpenEventSpy = new EventSpy(SbbSelectElement.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbSelectElement.events.willClose); + + element.open(); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + await waitForLitRender(element); + + element.addEventListener(SbbSelectElement.events.willClose, (ev) => ev.preventDefault()); + element.close(); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + }); }); - it('renders', async () => { - assert.instanceOf(element, SbbSelectElement); - assert.instanceOf(firstOption, SbbOptionElement); - }); - - it('opens and closes the dialog', async () => { - const willOpen = new EventSpy(SbbSelectElement.events.willOpen); - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - const willClose = new EventSpy(SbbSelectElement.events.willClose); - const didClose = new EventSpy(SbbSelectElement.events.didClose); - element.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen.count).to.be.equal(1); - await waitForCondition(() => didOpen.events.length === 1); - - expect(didOpen.count).to.be.equal(1); - await waitForLitRender(element); - - expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); - - element.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - await waitForCondition(() => willClose.events.length === 1); - expect(willClose.count).to.be.equal(1); - await waitForCondition(() => didClose.events.length === 1); - - expect(didClose.count).to.be.equal(1); - await waitForLitRender(element); - - expect(comboBoxElement).to.have.attribute('aria-expanded', 'false'); - }); - - it('displays placeholder if no value is set and there is no selected element', async () => { - expect(element.value).to.be.undefined; - const placeholder = element.shadowRoot!.querySelector('.sbb-select__trigger--placeholder'); - expect(placeholder).not.to.be.null; - expect(placeholder).to.have.trimmed.text('Placeholder'); - }); - - it("displays value if it's set, or placeholder if value doesn't match available options", async () => { - expect(displayValue).to.have.trimmed.text('Placeholder'); - - element.value = '1'; - await waitForLitRender(element); - expect(displayValue).to.have.trimmed.text('First'); - expect(firstOption).to.have.attribute('selected'); - expect(secondOption).not.to.have.attribute('selected'); - expect(thirdOption).not.to.have.attribute('selected'); - - element.value = '000000000'; - await waitForLitRender(element); - expect(displayValue).to.have.trimmed.text('Placeholder'); - expect(firstOption).not.to.have.attribute('selected'); - expect(secondOption).not.to.have.attribute('selected'); - expect(thirdOption).not.to.have.attribute('selected'); - }); - - it("displays joined string if both multiple and value props are set, or placeholder if value doesn't match available options", async () => { - expect(displayValue).to.have.trimmed.text('Placeholder'); - element.toggleAttribute('multiple', true); - - element.value = ['1', '3']; - await waitForLitRender(element); - expect(displayValue).to.have.trimmed.text('First, Third'); - expect(firstOption).to.have.attribute('selected'); - expect(secondOption).not.to.have.attribute('selected'); - expect(thirdOption).to.have.attribute('selected'); - - element.value = '000000000'; - await waitForLitRender(element); - expect(displayValue).to.have.trimmed.text('Placeholder'); - expect(firstOption).not.to.have.attribute('selected'); - expect(secondOption).not.to.have.attribute('selected'); - expect(thirdOption).not.to.have.attribute('selected'); - }); - - it("displays value if it's set with 'wrong' selected attributes on sbb-options", async () => { - const root = await fixture(html` -
- - First - Second - Third - -
- `); - element = root.querySelector('sbb-select')!; - - const displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger'); - const firstOption = element.querySelector('#option-1'); - const secondOption = element.querySelector('#option-2'); - const thirdOption = element.querySelector('#option-3'); - - expect(element.value).to.be.equal('2'); - expect(displayValue).to.have.trimmed.text('Second'); - expect(firstOption).not.to.have.attribute('selected'); - expect(secondOption).to.have.attribute('selected'); - expect(thirdOption).not.to.have.attribute('selected'); - }); + describe('form association', () => { + let form: HTMLFormElement; + let element: SbbSelectElement; + let comboBoxElement: HTMLElement; + let nativeSelect: HTMLSelectElement; + let fieldSet: HTMLFieldSetElement; + let elemChangeEvent: EventSpy, + elemInputEvent: EventSpy, + nativeChangeEvent: EventSpy, + nativeInputEvent: EventSpy; + + beforeEach(async () => { + form = await fixture(html` +
+
+ + First + Second + Third + + + +
+
+ `); + element = form.querySelector('sbb-select')!; + comboBoxElement = form.querySelector('[role="combobox"]')!; + nativeSelect = form.querySelector('select')!; + fieldSet = form.querySelector('fieldset')!; + + // event spies + elemChangeEvent = new EventSpy('change', element); + elemInputEvent = new EventSpy('input', element); + nativeChangeEvent = new EventSpy('change', nativeSelect); + nativeInputEvent = new EventSpy('input', nativeSelect); + + await waitForLitRender(form); + }); + + function compareToNative(skipValue?: boolean): void { + const formData = new FormData(form); + + if (!skipValue) { + expect(element.value, 'compare to native - value').to.be.equal(nativeSelect.value); + } + expect(formData.get('sbb-select'), 'compare to native - form value').to.be.equal( + formData.get('native-select'), + ); + expect(elemChangeEvent.count, 'compare to native - change counts').to.be.equal( + nativeChangeEvent.count, + ); + expect(elemInputEvent.count, 'compare to native - input counts').to.be.equal( + nativeInputEvent.count, + ); + } + + it('should set default value', async () => { + expect(element.value).to.be.equal('2'); + compareToNative(); + }); + + it('should handle invalid values', async () => { + element.value = nativeSelect.value = '4'; + await waitForLitRender(form); + + /** + * Custom implementation + * If an invalid value is set, we keep it and show the empty placeholder. + * Meanwhile, the native select ignores it and set and empty value. + */ + expect(element.value).to.be.equal('4'); + }); + + it('should handle multiple values', async () => { + element.multiple = nativeSelect.multiple = true; + + element.value = ['1', '3']; + nativeSelect.options[0].selected = nativeSelect.options[2].selected = true; + await waitForLitRender(form); + + expect(element.value).to.be.eql(['1', '3']); + + // The native select does not handle multiple values, so we expect the value to differ + compareToNative(true); + }); + + it('should result :disabled', async () => { + element.disabled = true; + nativeSelect.disabled = true; + + await waitForLitRender(form); + + expect(element).to.match(':disabled'); + expect(comboBoxElement.tabIndex).to.be.equal(-1); + compareToNative(); + + element.disabled = false; + await waitForLitRender(element); + + expect(element).not.to.match(':disabled'); + expect(comboBoxElement.tabIndex).to.be.equal(0); + }); + + it('should result :disabled if a fieldSet is', async () => { + fieldSet.disabled = true; + + await waitForLitRender(form); + + expect(element).to.match(':disabled'); + expect(comboBoxElement.tabIndex).to.be.equal(-1); + compareToNative(); + + fieldSet.disabled = false; + await waitForLitRender(element); + + expect(element).not.to.match(':disabled'); + expect(comboBoxElement.tabIndex).to.be.equal(0); + }); + + it('should restore form state on formStateRestoreCallback()', async () => { + // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. + element.formStateRestoreCallback('3', 'restore'); + await waitForLitRender(element); + + expect(element.value).to.be.equal('3'); - it('display selected sbb-option if no value is set, then handles selection', async () => { - const root = await fixture(html` -
- - First - Second - Third - -
- `); - element = root.querySelector('sbb-select')!; - comboBoxElement = root.querySelector('[role="combobox"]')!; - focusableElement = comboBoxElement; - - const displayValue = element.shadowRoot!.querySelector('.sbb-select__trigger'); - expect(displayValue).to.have.trimmed.text('First'); - expect(element.value).to.be.equal('1'); - - const willOpen = new EventSpy(SbbSelectElement.events.willOpen); - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - element.click(); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen.count).to.be.equal(1); - await waitForCondition(() => didOpen.events.length === 1); - - expect(didOpen.count).to.be.equal(1); - await waitForLitRender(element); - - firstOption = element.querySelector('#option-1')!; - expect(firstOption).not.to.have.attribute('data-active'); - expect(firstOption).to.have.attribute('selected'); - secondOption = element.querySelector('#option-2')!; - expect(secondOption).not.to.have.attribute('data-active'); - expect(secondOption).not.to.have.attribute('selected'); - - const selectionChange = new EventSpy(SbbOptionElement.events.selectionChange); - const optionSelected = new EventSpy(SbbOptionElement.events.optionSelected); - const willClose = new EventSpy(SbbSelectElement.events.willClose); - const didClose = new EventSpy(SbbSelectElement.events.didClose); - - secondOption.click(); - await waitForLitRender(element); - - // Event received, panel is closed - expect(selectionChange.count).to.be.equal(1); - expect(optionSelected.count).to.be.equal(1); - - await waitForCondition(() => willClose.events.length === 1); - expect(willClose.count).to.be.equal(1); - await waitForCondition(() => didClose.events.length === 1); - expect(didClose.count).to.be.equal(1); - await waitForLitRender(element); - - expect(element.value).to.be.equal('2'); - expect(comboBoxElement).to.have.attribute('aria-expanded', 'false'); - }); - - it('handles selection in multiple', async () => { - element.toggleAttribute('multiple', true); - await waitForLitRender(element); - - const willOpen = new EventSpy(SbbSelectElement.events.willOpen); - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - element.dispatchEvent(new CustomEvent('click')); - - await waitForCondition(() => willOpen.events.length === 1); - expect(willOpen.count).to.be.equal(1); - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen.count).to.be.equal(1); - await waitForLitRender(element); - expect(firstOption).not.to.have.attribute('data-active'); - expect(firstOption).not.to.have.attribute('selected'); - expect(secondOption).not.to.have.attribute('data-active'); - expect(secondOption).not.to.have.attribute('selected'); - - const selectionChange = new EventSpy(SbbOptionElement.events.selectionChange); - firstOption.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - expect(selectionChange.count).to.be.equal(1); - expect(element.value).to.be.eql(['1']); - expect(displayValue).to.have.trimmed.text('First'); - - secondOption.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - expect(selectionChange.count).to.be.equal(2); - expect(element.value).to.be.eql(['1', '2']); - expect(displayValue).to.have.trimmed.text('First, Second'); - - firstOption.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - expect(element.value).to.be.eql(['2']); - secondOption.dispatchEvent(new CustomEvent('click')); - await waitForLitRender(element); - expect(element.value).to.be.eql([]); - expect(displayValue).to.have.trimmed.text('Placeholder'); - // Panel is still open - expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); - }); - - it('handles keypress on host', async () => { - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - const didClose = new EventSpy(SbbSelectElement.events.didClose); - - focusableElement.focus(); - await sendKeys({ press: 'Enter' }); - await waitForLitRender(element); - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen.count).to.be.equal(1); - - focusableElement.focus(); - await sendKeys({ press: 'Escape' }); - await waitForLitRender(element); - await waitForCondition(() => didClose.events.length === 1); - expect(didClose.count).to.be.equal(1); - - focusableElement.focus(); - await sendKeys({ press: 'ArrowDown' }); - await waitForCondition(() => didOpen.events.length === 2); - expect(didOpen.count).to.be.equal(2); - - focusableElement.focus(); - await sendKeys({ press: tabKey }); - await waitForCondition(() => didClose.events.length === 2); - expect(didClose.count).to.be.equal(2); - - focusableElement.focus(); - await sendKeys({ press: 'F' }); - await waitForLitRender(element); - expect(didOpen.count).to.be.equal(2); - expect(didClose.count).to.be.equal(2); - expect(displayValue).to.have.trimmed.text('First'); - - await aTimeout(1100); // wait for the reset of _searchString timeout - - focusableElement.focus(); - await sendKeys({ press: 'S' }); - await waitForLitRender(element); - expect(didOpen.count).to.be.equal(2); - expect(didClose.count).to.be.equal(2); - expect(displayValue).to.have.trimmed.text('Second'); - }); - - it('handles keyboard selection', async () => { - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - focusableElement.focus(); - await sendKeys({ press: ' ' }); - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen.count).to.be.equal(1); - expect(firstOption).not.to.have.attribute('data-active'); - expect(firstOption).not.to.have.attribute('selected'); - - focusableElement.focus(); - await sendKeys({ press: 'ArrowDown' }); - expect(firstOption).to.have.attribute('data-active'); - expect(firstOption).to.have.attribute('selected'); - expect(element.value).to.be.equal('1'); - expect(displayValue).to.have.trimmed.text('First'); - expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); - - focusableElement.focus(); - await sendKeys({ press: 'T' }); - await waitForLitRender(element); - expect(didOpen.count).to.be.equal(1); - expect(displayValue).to.have.trimmed.text('Third'); - expect(thirdOption).to.have.attribute('data-active'); - expect(thirdOption).to.have.attribute('selected'); - expect(element.value).to.be.equal('3'); - - await aTimeout(1100); // wait for the reset of _searchString timeout - - focusableElement.focus(); - await sendKeys({ press: 'S' }); - await waitForLitRender(element); - expect(didOpen.count).to.be.equal(1); - expect(displayValue).to.have.trimmed.text('Second'); - expect(secondOption).to.have.attribute('data-active'); - expect(secondOption).to.have.attribute('selected'); - expect(element.value).to.be.equal('2'); - }); - - it('handles keyboard selection in multiple', async () => { - element.toggleAttribute('multiple', true); - await waitForLitRender(element); - - const didOpen = new EventSpy(SbbSelectElement.events.didOpen); - const didClose = new EventSpy(SbbSelectElement.events.didClose); - focusableElement.focus(); - await sendKeys({ press: 'ArrowUp' }); - await waitForCondition(() => didOpen.events.length === 1); - expect(didOpen.count).to.be.equal(1); - - expect(secondOption).not.to.have.attribute('data-active'); - expect(secondOption).not.to.have.attribute('selected'); - focusableElement.focus(); - await sendKeys({ press: 'ArrowDown' }); - await sendKeys({ press: 'ArrowDown' }); - await sendKeys({ press: 'Enter' }); - expect(secondOption).to.have.attribute('data-active'); - expect(secondOption).to.have.attribute('selected'); - expect(element.value).to.be.eql(['2']); - expect(displayValue).to.have.trimmed.text('Second'); - - await sendKeys({ press: 'Escape' }); - await waitForCondition(() => didClose.events.length === 1); - expect(didClose.count).to.be.equal(1); - - element.focus(); - await sendKeys({ press: 'ArrowDown' }); - await waitForLitRender(element); - await waitForCondition(() => didOpen.events.length === 2); - expect(didOpen.count).to.be.equal(2); - expect(secondOption).not.to.have.attribute('data-active'); - expect(secondOption).to.have.attribute('selected'); - expect(comboBoxElement).to.have.attribute('aria-expanded', 'true'); - }); - - it('correctly forward focus and blur', async () => { - element.focus(); - await waitForLitRender(element); - expect(document.activeElement).to.have.attribute('role', 'combobox'); - - element.blur(); - await waitForLitRender(element); - expect(document.activeElement).not.to.have.attribute('role', 'combobox'); - }); + element.multiple = true; + await waitForLitRender(element); - it('does not open if prevented', async () => { - const willOpenEventSpy = new EventSpy(SbbSelectElement.events.willOpen); - - element.addEventListener(SbbSelectElement.events.willOpen, (ev) => ev.preventDefault()); - element.open(); - - await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy.count).to.be.equal(1); - await waitForLitRender(element); - - expect(element).to.have.attribute('data-state', 'closed'); - }); + const formData = new FormData(); + formData.append(element.name, '1'); + formData.append(element.name, '2'); - it('does not close if prevented', async () => { - const didOpenEventSpy = new EventSpy(SbbSelectElement.events.didOpen); - const willCloseEventSpy = new EventSpy(SbbSelectElement.events.willClose); + element.formStateRestoreCallback(Array.from(formData.entries()), 'restore'); + await waitForLitRender(element); - element.open(); - await waitForCondition(() => didOpenEventSpy.events.length === 1); - await waitForLitRender(element); + expect(element.value).to.be.eql(['1', '2']); + }); - element.addEventListener(SbbSelectElement.events.willClose, (ev) => ev.preventDefault()); - element.close(); + it('should reset on form reset', async () => { + element.value = nativeSelect.value = '3'; - await waitForCondition(() => willCloseEventSpy.events.length === 1); - await waitForLitRender(element); + form.reset(); + await waitForLitRender(form); - expect(element).to.have.attribute('data-state', 'opened'); + expect(element.value).to.be.equal('2'); + compareToNative(); + }); }); }); diff --git a/src/elements/select/select.ts b/src/elements/select/select.ts index 7b8543cdcc..e276ce8f53 100644 --- a/src/elements/select/select.ts +++ b/src/elements/select/select.ts @@ -12,9 +12,13 @@ import { hostAttributes } from '../core/decorators.js'; import { isNextjs, isSafari } from '../core/dom.js'; import { EventEmitter } from '../core/eventing.js'; import { + type FormRestoreReason, + type FormRestoreState, SbbDisabledMixin, + SbbFormAssociatedMixin, SbbHydrationMixin, SbbNegativeMixin, + SbbRequiredMixin, SbbUpdateSchedulerMixin, } from '../core/mixins.js'; import { isEventOnElement, overlayGapFixCorners, setOverlayPosition } from '../core/overlay.js'; @@ -55,7 +59,17 @@ export interface SelectChange { role: ariaRoleOnHost ? 'listbox' : null, }) export class SbbSelectElement extends SbbUpdateSchedulerMixin( - SbbDisabledMixin(SbbNegativeMixin(SbbHydrationMixin(SbbOpenCloseBaseElement))), + SbbDisabledMixin( + SbbNegativeMixin( + SbbHydrationMixin( + SbbRequiredMixin( + SbbFormAssociatedMixin( + SbbOpenCloseBaseElement, + ), + ), + ), + ), + ), ) { public static override styles: CSSResultGroup = style; @@ -71,17 +85,11 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( didClose: 'didClose', } as const; - /** The value of the select component. If `multiple` is true, it's an array. */ - @property() public value?: string | string[]; - /** The placeholder used if no value has been selected. */ @property() public placeholder?: string; /** Whether the select allows for multiple selection. */ - @property({ type: Boolean, reflect: true }) public multiple = false; - - /** Whether the select is required. */ - @property({ reflect: true, type: Boolean }) public required = false; + @property({ reflect: true, type: Boolean }) public multiple = false; /** Whether the select is readonly. */ @property({ type: Boolean }) public readonly = false; @@ -167,7 +175,13 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( /** Opens the selection panel. */ public open(): void { - if (this.state !== 'closed' || !this._overlay || this._options.length === 0) { + if ( + this.state !== 'closed' || + !this._overlay || + this._options.length === 0 || + this.disabled || + this.formDisabled + ) { return; } @@ -316,7 +330,8 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( protected override willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); - if (changedProperties.has('value')) { + // On initialization, the '_onValueChanged' is called by the connectedCallback + if (changedProperties.has('value') && this._didLoad) { this._onValueChanged(this.value!); } if (changedProperties.has('negative') || changedProperties.has('multiple')) { @@ -330,6 +345,44 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( this._openPanelEventsController?.abort(); } + protected override updateFormValue(): void { + if (this.multiple && this.value instanceof Array) { + const data = new FormData(); + (this.value as string[]).forEach((el) => data.append(this.name, el)); + this.internals.setFormValue(data); + } else { + this.internals.setFormValue(this.value as string | null); + } + } + + /** + * The reset value is the attribute value (the setup value), null otherwise. + * @internal + */ + public formResetCallback(): void { + this.value = this.hasAttribute('value') ? this.getAttribute('value') : null; + } + + /** + * @internal + */ + public formStateRestoreCallback( + state: FormRestoreState | null, + _reason: FormRestoreReason, + ): void { + if (!state) { + this.value = null; + return; + } + + if (this.multiple) { + // if multiple, the state format is ['field-name', 'value'][] + this.value = (state as [string, string][]).map((entries) => entries[1]); + } else { + this.value = state as string; + } + } + private _syncProperties(): void { this.querySelectorAll?.('sbb-divider').forEach((element) => (element.negative = this.negative)); @@ -468,7 +521,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( } private _onKeyDown(event: KeyboardEvent): void { - if (this.disabled || this.readonly) { + if (this.readonly) { return; } @@ -497,7 +550,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( } private _openedPanelKeyboardInteraction(event: KeyboardEvent): void { - if (this.disabled || this.readonly || this.state !== 'opened') { + if (this.readonly || this.state !== 'opened') { return; } @@ -690,7 +743,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( } private _toggleOpening(): void { - if (this.disabled || this.readonly) { + if (this.disabled || this.formDisabled || this.readonly) { return; } this._triggerElement?.focus(); @@ -728,7 +781,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( readers to work properly -->