diff --git a/src/elements/autocomplete-grid.ts b/src/elements/autocomplete-grid.ts new file mode 100644 index 0000000000..e8733d6230 --- /dev/null +++ b/src/elements/autocomplete-grid.ts @@ -0,0 +1,6 @@ +export * from './autocomplete-grid/autocomplete-grid.js'; +export * from './autocomplete-grid/autocomplete-grid-button.js'; +export * from './autocomplete-grid/autocomplete-grid-cell.js'; +export * from './autocomplete-grid/autocomplete-grid-optgroup.js'; +export * from './autocomplete-grid/autocomplete-grid-option.js'; +export * from './autocomplete-grid/autocomplete-grid-row.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button.ts b/src/elements/autocomplete-grid/autocomplete-grid-button.ts new file mode 100644 index 0000000000..5621819751 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-button/autocomplete-grid-button.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.snapshot.spec.snap.js new file mode 100644 index 0000000000..6e9ab44f1f --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.snapshot.spec.snap.js @@ -0,0 +1,116 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-button renders DOM"] = +` + +`; +/* end snapshot sbb-autocomplete-grid-button renders DOM */ + +snapshots["sbb-autocomplete-grid-button renders Shadow DOM"] = +` + + + + +`; +/* end snapshot sbb-autocomplete-grid-button renders Shadow DOM */ + +snapshots["sbb-autocomplete-grid-button renders disabled DOM"] = +` + +`; +/* end snapshot sbb-autocomplete-grid-button renders disabled DOM */ + +snapshots["sbb-autocomplete-grid-button renders disabled Shadow DOM"] = +` + + + + +`; +/* end snapshot sbb-autocomplete-grid-button renders disabled Shadow DOM */ + +snapshots["sbb-autocomplete-grid-button renders negative without icon DOM"] = +` + +`; +/* end snapshot sbb-autocomplete-grid-button renders negative without icon DOM */ + +snapshots["sbb-autocomplete-grid-button renders negative without icon Shadow DOM"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-button renders negative without icon Shadow DOM */ + +snapshots["sbb-autocomplete-grid-button A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "button", + "name": "" + } + ] +} +

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

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

+`; +/* end snapshot sbb-autocomplete-grid-button A11y tree Firefox */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss new file mode 100644 index 0000000000..43a5b2d3c4 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss @@ -0,0 +1,15 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; + display: block; + + --sbb-button-display: flex; +} + +@include sbb.icon-button('.sbb-autocomplete-grid-button', '::slotted(sbb-icon), sbb-icon'); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.snapshot.spec.ts new file mode 100644 index 0000000000..7654417e29 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.snapshot.spec.ts @@ -0,0 +1,104 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; +import { waitForLitRender } from '../../core/testing.js'; + +import type { SbbAutocompleteGridButtonElement } from './autocomplete-grid-button.js'; +import '../../form-field.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import './autocomplete-grid-button.js'; + +describe('sbb-autocomplete-grid-button', () => { + describe('renders', () => { + let root: SbbAutocompleteGridButtonElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-button')!; + await waitForLitRender(root); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders disabled', () => { + let root: SbbAutocompleteGridButtonElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-button')!; + await waitForLitRender(root); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders negative without icon', () => { + let root: SbbAutocompleteGridButtonElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + + + + + + + + + `) + ).querySelector('sbb-autocomplete-grid-button')!; + await waitForLitRender(root); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + testA11yTreeSnapshot( + html``, + ); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts new file mode 100644 index 0000000000..6863dad599 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts @@ -0,0 +1,53 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; + +import { SbbAutocompleteGridButtonElement } from './autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid-button`, () => { + let element: SbbAutocompleteGridButtonElement; + + beforeEach(async () => { + element = await fixture( + html`Button`, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridButtonElement); + }); + + describe('events', () => { + it('dispatches event on click', async () => { + const clickSpy = new EventSpy('click'); + + element.click(); + await waitForCondition(() => clickSpy.events.length === 1); + expect(clickSpy.count).to.be.equal(1); + }); + + it('should not dispatch event on click if disabled', async () => { + element.setAttribute('disabled', 'true'); + + await waitForLitRender(element); + + const clickSpy = new EventSpy('click'); + + element.click(); + expect(clickSpy.count).not.to.be.greaterThan(0); + }); + + it('should stop propagating host click if disabled', async () => { + element.disabled = true; + + const clickSpy = new EventSpy('click'); + + element.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + + expect(clickSpy.count).not.to.be.greaterThan(0); + }); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ssr.spec.ts new file mode 100644 index 0000000000..45819977ca --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ssr.spec.ts @@ -0,0 +1,21 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridButtonElement } from './autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid-button ssr`, () => { + let element: SbbAutocompleteGridButtonElement; + + beforeEach(async () => { + element = await fixture( + html`Button`, + { modules: ['./autocomplete-grid-button.ts'] }, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridButtonElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts new file mode 100644 index 0000000000..2c590b62f9 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts @@ -0,0 +1,155 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import readme from './readme.md?raw'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import './autocomplete-grid-button.js'; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Button', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Icon', + }, +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + +const active: InputType = { + control: { + type: 'boolean', + }, +}; + +const focusVisible: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + disabled, + negative, + 'icon-name': iconName, + 'aria-label': ariaLabel, + active, + focusVisible, +}; + +const defaultArgs: Args = { + disabled: false, + negative: false, + 'icon-name': 'arrow-right-small', + 'aria-label': 'arrow-right-small', + active: false, + focusVisible: false, +}; + +const Template = ({ active, focusVisible, ...args }: Args): TemplateResult => html` + + + + + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const Active: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, active: true }, +}; + +export const FocusVisible: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, focusVisible: true }, +}; + +export const NegativeDisabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, disabled: true }, +}; + +export const NegativeActive: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, active: true }, +}; + +export const NegativeFocusVisible: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, focusVisible: true }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: ['click'], + }, + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid-button', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts new file mode 100644 index 0000000000..128bc0e45e --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts @@ -0,0 +1,111 @@ +import { type CSSResultGroup, isServer, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbActionBaseElement } from '../../core/base-elements.js'; +import { hostAttributes, slotState } from '../../core/decorators.js'; +import { setOrRemoveAttribute } from '../../core/dom.js'; +import { isEventPrevented } from '../../core/eventing.js'; +import { SbbDisabledMixin, SbbNegativeMixin } from '../../core/mixins.js'; +import { AgnosticMutationObserver } from '../../core/observers.js'; +import { SbbIconNameMixin } from '../../icon.js'; +import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; + +import style from './autocomplete-grid-button.scss?lit&inline'; + +let autocompleteButtonNextId = 0; + +/** Configuration for the attribute to look at if component is nested in a sbb-optgroup */ +const buttonObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-disabled'], +}; + +/** + * It displays an icon-only button that can be used in `sbb-autocomplete-grid`. + * + * @slot icon - Slot used to display the icon, if one is set + */ +@customElement('sbb-autocomplete-grid-button') +@hostAttributes({ + role: 'button', + tabindex: null, + 'data-button': '', +}) +@slotState() +export class SbbAutocompleteGridButtonElement extends SbbDisabledMixin( + SbbNegativeMixin(SbbIconNameMixin(SbbActionBaseElement)), +) { + public static override styles: CSSResultGroup = style; + + /** Gets the SbbAutocompleteGridOptionElement on the same row of the button. */ + public get option(): SbbAutocompleteGridOptionElement | null { + return ( + this.closest('sbb-autocomplete-grid-row')?.querySelector('sbb-autocomplete-grid-option') || + null + ); + } + + /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ + private _disabledFromGroup = false; + + protected override isDisabledExternally(): boolean { + return this._disabledFromGroup ?? false; + } + + /** MutationObserver on data attributes. */ + private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.attributeName === 'data-group-disabled') { + this._disabledFromGroup = this.hasAttribute('data-group-disabled'); + setOrRemoveAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + } + } + }); + + public constructor() { + super(); + if (!isServer) { + this.setupBaseEventHandlers(); + this.addEventListener('click', this._handleButtonClick); + } + } + + protected override renderTemplate(): TemplateResult { + return super.renderIconSlot(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.id ||= `sbb-autocomplete-grid-button-${++autocompleteButtonNextId}`; + const parentGroup = this.closest('sbb-autocomplete-grid-optgroup'); + if (parentGroup) { + this._disabledFromGroup = parentGroup.disabled; + setOrRemoveAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + } + this._optionAttributeObserver.observe(this, buttonObserverConfig); + } + + public override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (changedProperties.has('disabled')) { + setOrRemoveAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._optionAttributeObserver.disconnect(); + } + + private _handleButtonClick = async (event: MouseEvent): Promise => { + if ((await isEventPrevented(event)) || !this.closest('form')) { + return; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-button': SbbAutocompleteGridButtonElement; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.visual.spec.ts new file mode 100644 index 0000000000..c15e492573 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.visual.spec.ts @@ -0,0 +1,69 @@ +import { html, type TemplateResult } from 'lit'; + +import { + describeViewports, + visualDiffDefault, + visualDiffHover, +} from '../../core/testing/private.js'; + +import './autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid-button`, () => { + const defaultArgs = { + disabled: false, + negative: false, + active: false, + focusVisible: false, + }; + + const template = ({ + disabled, + negative, + active, + focusVisible, + }: typeof defaultArgs): TemplateResult => html` + + `; + + describeViewports({ viewports: ['zero', 'medium'] }, () => { + for (const negative of [false, true]) { + const wrapperStyle = { + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + for (const disabled of [false, true]) { + for (const state of [visualDiffDefault, visualDiffHover]) { + it( + `negative=${negative} disabled=${disabled} ${state.name}`, + state.with(async (setup) => { + const args = { ...defaultArgs, negative, disabled }; + await setup.withFixture(template(args), wrapperStyle); + }), + ); + } + } + + it( + `negative=${negative} active`, + visualDiffDefault.with(async (setup) => { + const args = { ...defaultArgs, negative, active: true }; + await setup.withFixture(template(args), wrapperStyle); + }), + ); + + it( + `negative=${negative} focus-visible`, + visualDiffDefault.with(async (setup) => { + const args = { ...defaultArgs, negative, focusVisible: true }; + await setup.withFixture(template(args), { ...wrapperStyle, focusOutlineDark: negative }); + }), + ); + } + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-button/readme.md b/src/elements/autocomplete-grid/autocomplete-grid-button/readme.md new file mode 100644 index 0000000000..efead5ba74 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-button/readme.md @@ -0,0 +1,109 @@ +The `sbb-autocomplete-grid-button` component has the same appearance of a [sbb-mini-button](/docs/elements-sbb-button-sbb-mini-button--docs), +but it's only designed to be used within the [sbb-autocomplete-grid-cell](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-cell--docs) +inside a [sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid--docs). + +```html + + + + + Option 1 + + + + + + Option 2 + + + + + + +``` + +## Slots + +The component can display a `sbb-icon` using the `iconName` property or via custom content using the `icon` slot. + +```html + + + + + +``` + +## Style + +The component has a negative variant which can be set using the `negative` property. + +The component can be displayed in `disabled` state using the self-named property. + +```html + + + +``` + +If the component is used within a [sbb-autocomplete-grid-optgroup](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-optgroup--docs), +it can be disabled by disabling the optgroup. + +## Interactions + +When the button is clicked, an event is triggered; the behavior is up to the consumer. +It's possible to fetch the button's related `sbb-autocomplete-grid-option` using the `option` property. + +```html + + + + + Option 1 + + + + + + + + +``` + +## Accessibility + +The `sbb-autocomplete-grid` follows the combobox `grid` pattern; +this means that the `sbb-autocomplete-grid-button` has a `button` role and its `id` is set based on the `sbb-autocomplete-grid-cell`'s `id`, +which is needed to correctly set the `aria-activedescendant` on the related `input`. +Moreover, the `sbb-autocomplete-grid-button` can't be focused via Tab due to the used pattern, +since the focus must always stay on the connected ``. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ------------------------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `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. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `option` | - | public | `SbbAutocompleteGridOptionElement \| null` | | Gets the SbbAutocompleteGridOptionElement on the same row of the button. | + +## Slots + +| Name | Description | +| ------ | -------------------------------------------- | +| `icon` | Slot used to display the icon, if one is set | diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell.ts new file mode 100644 index 0000000000..4734c19a11 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-cell/autocomplete-grid-cell.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/__snapshots__/autocomplete-grid-cell.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid-cell/__snapshots__/autocomplete-grid-cell.snapshot.spec.snap.js new file mode 100644 index 0000000000..d3898238b4 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/__snapshots__/autocomplete-grid-cell.snapshot.spec.snap.js @@ -0,0 +1,59 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-cell renders DOM"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-cell renders DOM */ + +snapshots["sbb-autocomplete-grid-cell renders Shadow DOM"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-cell renders Shadow DOM */ + +snapshots["sbb-autocomplete-grid-cell renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "button", + "name": "" + } + ] +} +

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

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

+`; +/* end snapshot sbb-autocomplete-grid-cell renders A11y tree Firefox */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.scss b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.scss new file mode 100644 index 0000000000..558678f4cc --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.scss @@ -0,0 +1,13 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + display: block; +} + +.sbb-autocomplete-grid-cell { + display: flex; + column-gap: var(--sbb-spacing-fixed-6x); +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.snapshot.spec.ts new file mode 100644 index 0000000000..5c51a97d48 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.snapshot.spec.ts @@ -0,0 +1,44 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbAutocompleteGridCellElement } from './autocomplete-grid-cell.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import './autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe('sbb-autocomplete-grid-cell', () => { + describe('renders', () => { + let root: SbbAutocompleteGridCellElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-cell')!; + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(html` + + + + `); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.spec.ts new file mode 100644 index 0000000000..ba5205a9d0 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.spec.ts @@ -0,0 +1,18 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridCellElement } from './autocomplete-grid-cell.js'; + +describe('sbb-autocomplete-grid-cell', () => { + let element: SbbAutocompleteGridCellElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridCellElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ssr.spec.ts new file mode 100644 index 0000000000..42cd7b5b6e --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ssr.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridCellElement } from './autocomplete-grid-cell.js'; + +describe(`sbb-autocomplete-grid-cell ssr`, () => { + let element: SbbAutocompleteGridCellElement; + + beforeEach(async () => { + element = await fixture(html``, { + modules: ['./autocomplete-grid-cell.ts'], + }); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridCellElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.stories.ts new file mode 100644 index 0000000000..e662d34c67 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.stories.ts @@ -0,0 +1,130 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import readme from './readme.md?raw'; +import '../autocomplete-grid-row.js'; +import './autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +const numberOfButtons: InputType = { + control: { + type: 'number', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + numberOfButtons, + negative, + disabled, +}; + +const defaultArgs: Args = { + numberOfButtons: 1, + negative: false, + disabled: false, +}; + +const Template = ({ numberOfButtons, ...args }: Args): TemplateResult => html` + + ${repeat( + new Array(numberOfButtons), + (_, i) => html` + + + + `, + )} + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const DisabledNegative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true, negative: true }, +}; + +export const Multiple: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, numberOfButtons: 3 }, +}; + +export const MultipleNegative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, numberOfButtons: 3, negative: true }, +}; + +export const MultipleDisabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, numberOfButtons: 3, disabled: true }, +}; + +export const MultipleDisabledNegative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, numberOfButtons: 3, disabled: true, negative: true }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: ['click'], + }, + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid-cell', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ts new file mode 100644 index 0000000000..e803c70c44 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.ts @@ -0,0 +1,34 @@ +import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { hostAttributes } from '../../core/decorators.js'; + +import style from './autocomplete-grid-cell.scss?lit&inline'; + +/** + * A wrapper component for autocomplete-grid action button. + * + * @slot - Use the unnamed slot to add a `sbb-autocomplete-grid-button` element. + */ +@customElement('sbb-autocomplete-grid-cell') +@hostAttributes({ + role: 'gridcell', +}) +export class SbbAutocompleteGridCellElement extends LitElement { + public static override styles: CSSResultGroup = style; + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-cell': SbbAutocompleteGridCellElement; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.visual.spec.ts new file mode 100644 index 0000000000..c7d161c874 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/autocomplete-grid-cell.visual.spec.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import '../autocomplete-grid-button.js'; +import './autocomplete-grid-cell.js'; + +describe('sbb-autocomplete-grid-cell', () => { + describeViewports({ viewports: ['zero', 'medium'] }, () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(html` + + + + `); + }), + ); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-cell/readme.md b/src/elements/autocomplete-grid/autocomplete-grid-cell/readme.md new file mode 100644 index 0000000000..77355853e2 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-cell/readme.md @@ -0,0 +1,42 @@ +The `sbb-autocomplete-grid-cell` component wraps one [sbb-autocomplete-grid-button](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-button--docs) +inside a [sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid--docs). +To properly work, it must be used within a [sbb-autocomplete-grid-row](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-row--docs). + +```html + + + + + Option 1 + + + + + + Option 2 + + + + + + +``` + +## Slots + +The component has an unnamed slot which is used to project the `sbb-autocomplete-grid-buttons`. + +## Accessibility + +The `sbb-autocomplete-grid` follows the combobox `grid` pattern; +this means that the `sbb-autocomplete-grid-cell` has a `gridcell` role and its child would receive an `id` +based on the `sbb-autocomplete-grid-cell`'s `id`, +which is needed to correctly set the `aria-activedescendant` on the related `input`. + + + +## Slots + +| Name | Description | +| ---- | --------------------------------------------------------------------- | +| | Use the unnamed slot to add a `sbb-autocomplete-grid-button` element. | diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup.ts new file mode 100644 index 0000000000..fdeeb224cc --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-optgroup/autocomplete-grid-optgroup.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.snapshot.spec.snap.js new file mode 100644 index 0000000000..dfc3513444 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.snapshot.spec.snap.js @@ -0,0 +1,166 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-optgroup renders Safari DOM"] = +` + + + Option 1 + + + + + Option 2 + + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup renders Safari DOM */ + +snapshots["sbb-autocomplete-grid-optgroup renders Safari Shadow DOM"] = +`
+ + +
+ + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup renders Safari Shadow DOM */ + +snapshots["sbb-autocomplete-grid-optgroup renders Chrome-Firefox DOM"] = +` + + + Option 1 + + + + + Option 2 + + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup renders Chrome-Firefox DOM */ + +snapshots["sbb-autocomplete-grid-optgroup renders Chrome-Firefox Shadow DOM"] = +`
+ + +
+ + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup renders Chrome-Firefox Shadow DOM */ + +snapshots["sbb-autocomplete-grid-optgroup renders Chrome-Firefox A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Option 1" + }, + { + "role": "text", + "name": "Option 2" + } + ] +} +

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

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Option 1" + }, + { + "role": "text leaf", + "name": "Option 2" + } + ] +} +

+`; +/* end snapshot sbb-autocomplete-grid-optgroup renders Chrome-Firefox A11y tree Firefox */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.snapshot.spec.ts new file mode 100644 index 0000000000..0c66713349 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.snapshot.spec.ts @@ -0,0 +1,63 @@ +import { expect } from '@open-wc/testing'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit/static-html.js'; + +import { isSafari } from '../../core/dom.js'; +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; +import { describeIf } from '../../core/testing.js'; + +import './autocomplete-grid-optgroup.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; +import type { SbbAutocompleteGridOptgroupElement } from './autocomplete-grid-optgroup.js'; + +describe('sbb-autocomplete-grid-optgroup', () => { + describe('renders', () => { + let root: SbbAutocompleteGridOptgroupElement; + const opt: TemplateResult = html` + + + Option 1 + + + Option 2 + + + `; + beforeEach(async () => { + root = ( + await fixture(html` + ${opt} +
+ `) + ).querySelector('sbb-autocomplete-grid-optgroup')!; + }); + + describeIf(!isSafari, 'Chrome-Firefox', async () => { + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(opt); + }); + + describeIf(isSafari, 'Safari', async () => { + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(opt); + }); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts new file mode 100644 index 0000000000..e9ec99f877 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts @@ -0,0 +1,114 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { waitForLitRender } from '../../core/testing.js'; +import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; + +import { SbbAutocompleteGridOptgroupElement } from './autocomplete-grid-optgroup.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; +import '../autocomplete-grid-option.js'; + +describe(`sbb-autocomplete-grid-optgroup`, () => { + let element: SbbAutocompleteGridOptgroupElement; + + beforeEach(async () => { + element = await fixture(html` + + + Option 1 + + + + + + Option 2 + + + + + + Option 3 + + + + + + `); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridOptgroupElement); + }); + + it('disabled status is inherited', async () => { + const optionOne = element.querySelector('sbb-autocomplete-grid-option#option-1'); + const buttonOne = element.querySelector('sbb-autocomplete-grid-button#button-1'); + const optionTwo = element.querySelector('sbb-autocomplete-grid-option#option-2'); + const optionThree = element.querySelector('sbb-autocomplete-grid-option#option-3'); + element.setAttribute('disabled', ''); + await waitForLitRender(element); + + expect(element).to.have.attribute('disabled'); + expect(optionOne).to.have.attribute('data-group-disabled'); + expect(buttonOne).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); + expect(optionThree).to.have.attribute('data-group-disabled'); + + element.removeAttribute('disabled'); + await waitForLitRender(element); + expect(buttonOne).not.to.have.attribute('data-group-disabled'); + expect(optionTwo).not.to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); + }); + + it('disabled status prevents changes', async () => { + const optionOne: SbbAutocompleteGridOptionElement = element.querySelector( + 'sbb-autocomplete-grid-option#option-1', + )!; + const optionTwo: SbbAutocompleteGridOptionElement = element.querySelector( + 'sbb-autocomplete-grid-option#option-2', + )!; + const optionThree: SbbAutocompleteGridOptionElement = element.querySelector( + 'sbb-autocomplete-grid-option#option-3', + )!; + const options = [optionOne, optionTwo, optionThree]; + + options.forEach((opt) => expect(opt).not.to.have.attribute('selected')); + + element.setAttribute('disabled', ''); + await waitForLitRender(element); + expect(element).to.have.attribute('disabled'); + + // clicks should have no effect since the group is disabled + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); + expect(opt).not.to.have.attribute('selected'); + } + + element.removeAttribute('disabled'); + await waitForLitRender(element); + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); + } + + expect(optionOne).to.have.attribute('selected'); + expect(optionTwo).not.to.have.attribute('selected'); + expect(optionThree).to.have.attribute('selected'); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ssr.spec.ts new file mode 100644 index 0000000000..fdb67f5fab --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ssr.spec.ts @@ -0,0 +1,56 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridOptgroupElement } from './autocomplete-grid-optgroup.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; +import '../autocomplete-grid-option.js'; + +describe(`sbb-autocomplete-grid-optgroup ssr`, () => { + let element: SbbAutocompleteGridOptgroupElement; + + beforeEach(async () => { + element = await fixture( + html` + + + Option 1 + + + + + + + Option 2 + + + + + + + Option 3 + + + + + + + `, + { modules: ['../../autocomplete-grid.ts'] }, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridOptgroupElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts new file mode 100644 index 0000000000..fc026a84ed --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts @@ -0,0 +1,154 @@ +import type { InputType } from '@storybook/types'; +import type { Args, ArgTypes, Meta, StoryContext, StoryObj } from '@storybook/web-components'; +import { html, nothing, type TemplateResult } from 'lit'; + +import readme from './readme.md?raw'; +import '../../form-field.js'; +import './autocomplete-grid-optgroup.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +const label: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option group', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option group', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option', + }, +}; + +const disabledSingle: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete', + }, +}; + +const numberOfOptions: InputType = { + control: { + type: 'number', + }, +}; + +const defaultArgTypes: ArgTypes = { + label, + 'icon-name': iconName, + value, + disabled, + disabledSingle, + numberOfOptions, + negative, +}; + +const defaultArgs: Args = { + label: 'Option group', + 'icon-name': undefined, + value: 'Option', + disabled: false, + disabledSingle: false, + numberOfOptions: 3, + negative: false, +}; + +const createOptions = (args: Args): TemplateResult[] => + new Array(args.numberOfOptions).fill(null).map((_, i) => { + return html` + + ${`${args.value} ${i + 1}`} + + + + + `; + }); + +const TemplateOptgroup = ({ label, disabled, ...args }: Args): TemplateResult => html` + + ${createOptions(args)} + + + ${createOptions(args)} + +`; + +const Template = (args: Args): TemplateResult => { + return html` + + + + ${TemplateOptgroup(args)} + + `; +}; +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +const meta: Meta = { + parameters: { + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + actions: { + handles: ['click'], + }, + docs: { + // Setting the iFrame height ensures that the story has enough space when used in the docs section. + story: { inline: false, iframeHeight: '500px' }, + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid-optgroup', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts new file mode 100644 index 0000000000..8c16cbe950 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts @@ -0,0 +1,46 @@ +import { customElement } from 'lit/decorators.js'; + +import { SbbOptgroupBaseElement } from '../../option/optgroup.js'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button.js'; +import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; +import type { SbbAutocompleteGridElement } from '../autocomplete-grid.js'; + +/** + * It can be used as a container for one or more `sbb-autocomplete-grid-option`. + * + * @slot - Use the unnamed slot to add `sbb-autocomplete-grid-option` elements to the `sbb-autocomplete-grid-optgroup`. + */ +@customElement('sbb-autocomplete-grid-optgroup') +export class SbbAutocompleteGridOptgroupElement extends SbbOptgroupBaseElement { + protected get options(): SbbAutocompleteGridOptionElement[] { + return Array.from( + this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? [], + ) as SbbAutocompleteGridOptionElement[]; + } + + protected getAutocompleteParent(): SbbAutocompleteGridElement | null { + return this.closest?.('sbb-autocomplete-grid') || null; + } + + protected setAttributeFromParent(): void { + this.negative = !!this.closest(`:is(sbb-autocomplete-grid, sbb-form-field)[negative]`); + this.toggleAttribute('data-negative', this.negative); + } + + protected override proxyDisabledToOptions(): void { + super.proxyDisabledToOptions(); + const buttons = Array.from( + this.querySelectorAll?.('sbb-autocomplete-grid-button') ?? [], + ) as SbbAutocompleteGridButtonElement[]; + for (const el of buttons) { + el.toggleAttribute('data-group-disabled', this.disabled); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-optgroup': SbbAutocompleteGridOptgroupElement; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.visual.spec.ts new file mode 100644 index 0000000000..4534621db5 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.visual.spec.ts @@ -0,0 +1,106 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import '../../form-field.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-row.js'; +import './autocomplete-grid-optgroup.js'; + +describe(`sbb-autocomplete-grid-optgroup`, () => { + const defaultArgs = { + iconName: undefined as string | undefined, + disabled: false, + disabledSingle: false, + }; + + const createOptions = ( + iconName: string | undefined, + disabledSingle: boolean, + ): TemplateResult => html` + ${repeat( + new Array(3), + (_, i) => html` + + Option ${i + 1} + + `, + )} + `; + + const template = (args: typeof defaultArgs): TemplateResult => html` + + ${createOptions(args.iconName, args.disabledSingle)} + + + ${createOptions(args.iconName, args.disabledSingle)} + + `; + + const standaloneTemplate = (args: typeof defaultArgs): TemplateResult => html` +
+ ${template(args)} +
+ `; + + const autocompleteTemplate = (args: typeof defaultArgs): TemplateResult => html` + + + + ${template(args)} + + `; + + describeViewports({ viewports: ['micro', 'medium'] }, () => { + describe('standalone', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate(defaultArgs)); + }), + ); + + it( + `icon`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, iconName: 'clock-small' })); + }), + ); + + it( + `disabled`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, disabled: true })); + }), + ); + + it( + `disabledSingle`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, disabledSingle: true })); + }), + ); + }); + + describe('autocomplete-grid', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(autocompleteTemplate(defaultArgs), { minHeight: '800px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-autocomplete-grid')!.open(), + ); + }), + ); + }); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/index.ts b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/index.ts new file mode 100644 index 0000000000..17c6ae3e23 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-optgroup.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-optgroup/readme.md b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/readme.md new file mode 100644 index 0000000000..db1a68eb46 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-optgroup/readme.md @@ -0,0 +1,78 @@ +The `sbb-autocomplete-grid-optgroup` is a component used to group more [sbb-autocomplete-grid-option](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-option--docs) +within a [sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid--docs). + +A [sbb-divider](/docs/elements-sbb-divider--docs) is displayed at the bottom of the component. + +```html + + + + + + Option 1 + + + + + + Option 2 + + + + + + + +``` + +## Slots + +It is possible to provide a set of `sbb-autocomplete-grid-option` via an unnamed slot; +the component has also a `label` property as name of the group. + +```html + + + 1 + + + 2 + + + 3 + + +``` + +## States + +The component has a `disabled` property which sets all the `sbb-autocomplete-grid-option` in the group as disabled. + +```html + + + A + + + B + + + C + + +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | --------- | ------- | ---------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `label` | `label` | public | `string` | | Option group label. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------------------------------------------ | +| | Use the unnamed slot to add `sbb-autocomplete-grid-option` elements to the `sbb-autocomplete-grid-optgroup`. | diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option.ts b/src/elements/autocomplete-grid/autocomplete-grid-option.ts new file mode 100644 index 0000000000..5321bb13ac --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-option/autocomplete-grid-option.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.snapshot.spec.snap.js new file mode 100644 index 0000000000..9e8026a54f --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.snapshot.spec.snap.js @@ -0,0 +1,97 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-option renders DOM"] = +` + Option 1 + +`; +/* end snapshot sbb-autocomplete-grid-option renders DOM */ + +snapshots["sbb-autocomplete-grid-option renders Shadow DOM"] = +`
+
+ + + + + + + + Option 1 + +
+
+`; +/* end snapshot sbb-autocomplete-grid-option renders Shadow DOM */ + +snapshots["sbb-autocomplete-grid-option renders disabled DOM"] = +` + Option 1 + +`; +/* end snapshot sbb-autocomplete-grid-option renders disabled DOM */ + +snapshots["sbb-autocomplete-grid-option renders disabled Shadow DOM"] = +`
+
+ + + + + + + + Option 1 + +
+
+`; +/* end snapshot sbb-autocomplete-grid-option renders disabled Shadow DOM */ + +snapshots["sbb-autocomplete-grid-option A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Option 1" + } + ] +} +

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

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Option 1" + } + ] +} +

+`; +/* end snapshot sbb-autocomplete-grid-option A11y tree Chrome */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss new file mode 100644 index 0000000000..acb964f983 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss @@ -0,0 +1,70 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-option-color: var(--sbb-color-charcoal); + --sbb-option-column-gap: var(--sbb-spacing-responsive-xxxs); + --sbb-option-icon-color: var(--sbb-color-metal); + --sbb-option-border-radius: var(--sbb-border-radius-4x); + --sbb-option-padding-inline: var(--sbb-spacing-responsive-xxxs); + --sbb-option-padding-block: calc(var(--sbb-spacing-fixed-2x) + var(--sbb-border-width-2x)); + + display: block; +} + +:host([active]) { + --sbb-focus-outline-offset: calc(-1 * var(--sbb-spacing-fixed-1x)); +} + +:host([data-negative]) { + --sbb-option-color: var(--sbb-color-milk); + --sbb-option-icon-color: var(--sbb-color-smoke); +} + +// If highlighting is enabled, hide the original slot content +:host(:not([data-disable-highlight])) { + .sbb-option__label slot { + display: none; + } +} + +.sbb-option { + @include sbb.text-s--regular; + + display: flex; + align-items: center; + column-gap: var(--sbb-option-column-gap); + justify-content: start; + padding-block: var(--sbb-option-padding-block); + padding-inline: var(--sbb-option-padding-inline); + color: var(--sbb-option-color); + + :host([active]) & { + @include sbb.focus-outline; + + border-radius: var(--sbb-option-border-radius); + } +} + +.sbb-option__label--highlight { + :host(:not(:is([disabled], [data-group-disabled]))) & { + @include sbb.text--bold; + @include sbb.if-forced-colors { + color: Highlight; + } + } +} + +.sbb-option__icon { + display: flex; + min-width: var(--sbb-size-icon-ui-small); + min-height: var(--sbb-size-icon-ui-small); + color: var(--sbb-option-icon-color); + + :host(:not([data-slot-names~='icon'], [icon-name])) & { + // Can be overridden by the 'preserve-icon-space' on the autocomplete + display: var(--sbb-option-icon-container-display, none); + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.snapshot.spec.ts new file mode 100644 index 0000000000..cdc6c4278a --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.snapshot.spec.ts @@ -0,0 +1,67 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import './autocomplete-grid-option.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe('sbb-autocomplete-grid-option', () => { + describe('renders', () => { + let root: SbbAutocompleteGridOptionElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + Option 1 + + +
+ `) + ).querySelector('sbb-autocomplete-grid-option')!; + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders disabled', () => { + let root: SbbAutocompleteGridOptionElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + Option 1 + + +
+ `) + ).querySelector('sbb-autocomplete-grid-option')!; + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + testA11yTreeSnapshot( + html`Option 1`, + ); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts new file mode 100644 index 0000000000..38172e7dcc --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts @@ -0,0 +1,225 @@ +import { assert, expect } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { EventSpy, waitForLitRender } from '../../core/testing.js'; +import type { SbbFormFieldElement } from '../../form-field.js'; +import type { SbbAutocompleteGridOptgroupElement } from '../autocomplete-grid-optgroup.js'; +import type { SbbAutocompleteGridElement } from '../autocomplete-grid.js'; + +import { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option.js'; +import '../../form-field.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-optgroup.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid-option`, () => { + let element: SbbFormFieldElement; + + beforeEach(async () => { + element = await fixture(html` + + + + + Option 1 + + + + + + Option 2 + + + + + + + `); + }); + + it('renders', async () => { + const option = element.querySelector('sbb-autocomplete-grid-option')!; + assert.instanceOf(option, SbbAutocompleteGridOptionElement); + }); + + it('set selected and emits on click', async () => { + const selectionChangeSpy = new EventSpy( + SbbAutocompleteGridOptionElement.events.selectionChange, + ); + const optionOne = element.querySelector( + 'sbb-autocomplete-grid-option', + )!; + + optionOne.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); + + expect(optionOne.selected).to.be.equal(true); + expect(selectionChangeSpy.count).to.be.equal(1); + }); + + it('highlight on input', async () => { + const input = element.querySelector('input')!; + const autocomplete = + element.querySelector('sbb-autocomplete-grid')!; + const options = element.querySelectorAll('sbb-autocomplete-grid-option'); + const optionOneLabel = options[0].shadowRoot!.querySelector('.sbb-option__label'); + const optionTwoLabel = options[1].shadowRoot!.querySelector('.sbb-option__label'); + + input.focus(); + await sendKeys({ press: '1' }); + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + Option + 1 + + + `); + expect(optionTwoLabel).dom.to.be.equal(` + + + Option 2 + + `); + }); + + it('highlight after option label changed', async () => { + const input = element.querySelector('input')!; + const autocomplete = + element.querySelector('sbb-autocomplete-grid')!; + const options = element.querySelectorAll('sbb-autocomplete-grid-option'); + const optionOneLabel = options[0].shadowRoot!.querySelector('.sbb-option__label'); + + input.focus(); + await sendKeys({ type: 'Opt' }); + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + + Opt + ion 1 + + `); + + options[0].textContent = 'Other content'; + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + Other content + + `); + + options[0].textContent = 'Option'; + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + + Opt + ion + + `); + }); + + it('highlight later added options', async () => { + const input = element.querySelector('input')!; + const autocomplete = + element.querySelector('sbb-autocomplete-grid')!; + const options = element.querySelectorAll('sbb-autocomplete-grid-option'); + const optionOneLabel = options[0].shadowRoot!.querySelector('.sbb-option__label'); + + input.focus(); + await sendKeys({ type: 'Opt' }); + await waitForLitRender(autocomplete); + + expect(optionOneLabel).dom.to.be.equal(` + + + + Opt + ion 1 + + `); + + const newOption = document.createElement('sbb-autocomplete-grid-option'); + newOption.innerText = 'Option 3'; + autocomplete.append(newOption); + await waitForLitRender(autocomplete); + + const newOptionLabel = newOption.shadowRoot!.querySelector('.sbb-option__label'); + + expect(newOptionLabel).dom.to.be.equal(` + + + + Opt + ion 3 + + `); + }); + + it('highlight later added options in sbb-optgroup', async () => { + element = await fixture(html` + + + + + + Option 1 + + + + + + + + `); + + const input = element.querySelector('input')!; + const optgroup = element.querySelector( + 'sbb-autocomplete-grid-optgroup', + )!; + const options = element.querySelectorAll('sbb-autocomplete-grid-option'); + const optionOneLabel = options[0].shadowRoot!.querySelector('.sbb-option__label'); + + input.focus(); + await sendKeys({ type: 'Opt' }); + await waitForLitRender(element); + + expect(optionOneLabel).dom.to.be.equal(` + + + + Opt + ion 1 + + `); + + const newOption = document.createElement('sbb-autocomplete-grid-option'); + newOption.innerText = 'Option 2'; + optgroup.append(newOption); + await waitForLitRender(element); + + const newOptionLabel = newOption.shadowRoot!.querySelector('.sbb-option__label'); + + expect(newOptionLabel).dom.to.be.equal(` + + + + Opt + ion 2 + + `); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ssr.spec.ts new file mode 100644 index 0000000000..2341978c39 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ssr.spec.ts @@ -0,0 +1,47 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import type { SbbFormFieldElement } from '../../form-field.js'; + +import { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option.js'; +import '../../form-field.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-optgroup.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid-option ssr`, () => { + let element: SbbFormFieldElement; + + beforeEach(async () => { + element = await fixture( + html` + + + + + Option 1 + + + + + + Option 2 + + + + + + + `, + { modules: ['../../autocomplete-grid.ts', '../../form-field.ts'] }, + ); + }); + + it('renders', async () => { + const option = element.querySelector('sbb-autocomplete-grid-option')!; + assert.instanceOf(option, SbbAutocompleteGridOptionElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts new file mode 100644 index 0000000000..a996f67a3a --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts @@ -0,0 +1,195 @@ +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option.js'; +import readme from './readme.md?raw'; + +import '../../form-field.js'; +import '../autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; + +const preserveIconSpace: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Wrapper property', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const iconName: InputType = { + control: { + type: 'text', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, +}; + +const active: InputType = { + control: { + type: 'boolean', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const numberOfOptions: InputType = { + control: { + type: 'number', + }, +}; + +const defaultArgTypes: ArgTypes = { + value, + 'icon-name': iconName, + active, + disabled, + numberOfOptions, + preserveIconSpace, +}; + +const defaultArgs: Args = { + value: 'Value', + 'icon-name': undefined, + active: false, + disabled: false, + numberOfOptions: 5, + preserveIconSpace: false, +}; + +const createOptions = ({ + value, + active, + disabled, + numberOfOptions, + preserveIconSpace, + ...args +}: Args): TemplateResult[] => { + const style: Readonly = preserveIconSpace + ? { '--sbb-option-icon-container-display': 'block' } + : {}; + return [ + ...new Array(numberOfOptions).fill(null).map((_, i) => { + return html` + + ${`${value} ${i + 1}`} + + `; + }), + html` + + Option Lorem ipsum dolor sit amet. + + `, + ]; +}; + +const StandaloneTemplate = (args: Args): TemplateResult => html`${createOptions(args)}`; + +const AutocompleteTemplate = (args: Args): TemplateResult => html` + + + + ${createOptions(args)} + +`; + +const borderDecorator: Decorator = (story) => html` +
${story()}
+`; + +export const Standalone: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + decorators: [borderDecorator], +}; + +export const WithIcon: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'icon-name': 'clock-small' }, + decorators: [borderDecorator], +}; + +export const WithDisabledState: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, + decorators: [borderDecorator], +}; + +export const WithActiveState: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, active: true }, + decorators: [borderDecorator], +}; + +export const WithIconSpace: StoryObj = { + render: StandaloneTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, preserveIconSpace: true }, + decorators: [borderDecorator], +}; + +export const Autocomplete: StoryObj = { + render: AutocompleteTemplate, + argTypes: { ...defaultArgTypes, negative }, + args: { ...defaultArgs, negative: false }, +}; + +const meta: Meta = { + parameters: { + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + actions: { + handles: [ + SbbAutocompleteGridOptionElement.events.selectionChange, + SbbAutocompleteGridOptionElement.events.optionSelected, + ], + }, + docs: { + // Setting the iFrame height ensures that the story has enough space when used in the docs section. + story: { inline: false, iframeHeight: '500px' }, + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid-option', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts new file mode 100644 index 0000000000..3e1aad9373 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -0,0 +1,100 @@ +import type { CSSResultGroup, PropertyValues } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { hostAttributes } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { SbbOptionBaseElement } from '../../option.js'; + +import style from './autocomplete-grid-option.scss?lit&inline'; + +export const autocompleteGridOptionId: string = `sbb-autocomplete-grid-option`; + +/** + * It displays an option item which can be used in `sbb-autocomplete-grid`. + * + * @slot - Use the unnamed slot to add content to the option label. + * @slot icon - Use this slot to provide an icon. If `icon-name` is set, a sbb-icon will be used. + * @event {CustomEvent} autocompleteOptionSelectionChange - Emits when the option selection status changes. + * @event {CustomEvent} autocompleteOptionSelected - Emits when an option was selected by user. + * @cssprop [--sbb-option-icon-container-display=none] - Can be used to reserve space even + * when preserve-icon-space on autocomplete is not set or iconName is not set. + */ +@customElement('sbb-autocomplete-grid-option') +@hostAttributes({ + role: 'gridcell', +}) +export class SbbAutocompleteGridOptionElement extends SbbOptionBaseElement { + public static override styles: CSSResultGroup = style; + public static readonly events = { + selectionChange: 'autocompleteOptionSelectionChange', + optionSelected: 'autocompleteOptionSelected', + } as const; + + protected optionId = autocompleteGridOptionId; + + /** Emits when the option selection status changes. */ + protected selectionChange: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridOptionElement.events.selectionChange, + ); + + /** Emits when an option was selected by user. */ + protected optionSelected: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridOptionElement.events.optionSelected, + ); + + protected override onOptionAttributesChange(mutationsList: MutationRecord[]): void { + super.onOptionAttributesChange(mutationsList); + this.closest?.('sbb-autocomplete-grid-row')?.toggleAttribute( + 'data-disabled', + this.disabled || this.disabledFromGroup, + ); + } + + public override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (changedProperties.has('disabled')) { + this.closest?.('sbb-autocomplete-grid-row')?.toggleAttribute( + 'data-disabled', + this.disabled || this.disabledFromGroup, + ); + this.updateAriaDisabled(); + } + } + + protected setAttributeFromParent(): void { + const parentGroup = this.closest('sbb-autocomplete-grid-optgroup'); + if (parentGroup) { + this.disabledFromGroup = parentGroup.disabled; + this.updateAriaDisabled(); + } + this.closest('sbb-autocomplete-grid-row')?.toggleAttribute( + 'data-disabled', + this.disabled || this.disabledFromGroup, + ); + + this.negative = !!this.closest(`:is(sbb-autocomplete-grid[negative],sbb-form-field[negative])`); + this.toggleAttribute('data-negative', this.negative); + } + + protected selectByClick(event: MouseEvent): void { + if (this.disabled || this.disabledFromGroup) { + event.stopPropagation(); + return; + } + + this.setSelectedViaUserInteraction(true); + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-option': SbbAutocompleteGridOptionElement; + } + + interface GlobalEventHandlersEventMap { + autocompleteOptionSelectionChange: CustomEvent; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.visual.spec.ts new file mode 100644 index 0000000000..dcd04e83ec --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.visual.spec.ts @@ -0,0 +1,114 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import '../../form-field.js'; +import '../../autocomplete-grid.js'; +import './autocomplete-grid-option.js'; + +describe(`sbb-autocomplete-grid-option`, () => { + const defaultArgs = { + iconName: undefined as string | undefined, + active: false, + disabled: false, + preserveIconSpace: false, + }; + + const createOption = ( + { active, disabled, preserveIconSpace, iconName }: typeof defaultArgs, + i: number, + ): TemplateResult => { + const style = preserveIconSpace ? { '--sbb-option-icon-container-display': 'block' } : {}; + return html` + Value ${i + 1} + `; + }; + + const standaloneTemplate = (args: typeof defaultArgs): TemplateResult => html` +
+ ${repeat(new Array(5), (_, i) => createOption(args, i))} +
+ `; + + const autocompleteTemplate = (args: typeof defaultArgs): TemplateResult => html` + + + + + ${repeat( + new Array(5), + (_, i) => html` + ${createOption(args, i)} + `, + )} + + + `; + + describeViewports({ viewports: ['micro', 'medium'] }, () => { + describe('standalone', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate(defaultArgs)); + }), + ); + + it( + `icon`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, iconName: 'clock-small' })); + }), + ); + + it( + `active`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, active: true })); + }), + ); + + it( + `preserveIconSpace`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, preserveIconSpace: true })); + }), + ); + }); + + describe('autocomplete-grid', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(autocompleteTemplate(defaultArgs), { minHeight: '400px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-autocomplete-grid')!.open(), + ); + }), + ); + + it( + 'disabled', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(autocompleteTemplate({ ...defaultArgs, disabled: true }), { + minHeight: '400px', + }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-autocomplete-grid')!.open(), + ); + }), + ); + }); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-option/readme.md b/src/elements/autocomplete-grid/autocomplete-grid-option/readme.md new file mode 100644 index 0000000000..6400fc58d6 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-option/readme.md @@ -0,0 +1,115 @@ +The `sbb-autocomplete-grid-option` is a component which can be used to display items in the +[sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid--docs). + +```html + + + + + Option 1 + + + + + + Option 2 + + + + + + +``` + +## Slots + +It is possible to provide a label via an unnamed slot; the component can optionally display a `sbb-icon` +at the component start using the `iconName` property or via custom content using the `icon` slot. + +```html +Option 1 + +Option 1 +``` + +## States + +Like the native `option`, the component has a `value` property. + +The `selected`, `disabled` and `active` properties are connected to the self-named states. +When disabled, the selection via click is prevented. +If the `sbb-autocomplete-grid-option` is nested in a `sbb-autocomplete-grid-optgroup` component, it inherits from the parent the `disabled` state. + +```html +Option label + +Option label + +Option label +``` + +## Events + +Consumers can listen to the `optionSelected` event on the `sbb-autocomplete-grid-option` component to intercept the selected value; +the event is triggered if the element has been selected by some user interaction. Alternatively, +the `selectionChange` event can be listened to, which is triggered if the element has been both selected or deselected. + +## Style + +If the label slot contains only a **text node**, it is possible to search for text in the `sbb-autocomplete-grid-option` using the +`highlight` method, passing the desired text; if the text is present it will be highlighted in bold. + +```html + + Highlightable caption + + + + Not highlightable caption + + + + + Highlightable caption + +``` + +## Accessibility + +The `sbb-autocomplete-grid` follows the combobox `grid` pattern; +this means that the `sbb-autocomplete-grid-option` has a `gridcell` role and its `id` is set from `sbb-autocomplete-grid-row`'s `id`, +which is needed to correctly set the `aria-activedescendant` on the related `input`. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `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. | +| `selected` | `selected` | public | `boolean` | | Whether the option is selected. | +| `value` | `value` | public | `string` | | Value of the option. | + +## Events + +| Name | Type | Description | Inherited From | +| ----------------------------------- | ------------------- | ----------------------------------------------- | -------------- | +| `autocompleteOptionSelected` | `CustomEvent` | Emits when an option was selected by user. | | +| `autocompleteOptionSelectionChange` | `CustomEvent` | Emits when the option selection status changes. | | + +## CSS Properties + +| Name | Default | Description | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| `--sbb-option-icon-container-display` | `none` | Can be used to reserve space even when preserve-icon-space on autocomplete is not set or iconName is not set. | + +## Slots + +| Name | Description | +| ------ | --------------------------------------------------------------------------------- | +| | Use the unnamed slot to add content to the option label. | +| `icon` | Use this slot to provide an icon. If `icon-name` is set, a sbb-icon will be used. | diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row.ts b/src/elements/autocomplete-grid/autocomplete-grid-row.ts new file mode 100644 index 0000000000..f4c5e8dd74 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-row/autocomplete-grid-row.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.snapshot.spec.snap.js new file mode 100644 index 0000000000..c53b554985 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.snapshot.spec.snap.js @@ -0,0 +1,81 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-row renders DOM"] = +` + + Option 1 + + + + + + +`; +/* end snapshot sbb-autocomplete-grid-row renders DOM */ + +snapshots["sbb-autocomplete-grid-row renders Shadow DOM"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-row renders Shadow DOM */ + +snapshots["sbb-autocomplete-grid-row renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Option 1" + }, + { + "role": "button", + "name": "" + } + ] +} +

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

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Option 1" + }, + { + "role": "button", + "name": "" + } + ] +} +

+`; +/* end snapshot sbb-autocomplete-grid-row renders A11y tree Firefox */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss new file mode 100644 index 0000000000..c6a77c7f8c --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss @@ -0,0 +1,87 @@ +@use '../../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + --sbb-autocomplete-grid-row-color: var(--sbb-color-charcoal); + --sbb-autocomplete-grid-row-background-color: inherit; + --sbb-autocomplete-grid-row-background-color-hover: var(--sbb-color-milk); + --sbb-autocomplete-grid-row-background-color-active: var(--sbb-color-cloud); + --sbb-autocomplete-grid-row-disabled-border-color: var(--sbb-color-graphite); + --sbb-autocomplete-grid-row-disabled-background-color: var(--sbb-color-milk); + --sbb-autocomplete-grid-row-padding-inline-end: var(--sbb-spacing-responsive-xxxs); + --sbb-autocomplete-grid-row-justify-content: space-between; + --sbb-autocomplete-grid-row-min-height: var(--sbb-size-button-m-min-height); + --sbb-autocomplete-grid-row-cursor: pointer; + --sbb-autocomplete-grid-row-border-radius: var(--sbb-border-radius-4x); + --sbb-autocomplete-grid-row-icon-color: var(--sbb-color-metal); + + display: block; +} + +:host([data-negative]) { + --sbb-autocomplete-grid-row-color: var(--sbb-color-milk); + --sbb-autocomplete-grid-row-icon-color: var(--sbb-color-smoke); + --sbb-autocomplete-grid-row-background-color-hover: var(--sbb-color-charcoal); + --sbb-autocomplete-grid-row-background-color-active: var(--sbb-color-iron); + --sbb-autocomplete-grid-row-disabled-border-color: var(--sbb-color-smoke); + --sbb-autocomplete-grid-row-disabled-background-color: var(--sbb-color-charcoal); + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); +} + +:host(:hover:not([data-disabled])) { + @include sbb.hover-mq($hover: true) { + --sbb-autocomplete-grid-row-background-color: var( + --sbb-autocomplete-grid-row-background-color-hover + ); + } +} + +:host([data-disabled]) { + --sbb-autocomplete-grid-row-cursor: default; + + @include sbb.if-forced-colors { + --sbb-autocomplete-grid-row-color: GrayText; + } +} + +::slotted(sbb-autocomplete-grid-option) { + flex: 1 1 auto; + margin-right: calc(-1 * var(--sbb-spacing-fixed-2x)); +} + +.sbb-autocomplete-grid-row { + display: flex; + align-items: center; + padding-inline-end: var(--sbb-autocomplete-grid-row-padding-inline-end); + justify-content: var(--sbb-autocomplete-grid-row-justify-content); + gap: var(--sbb-spacing-fixed-6x); + color: var(--sbb-autocomplete-grid-row-color); + background-color: var(--sbb-autocomplete-grid-row-background-color); + cursor: var(--sbb-autocomplete-grid-row-cursor); + -webkit-tap-highlight-color: transparent; + -webkit-text-fill-color: var(--sbb-autocomplete-grid-row-color); + + // Add inner border and background for disabled option when it's not multiple + :host([data-disabled]) & { + position: relative; + z-index: 0; + + &::before { + content: ''; + display: block; + position: absolute; + inset: #{sbb.px-to-rem-build(6)}; + border: var(--sbb-border-width-1x) dashed + var(--sbb-autocomplete-grid-row-disabled-border-color); + border-radius: var(--sbb-border-radius-2x); + background-color: var(--sbb-autocomplete-grid-row-disabled-background-color); + z-index: -1; + + @include sbb.if-forced-colors { + border-color: GrayText; + } + } + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.snapshot.spec.ts new file mode 100644 index 0000000000..068eb01c57 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.snapshot.spec.ts @@ -0,0 +1,44 @@ +import { expect } from '@open-wc/testing'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbAutocompleteGridRowElement } from './autocomplete-grid-row.js'; +import '../autocomplete-grid.js'; +import './autocomplete-grid-row.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe('sbb-autocomplete-grid-row', () => { + describe('renders', () => { + let root: SbbAutocompleteGridRowElement; + const row: TemplateResult = html` + + Option 1 + + + + + `; + beforeEach(async () => { + root = ( + await fixture(html` + ${row} +
+ `) + ).querySelector('sbb-autocomplete-grid-row')!; + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(row); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts new file mode 100644 index 0000000000..2e4d0035ab --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridRowElement } from './autocomplete-grid-row.js'; + +describe('sbb-autocomplete-grid-row', () => { + let element: SbbAutocompleteGridRowElement; + + beforeEach(async () => { + element = await fixture(html``, { + modules: ['./autocomplete-grid-row.ts'], + }); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridRowElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ssr.spec.ts new file mode 100644 index 0000000000..0db47862c2 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ssr.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbAutocompleteGridRowElement } from './autocomplete-grid-row.js'; + +describe(`sbb-autocomplete-grid-row ssr`, () => { + let element: SbbAutocompleteGridRowElement; + + beforeEach(async () => { + element = await fixture(html``, { + modules: ['./autocomplete-grid-row.ts'], + }); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridRowElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts new file mode 100644 index 0000000000..c4a37a7301 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts @@ -0,0 +1,73 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import readme from './readme.md?raw'; + +import './autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-button.js'; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + negative, +}; + +const defaultArgs: Args = { + negative: false, +}; + +const Template = ({ negative }: Args): TemplateResult => html` + + Opt 1 + + + + + + Opt 2 + + + + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid-row', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts new file mode 100644 index 0000000000..706102d9fb --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts @@ -0,0 +1,41 @@ +import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { hostAttributes } from '../../core/decorators.js'; + +import style from './autocomplete-grid-row.scss?lit&inline'; + +let autocompleteRowNextId = 0; + +/** + * The component is used as a wrapper for options and action buttons. + * + * @slot - Use the unnamed slot to add a `sbb-autocomplete-grid-option` and a `sbb-autocomplete-grid-cell` with one or more `sbb-autocomplete-grid-button`. + */ +@customElement('sbb-autocomplete-grid-row') +@hostAttributes({ + role: 'row', +}) +export class SbbAutocompleteGridRowElement extends LitElement { + public static override styles: CSSResultGroup = style; + + public override connectedCallback(): void { + super.connectedCallback(); + this.id ||= `sbb-autocomplete-grid-row-${++autocompleteRowNextId}`; + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-row': SbbAutocompleteGridRowElement; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.visual.spec.ts new file mode 100644 index 0000000000..bcfef1d22c --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.visual.spec.ts @@ -0,0 +1,78 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import './autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-button.js'; + +describe('sbb-autocomplete-grid-row', () => { + const defaultArgs = { + negative: false, + withActions: false, + numberOfActions: 1, + }; + + const template = ({ + negative, + withActions, + numberOfActions, + }: typeof defaultArgs): TemplateResult => html` + ${repeat( + new Array(5), + (_, i) => html` + + Opt ${i} + ${withActions + ? repeat( + new Array(numberOfActions), + () => html` + + + + `, + ) + : nothing} + + `, + )} + `; + + describeViewports({ viewports: ['zero', 'medium'] }, () => { + for (const negative of [false, true]) { + const args = { ...defaultArgs, negative }; + const wrapperStyle = { + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + it( + `negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template(args), wrapperStyle); + }), + ); + + it( + `negative=${negative} withActions=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...args, withActions: true }), wrapperStyle); + }), + ); + + it( + `negative=${negative} withMultipleActions=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...args, withActions: true, numberOfActions: 3 }), + wrapperStyle, + ); + }), + ); + } + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid-row/readme.md b/src/elements/autocomplete-grid/autocomplete-grid-row/readme.md new file mode 100644 index 0000000000..d904e933c4 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid-row/readme.md @@ -0,0 +1,40 @@ +The `sbb-autocomplete-grid-row` is a wrapper for both [sbb-autocomplete-grid-option](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-option--docs) +and [sbb-autocomplete-grid-cell](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-cell--docs) within the +[sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid--docs) component. + +```html + + + + + Option 1 + + + + + + Option 2 + + + + + + +``` + +## Slots + +The component has an unnamed slot which is used to project `sbb-autocomplete-grid-option` and `sbb-autocomplete-grid-cell`. + +## Accessibility + +The `sbb-autocomplete-grid` follows the combobox `grid` pattern; +this means that the `sbb-autocomplete-grid-row` has a `row` role and its child would receive an `id` based on the `sbb-autocomplete-grid-row`'s `id`. + + + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| | Use the unnamed slot to add a `sbb-autocomplete-grid-option` and a `sbb-autocomplete-grid-cell` with one or more `sbb-autocomplete-grid-button`. | diff --git a/src/elements/autocomplete-grid/autocomplete-grid.ts b/src/elements/autocomplete-grid/autocomplete-grid.ts new file mode 100644 index 0000000000..44b44c1b7e --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid/autocomplete-grid.js'; diff --git a/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js b/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js new file mode 100644 index 0000000000..dde7f35f5a --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.snapshot.spec.snap.js @@ -0,0 +1,235 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid Chrome-Firefox DOM"] = +` + + + Option 1 + + + + + + + + + Option 2 + + + + + + + +`; +/* end snapshot sbb-autocomplete-grid Chrome-Firefox DOM */ + +snapshots["sbb-autocomplete-grid Chrome-Firefox Shadow DOM"] = +`
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+`; +/* end snapshot sbb-autocomplete-grid Chrome-Firefox Shadow DOM */ + +snapshots["sbb-autocomplete-grid Chrome-Firefox A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "​" + }, + { + "role": "combobox", + "name": "", + "autocomplete": "list", + "haspopup": "grid" + } + ] +} +

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

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "statictext", + "name": "​" + }, + { + "role": "combobox", + "name": "", + "autocomplete": "list", + "haspopup": "grid" + } + ] +} +

+`; +/* end snapshot sbb-autocomplete-grid Chrome-Firefox A11y tree Firefox */ + +snapshots["sbb-autocomplete-grid Safari DOM"] = +` + + + Option 1 + + + + + + + + + Option 2 + + + + + + + +`; +/* end snapshot sbb-autocomplete-grid Safari DOM */ + +snapshots["sbb-autocomplete-grid Safari Shadow DOM"] = +`
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+`; +/* end snapshot sbb-autocomplete-grid Safari Shadow DOM */ + diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.snapshot.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.snapshot.spec.ts new file mode 100644 index 0000000000..71bf81fe40 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.snapshot.spec.ts @@ -0,0 +1,67 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { isSafari } from '../../core/dom.js'; +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; +import { describeIf } from '../../core/testing.js'; +import type { SbbFormFieldElement } from '../../form-field.js'; + +import type { SbbAutocompleteGridElement } from './autocomplete-grid.js'; +import './autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-option.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; +import '../../form-field/form-field/form-field.js'; + +describe('sbb-autocomplete-grid', () => { + let root: SbbFormFieldElement; + let element: SbbAutocompleteGridElement; + + beforeEach(async () => { + root = await fixture(html` + + + + + Option 1 + + + + + + Option 2 + + + + + + + `); + element = root.querySelector('sbb-autocomplete-grid')!; + }); + + describeIf(!isSafari, 'Chrome-Firefox', async () => { + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); + + describeIf(isSafari, 'Safari', async () => { + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts new file mode 100644 index 0000000000..aebd7ced75 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts @@ -0,0 +1,373 @@ +import { assert, expect } from '@open-wc/testing'; +import { sendKeys, sendMouse } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; + +import { isSafari } from '../../core/dom.js'; +import { tabKey } from '../../core/testing/private/keys.js'; +import { fixture } from '../../core/testing/private.js'; +import { describeIf, EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; +import { SbbFormFieldElement } from '../../form-field.js'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button.js'; +import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; + +import { SbbAutocompleteGridElement } from './autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid`, () => { + let formField: SbbFormFieldElement; + let element: SbbAutocompleteGridElement; + let input: HTMLInputElement; + + beforeEach(async () => { + formField = await fixture(html` + + + + + Option 1 + + + + + + Option 2 + + + + + + + + + + `); + input = formField.querySelector('input')!; + element = formField.querySelector('sbb-autocomplete-grid')!; + }); + + describeIf(isSafari, 'Safari', async () => { + it('renders and sets the correct attributes', () => { + assert.instanceOf(formField, SbbFormFieldElement); + assert.instanceOf(element, SbbAutocompleteGridElement); + + expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + + expect(input).to.have.attribute('autocomplete', 'off'); + expect(input).to.have.attribute('role', 'combobox'); + expect(input).to.have.attribute('aria-autocomplete', 'list'); + expect(input).to.have.attribute('aria-haspopup', 'grid'); + expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); + expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + }); + + describeIf(!isSafari, 'Chrome-Firefox', async () => { + it('renders and sets the correct attributes', () => { + assert.instanceOf(formField, SbbFormFieldElement); + assert.instanceOf(element, SbbAutocompleteGridElement); + + expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + + expect(input).to.have.attribute('autocomplete', 'off'); + expect(input).to.have.attribute('role', 'combobox'); + expect(input).to.have.attribute('aria-autocomplete', 'list'); + expect(input).to.have.attribute('aria-haspopup', 'grid'); + expect(input).to.have.attribute('aria-controls', 'sbb-autocomplete-grid-11'); + expect(input).to.have.attribute('aria-owns', 'sbb-autocomplete-grid-11'); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + }); + + it('opens and closes with mouse and keyboard', async () => { + const willOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willClose); + const didCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didClose); + + input.click(); + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'true'); + + await sendKeys({ press: 'Escape' }); + await waitForCondition(() => willCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'false'); + + await sendKeys({ press: 'ArrowDown' }); + await waitForCondition(() => willOpenEventSpy.events.length === 2); + expect(willOpenEventSpy.count).to.be.equal(2); + await waitForCondition(() => didOpenEventSpy.events.length === 2); + expect(didOpenEventSpy.count).to.be.equal(2); + expect(input).to.have.attribute('aria-expanded', 'true'); + + await sendKeys({ press: tabKey }); + await waitForCondition(() => willCloseEventSpy.events.length === 2); + expect(willCloseEventSpy.count).to.be.equal(2); + await waitForCondition(() => didCloseEventSpy.events.length === 2); + expect(didCloseEventSpy.count).to.be.equal(2); + expect(input).to.have.attribute('aria-expanded', 'false'); + + input.click(); + await waitForCondition(() => willOpenEventSpy.events.length === 3); + expect(willOpenEventSpy.count).to.be.equal(3); + await waitForCondition(() => didOpenEventSpy.events.length === 3); + expect(didOpenEventSpy.count).to.be.equal(3); + expect(input).to.have.attribute('aria-expanded', 'true'); + + // Simulate backdrop click + sendMouse({ type: 'click', position: [formField.offsetWidth + 25, 25] }); + + await waitForCondition(() => willCloseEventSpy.events.length === 3); + expect(willCloseEventSpy.count).to.be.equal(3); + await waitForCondition(() => didCloseEventSpy.events.length === 3); + expect(didCloseEventSpy.count).to.be.equal(3); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + + it('select by mouse', async () => { + const willOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const optionSelectedEventSpy = new EventSpy( + SbbAutocompleteGridOptionElement.events.optionSelected, + ); + + input.focus(); + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(element); + + expect(optionSelectedEventSpy.count).to.be.equal(1); + expect(optionSelectedEventSpy.firstEvent!.target).to.have.property('id', 'option-2'); + }); + + it('select button and get related option', async () => { + const willOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willOpen); + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const clickSpy = new EventSpy('click'); + + input.focus(); + await waitForCondition(() => willOpenEventSpy.events.length === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + const buttonOne = element.querySelector('#button-1') as SbbAutocompleteGridButtonElement; + buttonOne.click(); + await waitForLitRender(element); + + await waitForCondition(() => clickSpy.events.length === 1); + expect(clickSpy.count).to.be.equal(1); + expect( + (clickSpy.firstEvent!.target as SbbAutocompleteGridButtonElement).option!.textContent, + ).to.be.equal('Option 1'); + expect( + (clickSpy.firstEvent!.target as SbbAutocompleteGridButtonElement).option!.value, + ).to.be.equal('1'); + }); + + it('keyboard navigation', async () => { + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const optOne = element.querySelector('#option-1'); + const buttonOne = element.querySelector('#button-1'); + const optTwo = element.querySelector('#option-2'); + const buttonTwo = element.querySelector('#button-2'); + const buttonThree = element.querySelector('#button-3'); + input.focus(); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(optTwo).to.have.attribute('active'); + expect(buttonTwo).not.to.have.attribute('data-focus-visible'); + expect(buttonThree).not.to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'option-2'); + + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(element); + expect(optTwo).not.to.have.attribute('active'); + expect(buttonTwo).to.have.attribute('data-focus-visible'); + expect(buttonThree).not.to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'button-2'); + + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(element); + expect(optTwo).not.to.have.attribute('active'); + expect(buttonTwo).not.to.have.attribute('data-focus-visible'); + expect(buttonThree).to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'button-3'); + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(optOne).to.have.attribute('active'); + expect(buttonOne).not.to.have.attribute('data-focus-visible'); + expect(optTwo).not.to.have.attribute('active'); + expect(buttonTwo).not.to.have.attribute('data-focus-visible'); + expect(buttonThree).not.to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'option-1'); + }); + + it('opens and select with keyboard', async () => { + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const didCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didClose); + const optionSelectedEventSpy = new EventSpy( + SbbAutocompleteGridOptionElement.events.optionSelected, + ); + const optOne = element.querySelector('#option-1'); + const optTwo = element.querySelector('#option-2'); + input.focus(); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(optOne).not.to.have.attribute('active'); + expect(optOne).not.to.have.attribute('selected'); + expect(optTwo).to.have.attribute('active'); + expect(optTwo).not.to.have.attribute('selected'); + expect(input).to.have.attribute('aria-activedescendant', 'option-2'); + + await sendKeys({ press: 'Enter' }); + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(didCloseEventSpy.count).to.be.equal(1); + + expect(optTwo).not.to.have.attribute('active'); + expect(optTwo).to.have.attribute('selected'); + expect(optionSelectedEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'false'); + expect(input).not.to.have.attribute('aria-activedescendant'); + }); + + it('opens and select button with keyboard', async () => { + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen); + const clickSpy = new EventSpy('click'); + const optOne = element.querySelector('#option-1'); + const buttonOne = element.querySelector('#button-1'); + const buttonTwo = element.querySelector('#button-2'); + input.focus(); + + await waitForCondition(() => didOpenEventSpy.events.length === 1); + expect(didOpenEventSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(optOne).to.have.attribute('active'); + expect(buttonOne).not.to.have.attribute('data-focus-visible'); + await sendKeys({ press: 'ArrowRight' }); + expect(optOne).not.to.have.attribute('active'); + expect(buttonOne).to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'button-1'); + await sendKeys({ press: 'Enter' }); + await waitForCondition(() => clickSpy.events.length === 1); + expect(clickSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(element); + expect(optOne).not.to.have.attribute('active'); + expect(buttonOne).not.to.have.attribute('data-focus-visible'); + expect(buttonTwo).to.have.attribute('data-focus-visible'); + expect(input).to.have.attribute('aria-activedescendant', 'button-2'); + await sendKeys({ press: 'Enter' }); + await waitForCondition(() => clickSpy.events.length === 2); + expect(clickSpy.count).to.be.equal(2); + }); + + it('should stay closed when disabled', async () => { + input.setAttribute('disabled', ''); + + input.focus(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + + input.click(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + + it('should stay closed when readonly', async () => { + input.setAttribute('readonly', ''); + + input.focus(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + + input.click(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + + it('does not open if prevented', async () => { + const willOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willOpen); + + element.addEventListener(SbbAutocompleteGridElement.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(SbbAutocompleteGridElement.events.didOpen); + const willCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willClose); + + element.open(); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + await waitForLitRender(element); + + element.addEventListener(SbbAutocompleteGridElement.events.willClose, (ev) => + ev.preventDefault(), + ); + element.close(); + + await waitForCondition(() => willCloseEventSpy.events.length === 1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'opened'); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ssr.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ssr.spec.ts new file mode 100644 index 0000000000..8eb6b3f8de --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ssr.spec.ts @@ -0,0 +1,62 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; +import { SbbFormFieldElement } from '../../form-field.js'; + +import { SbbAutocompleteGridElement } from './autocomplete-grid.js'; +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; + +describe(`sbb-autocomplete-grid ssr`, () => { + let formField: SbbFormFieldElement; + let element: SbbAutocompleteGridElement; + + beforeEach(async () => { + formField = await fixture( + html` + + + + + Option 1 + + + + + + + Option 2 + + + + + + + + + + + `, + { modules: ['../../autocomplete-grid.ts', '../../form-field.ts'] }, + ); + element = formField.querySelector('sbb-autocomplete-grid')!; + }); + + it('renders', () => { + assert.instanceOf(formField, SbbFormFieldElement); + assert.instanceOf(element, SbbAutocompleteGridElement); + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts new file mode 100644 index 0000000000..d8dd5cffc5 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts @@ -0,0 +1,530 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import { userEvent, within } from '@storybook/test'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import isChromatic from 'chromatic/isChromatic'; +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { waitForComponentsReady } from '../../../storybook/testing/wait-for-components-ready.js'; +import { waitForStablePosition } from '../../../storybook/testing/wait-for-stable-position.js'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button.js'; +import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; + +import { SbbAutocompleteGridElement } from './autocomplete-grid.js'; +import readme from './readme.md?raw'; + +import '../autocomplete-grid-row.js'; +import '../autocomplete-grid-optgroup.js'; +import '../autocomplete-grid-cell.js'; +import '../autocomplete-grid-button.js'; +import '../../form-field.js'; + +const getOption = (event: Event): void => { + const button = event.target as SbbAutocompleteGridButtonElement; + const div: HTMLDivElement = document.createElement('div'); + div.innerText = `Button '${button.iconName}' clicked on row '${button.option?.textContent}' / value: '${button.option?.value}'`; + (event.currentTarget as HTMLElement).closest('div')!.querySelector('#container')!.prepend(div); +}; + +const textBlockStyle: Readonly = { + position: 'relative', + marginBlockStart: '1rem', + padding: '1rem', + backgroundColor: 'var(--sbb-color-milk)', + border: 'var(--sbb-border-width-1x) solid var(--sbb-color-cloud)', + borderRadius: 'var(--sbb-border-radius-4x)', + zIndex: '100', +}; + +const codeStyle: Readonly = { + padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)', + borderRadius: 'var(--sbb-border-radius-4x)', + backgroundColor: 'var(--sbb-color-smoke-alpha-20)', +}; + +const textBlock = (): TemplateResult => html` +
+ This text block has a z-index greater than the form + field, but it must always be covered by the autocomplete overlay. +
+`; + +const aboveDecorator: Decorator = (story) => html` +
+ ${story()} +
+`; + +const scrollDecorator: Decorator = (story) => html` +
+ ${story()} +
+`; + +// Story interaction executed after the story renders +const playStory = async ({ canvasElement }: StoryContext): Promise => { + const canvas = within(canvasElement); + + await waitForComponentsReady(() => + canvas.getByTestId('form-field').shadowRoot!.querySelector('div.sbb-form-field__space-wrapper'), + ); + + await waitForStablePosition(() => canvas.getByTestId('autocomplete-input')); + await userEvent.type(canvas.getByTestId('autocomplete-input'), 'Opt'); + await new Promise((resolve) => setTimeout(resolve, 2000)); +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete', + }, +}; + +const readonly: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete', + }, +}; + +const preserveIconSpace: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Autocomplete', + }, +}; + +const borderless: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Form field', + }, +}; + +const floatingLabel: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Form field', + }, +}; + +const optionIconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Option', + }, +}; + +const disableOption: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option', + }, +}; + +const buttonIconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const disableGroup: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Option group', + }, +}; + +const defaultArgTypes: ArgTypes = { + // Form field args + negative, + borderless, + floatingLabel, + + // Input args + disabled, + readonly, + + // Autocomplete args + preserveIconSpace, + + // Option args + optionIconName, + disableOption, + + // Button args + buttonIconName, +}; + +const withGroupsArgTypes: ArgTypes = { + ...defaultArgTypes, + + // Option group args + disableGroup, +}; + +const defaultArgs: Args = { + // Form field args + negative: false, + borderless: false, + floatingLabel: false, + + // Input args + disabled: false, + readonly: false, + + // Autocomplete args + preserveIconSpace: true, + + // Option args + optionIconName: 'clock-small', + disableOption: false, + + // Button args + buttonIconName: 'pen-small', +}; + +const withGroupsDefaultArgs: Args = { + ...defaultArgs, + + // Option group args + disableGroup: false, +}; + +const createRows1 = ( + optionIconName: string, + buttonIconName: string, + disableOption: boolean, +): TemplateResult => html` + ${repeat( + new Array(3), + (_, i: number) => html` + + ${`Option 1-${i + 1}`} + + getOption(event)} + > + + + `, + )} +`; + +const createRows2 = (buttonIconName: string, disableOption: boolean): TemplateResult => html` + ${repeat( + new Array(3), + (_, i: number) => html` + + ${`Option 2-${i + 1}`} + + getOption(event)} + > + + + getOption(event)} + > + + + `, + )} +`; + +const Template = (args: Args): TemplateResult => html` +
+ + + + + ${createRows1(args.optionIconName, args.buttonIconName, args.disableOption)} + ${createRows2(args.buttonIconName, args.disableOption)} + + + ${textBlock()} +
+
+`; + +const OptionGroupTemplate = (args: Args): TemplateResult => html` +
+ + + + + + Current location + + + ${createRows1(args.optionIconName, args.buttonIconName, args.disableOption)} + + + ${createRows2(args.buttonIconName, args.disableOptio1n)} + + + + ${textBlock()} +
+
+`; + +export const Basic: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + play: isChromatic() ? playStory : undefined, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const Readonly: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, readonly: true }, +}; + +export const NoIcon: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, optionIconName: undefined }, + play: isChromatic() ? playStory : undefined, +}; + +export const NoIconNoIconSpace: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, optionIconName: undefined, preserveIconSpace: false }, + play: isChromatic() ? playStory : undefined, +}; + +export const Borderless: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, borderless: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const BorderlessNegative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, borderless: true, negative: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const BasicOpenAbove: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + decorators: [aboveDecorator], + play: isChromatic() ? playStory : undefined, +}; + +export const BorderlessOpenAbove: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, borderless: true }, + decorators: [aboveDecorator], + play: isChromatic() ? playStory : undefined, +}; + +export const DisableOption: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disableOption: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const NegativeDisableOption: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true, disableOption: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const WithOptionGroup: StoryObj = { + render: OptionGroupTemplate, + argTypes: withGroupsArgTypes, + args: { ...withGroupsDefaultArgs }, + play: isChromatic() ? playStory : undefined, +}; + +export const WithOptionGroupNegative: StoryObj = { + render: OptionGroupTemplate, + argTypes: withGroupsArgTypes, + args: { ...withGroupsDefaultArgs, negative: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const WithOptionGroupDisabled: StoryObj = { + render: OptionGroupTemplate, + argTypes: withGroupsArgTypes, + args: { ...withGroupsDefaultArgs, disableGroup: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const WithOptionGroupNegativeDisabled: StoryObj = { + render: OptionGroupTemplate, + argTypes: withGroupsArgTypes, + args: { ...withGroupsDefaultArgs, negative: true, disableGroup: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const WithOptionGroupNegativeOptionDisabled: StoryObj = { + render: OptionGroupTemplate, + argTypes: withGroupsArgTypes, + args: { ...withGroupsDefaultArgs, negative: true, disableOption: true }, + play: isChromatic() ? playStory : undefined, +}; + +export const Scroll: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, + decorators: [scrollDecorator], + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + chromatic: { disableSnapshot: false }, + actions: { + handles: [ + SbbAutocompleteGridElement.events.willOpen, + SbbAutocompleteGridElement.events.didOpen, + SbbAutocompleteGridElement.events.didClose, + SbbAutocompleteGridElement.events.willClose, + 'change', + 'click', + SbbAutocompleteGridOptionElement.events.optionSelected, + ], + }, + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + // Setting the iFrame height ensures that the story has enough space when used in the docs section. + story: { inline: false, iframeHeight: '500px' }, + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-autocomplete-grid/sbb-autocomplete-grid', +}; + +export default meta; diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts new file mode 100644 index 0000000000..3ff62585f1 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -0,0 +1,226 @@ +import { customElement } from 'lit/decorators.js'; + +import { SbbAutocompleteBaseElement } from '../../autocomplete.js'; +import { getNextElementIndex } from '../../core/a11y.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { getDocumentWritingMode, isSafari } from '../../core/dom.js'; +import { setAriaComboBoxAttributes } from '../../core/overlay.js'; +import type { SbbDividerElement } from '../../divider.js'; +import type { SbbOptGroupElement, SbbOptionElement } from '../../option.js'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button.js'; +import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; +import type { SbbAutocompleteGridRowElement } from '../autocomplete-grid-row.js'; + +let nextId = 0; + +/** + * On Safari, the aria role 'listbox' must be on the host element, or else VoiceOver won't work at all. + * On the other hand, JAWS and NVDA need the role to be "closer" to the options, or else optgroups won't work. + */ +const ariaRoleOnHost = isSafari; + +/** + * Combined with a native input, it displays a panel with a list of available options with connected buttons. + * + * @slot - Use the unnamed slot to add `sbb-autocomplete-grid-row` or `sbb-autocomplete-grid-optgroup` elements to the `sbb-autocomplete-grid`. + * @event {CustomEvent} willOpen - Emits whenever the `sbb-autocomplete-grid` starts the opening transition. Can be canceled. + * @event {CustomEvent} didOpen - Emits whenever the `sbb-autocomplete-grid` is opened. + * @event {CustomEvent} willClose - Emits whenever the `sbb-autocomplete-grid` begins the closing transition. Can be canceled. + * @event {CustomEvent} didClose - Emits whenever the `sbb-autocomplete-grid` is closed. + * @cssprop [--sbb-autocomplete-z-index=var(--sbb-overlay-default-z-index)] - To specify a custom stack order, + * the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the + * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. + */ +@customElement('sbb-autocomplete-grid') +@hostAttributes({ + dir: getDocumentWritingMode(), + role: ariaRoleOnHost ? 'grid' : null, +}) +export class SbbAutocompleteGridElement extends SbbAutocompleteBaseElement { + protected overlayId = `sbb-autocomplete-grid-${++nextId}`; + protected panelRole = 'grid'; + private _activeItemIndex = -1; + private _activeColumnIndex = 0; + + protected get options(): SbbAutocompleteGridOptionElement[] { + return Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? []); + } + + private get _row(): SbbAutocompleteGridRowElement[] { + return ( + Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-row')).filter( + (row) => !row.hasAttribute('data-disabled'), + ) ?? [] + ); + } + + protected onOptionClick(event: MouseEvent): void { + if ( + (event.target as Element).localName !== 'sbb-autocomplete-grid-option' || + (event.target as SbbOptionElement).disabled + ) { + return; + } + this.close(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this.abort.signal; + this.addEventListener( + 'autocompleteOptionSelectionChange', + (e: CustomEvent) => this.onOptionSelected(e), + { signal }, + ); + } + + protected syncNegative(): void { + this.querySelectorAll?.( + 'sbb-divider, sbb-autocomplete-grid-button', + ).forEach((e) => (e.negative = this.negative)); + + this.querySelectorAll?.( + 'sbb-autocomplete-grid-row, sbb-autocomplete-grid-option, sbb-autocomplete-grid-optgroup', + ).forEach((element) => element.toggleAttribute('data-negative', this.negative)); + } + + protected openedPanelKeyboardInteraction(event: KeyboardEvent): void { + if (this.state !== 'opened') { + return; + } + + switch (event.key) { + case 'Escape': + case 'Tab': + this.close(); + break; + + case 'Enter': + this.selectByKeyboard(); + break; + + case 'ArrowDown': + case 'ArrowUp': + this.setNextActiveOption(event); + break; + + case 'ArrowRight': + case 'ArrowLeft': + this._setNextHorizontalActiveElement(event); + break; + } + } + + /** + * Select an element on 'Enter' keypress. + * + * Due to keyboard navigation code, the `_activeColumnIndex` is zero when an option is 'focused' + * and greater than zero when a button is 'focused', so asking for `querySelectorAll(...)[this._activeColumnIndex]` + * would always return a `SbbAutocompleteGridButtonElement`. + */ + protected selectByKeyboard(): void { + if (this._activeColumnIndex !== 0) { + ( + this._row[this._activeItemIndex].querySelectorAll( + 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-button', + )[this._activeColumnIndex] as SbbAutocompleteGridButtonElement + ).click(); + } else { + this.options[this._activeItemIndex]?.setSelectedViaUserInteraction(true); + } + } + + protected setNextActiveOption(event: KeyboardEvent): void { + const filteredOptions = this.options.filter( + (opt) => !opt.disabled && !opt.hasAttribute('data-group-disabled'), + ); + + // Get and activate the next active option + const next = getNextElementIndex(event, this._activeItemIndex, filteredOptions.length); + if (isNaN(next)) { + return; + } + const nextActiveOption = filteredOptions[next]; + nextActiveOption.active = true; + this.triggerElement?.setAttribute('aria-activedescendant', nextActiveOption.id); + nextActiveOption.scrollIntoView({ block: 'nearest' }); + + // Reset the previous active option/button + if (this._activeColumnIndex !== 0) { + this._row[this._activeItemIndex] + .querySelectorAll('sbb-autocomplete-grid-button') + .forEach((e) => e.toggleAttribute('data-focus-visible', false)); + } else { + const lastActiveOption = filteredOptions[this._activeItemIndex]; + if (lastActiveOption) { + lastActiveOption.active = false; + } + } + this._activeItemIndex = next; + this._activeColumnIndex = 0; + } + + private _setNextHorizontalActiveElement(event: KeyboardEvent): void { + if (this._activeItemIndex < 0) { + return; + } + + const elementsInRow: (SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement)[] = + Array.from( + this._row[this._activeItemIndex].querySelectorAll< + SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement + >('sbb-autocomplete-grid-option, sbb-autocomplete-grid-button'), + ).filter((el) => !el.disabled && !el.hasAttribute('data-group-disabled')); + const next: number = getNextElementIndex(event, this._activeColumnIndex, elementsInRow.length); + if (isNaN(next)) { + return; + } + const nextElement: SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement = + elementsInRow[next]; + if (nextElement instanceof SbbAutocompleteGridOptionElement) { + nextElement.active = true; + } else { + nextElement.toggleAttribute('data-focus-visible', true); + } + + const lastActiveElement: SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement = + elementsInRow[this._activeColumnIndex]; + if (lastActiveElement instanceof SbbAutocompleteGridOptionElement) { + lastActiveElement.active = false; + } else { + lastActiveElement.toggleAttribute('data-focus-visible', false); + } + this.triggerElement?.setAttribute('aria-activedescendant', nextElement.id); + nextElement.scrollIntoView({ block: 'nearest' }); + this._activeColumnIndex = next; + } + + protected resetActiveElement(): void { + if (this._activeColumnIndex !== 0) { + this._row[this._activeItemIndex] + .querySelectorAll('sbb-autocomplete-grid-button') + .forEach((e) => e.toggleAttribute('data-focus-visible', false)); + } else { + const activeElement = this.options.filter( + (opt) => !opt.disabled && !opt.hasAttribute('data-group-disabled'), + )[this._activeItemIndex]; + if (activeElement) { + activeElement.active = false; + } + } + this._activeItemIndex = -1; + this._activeColumnIndex = 0; + this.triggerElement?.removeAttribute('aria-activedescendant'); + } + + protected setTriggerAttributes(element: HTMLInputElement): void { + setAriaComboBoxAttributes(element, ariaRoleOnHost ? this.id : this.overlayId, false, 'grid'); + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid': SbbAutocompleteGridElement; + } +} diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.visual.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.visual.spec.ts new file mode 100644 index 0000000000..284c297399 --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.visual.spec.ts @@ -0,0 +1,302 @@ +import { sendKeys } from '@web/test-runner-commands'; +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { VisualDiffSetupBuilder } from '../../core/testing/private.js'; +import { + describeViewports, + visualDiffDefault, + visualDiffFocus, +} from '../../core/testing/private.js'; +import '../../form-field.js'; +import '../../form-error.js'; +import '../../autocomplete-grid.js'; + +describe('sbb-autocomplete-grid', () => { + const defaultArgs = { + negative: false, + disabled: false, + readonly: false, + required: false, + withIcon: true, + preserveIconSpace: true, + disableOption: false, + borderless: false, + withGroup: false, + disableGroup: false, + withMixedOptionAndGroup: false, + }; + + const textBlock = (): TemplateResult => html` +
+ This text block has a z-index greater than the form field, but it must always be + covered by the autocomplete overlay. +
+ `; + + const createOptionBlockOne = (withIcon: boolean, disableOption: boolean): TemplateResult => html` + ${repeat( + new Array(3), + (_, i) => html` + + + ${withIcon && i === 2 + ? html`` + : nothing} + Option ${i} + + + + + + `, + )} + `; + + const createOptionBlockTwo = (): TemplateResult => html` + ${repeat( + new Array(2), + (_, i) => html` + + Option ${i + 3} + + + + + + + + `, + )} + `; + + const createOptions = (withIcon: boolean, disableOption: boolean): TemplateResult => html` + ${createOptionBlockOne(withIcon, disableOption)} ${createOptionBlockTwo()} + `; + + const createOptionsGroup = ( + withIcon: boolean, + disableOption: boolean, + disableGroup: boolean, + ): TemplateResult => html` + + ${createOptionBlockOne(withIcon, disableOption)} + + ${createOptionBlockTwo()} + `; + + const createMixedOptionsGroup = ( + withIcon: boolean, + disableOption: boolean, + disableGroup: boolean, + ): TemplateResult => html` + + + Option Value + + ${createOptionsGroup(withIcon, disableOption, disableGroup)} + `; + + const template = (args: typeof defaultArgs): TemplateResult => html` + + + + + ${args.withGroup + ? args.withMixedOptionAndGroup + ? createMixedOptionsGroup(args.withIcon, args.disableOption, args.disableGroup) + : createOptionsGroup(args.withIcon, args.disableOption, args.disableGroup) + : createOptions(args.withIcon, args.disableOption)} + + ${args.required + ? html`This is a required field.` + : nothing} + + ${textBlock()} + `; + + const openAutocomplete = async (setup: VisualDiffSetupBuilder): Promise => { + const ac = setup.snapshotElement.querySelector('sbb-autocomplete-grid')!; + ac.open(); + const input = setup.snapshotElement.querySelector('input')!; + input.focus(); + await sendKeys({ press: 'O' }); + }; + + describeViewports({ viewports: ['zero', 'medium'], viewportHeight: 500 }, () => { + for (const negative of [false, true]) { + for (const borderless of [false, true]) { + for (const visualDiffState of [visualDiffDefault, visualDiffFocus]) { + it( + `state=above negative=${negative} borderless=${borderless} ${visualDiffState.name}`, + visualDiffState.with(async (setup) => { + await setup.withFixture( + html` +
+ ${template({ ...defaultArgs, negative, borderless })} +
+ `, + { + minHeight: '500px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + } + } + }); + + describeViewports({ viewports: ['zero', 'medium'] }, () => { + for (const negative of [false, true]) { + const style = { + minHeight: '400px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + for (const visualDiffState of [visualDiffDefault, visualDiffFocus]) { + it( + `state=${visualDiffState.name} negative=${negative}`, + visualDiffState.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative }), style); + }), + ); + } + + it( + `state=required negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, required: true }), style); + }), + ); + + it( + `state=disabled negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, disabled: true }), style); + }), + ); + + it( + `state=readonly negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, readonly: true }), style); + }), + ); + + it( + `state=borderless negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, borderless: true }), style); + }), + ); + + it( + `state=noIcon negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, withIcon: false }), style); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + for (const withIcon of [false, true]) { + it( + `state=noSpace negative=${negative} withIcon=${withIcon}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withIcon, preserveIconSpace: false }), + style, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + + for (const withGroup of [false, true]) { + const wrapperStyle = { + minHeight: withGroup ? '800px' : '400px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + it( + `negative=${negative} withGroup=${withGroup}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup }), + wrapperStyle, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + it( + `negative=${negative} withGroup=${withGroup} disableOption=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup, disableOption: true }), + wrapperStyle, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + + it( + `negative=${negative} withGroup=true disableGroup=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ + ...defaultArgs, + negative, + disableGroup: true, + withGroup: true, + }), + { + minHeight: '800px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + it( + `negative=${negative} withGroup=true withMixedOptionAndGroup=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup: true, withMixedOptionAndGroup: true }), + { + minHeight: '800px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + }); +}); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/readme.md b/src/elements/autocomplete-grid/autocomplete-grid/readme.md new file mode 100644 index 0000000000..1f67ec358d --- /dev/null +++ b/src/elements/autocomplete-grid/autocomplete-grid/readme.md @@ -0,0 +1,180 @@ +The `sbb-autocomplete-grid` is a component that can be used to display a panel of suggested options connected to a text input, +with each option connected to one or more buttons. +Use it when you need an autocomplete in which every selectable option in the panel needs one or more related button. +If you don't need actions, use the [sbb-autocomplete](/docs/elements-sbb-autocomplete---docs). + +The component is strictly connected to: + +- the [sbb-autocomplete-grid-row](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid-row--docs), which is a wrapper for both option and buttons; +- the [sbb-autocomplete-grid-option](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid-option--docs), which displays a selectable option within a panel; +- the [sbb-autocomplete-grid-cell](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid-cell--docs), which is a wrapper a for button element; +- the [sbb-autocomplete-grid-button](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid-button--docs), which displays a button within a row; +- the [sbb-autocomplete-grid-optgroup](/docs/elements-sbb-autocomplete-sbb-autocomplete-grid-optgroup--docs), which can be used to group more row within a group. + +It's possible to set the element to which the component's panel will be attached using the `origin` prop, +and the input which will work as a trigger using the `trigger` prop. +Both accept an id or an element reference. + +```html + +
Another origin
+ + + + + + + Option 1 + + + + + + Option 2 + + + + + +``` + +## In `sbb-form-field` + +If the component is used within a [sbb-form-field](/docs/elements-sbb-form-field-sbb-form-field--docs), +it will automatically connect to the native `` as trigger and will display the option panel above or below the `sbb-form-field`. + +```html + + + + + + + + Option 1 + + + + + + Option 2 + + + + + + +``` + +## Style + +### Option highlight + +By default, the `sbb-autocomplete-grid` will highlight the label of the `sbb-autocomplete-grid-option` in the panel, +if it matches the typed text. +See the [sbb-autocomplete-grid-option](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid-option--docs) for more details. + +### Option grouping + +The displayed `sbb-autocomplete-grid-option` can be collected into groups using `sbb-autocomplete-grid-optgroup` element: + +```html + + + + + + + + + Option 1 + + + + + ... + + + + Option 100 + + + + + ... + + + +``` + +## Events + +The `sbb-autocomplete-grid-option` emits the `optionSelected` event when selected via user interaction. + +## Keyboard interaction + +The options panel opens on `focus`, `click` or `input` events on the trigger element, or on `ArrowDown` keypress; +it can be closed on backdrop click, or using the `Escape` or `Tab` keys. + +| Keyboard | Action | +| ---------------------- | ------------------------------------------------------- | +| Down Arrow | Navigate to the next option. Open the panel, if closed. | +| Up Arrow | Navigate to the previous option. | +| Right Arrow | Navigate to the next button. | +| Left Arrow | Navigate to the previous button. | +| Enter | Select the active option/button. | +| Escape | Close the autocomplete panel. | + +## Accessibility + +The `sbb-autocomplete-grid` implements the [ARIA combobox-grid interaction pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/grid-combo/). + +The text input trigger specifies `role="combobox"` while the content of the pop-up applies `role="grid"`. +The inner option and actions have `role="gridcell"`, while the buttons inside the action have `role="button"`. +Note that since the focus must always be on the connected input, those buttons can't be reached via Tab, +but only with arrow navigation; note also that when a button is reached, going up or down will move to the previous/next option +and not to the previous/next button. + +The component preserves focus on the input trigger, +using `aria-activedescendant` to support navigation though the autocomplete options. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `origin` | `origin` | public | `string \| HTMLElement \| undefined` | | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, it will search for the first 'sbb-form-field' ancestor. | +| `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | +| `preserveIconSpace` | `preserve-icon-space` | public | `boolean \| undefined` | | Whether the icon space is preserved when no icon is set. | +| `trigger` | `trigger` | public | `string \| HTMLInputElement \| undefined` | | The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. | +| `triggerElement` | - | public | `HTMLInputElement \| undefined` | | Returns the trigger element. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------ | ---------- | ------ | ----------------------- | +| `close` | public | Closes the autocomplete. | | `void` | SbbOpenCloseBaseElement | +| `open` | public | Opens the autocomplete. | | `void` | SbbOpenCloseBaseElement | + +## Events + +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ------------------------------------------------------------------------------------------ | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete-grid` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete-grid` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete-grid` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete-grid` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | + +## CSS Properties + +| Name | Default | Description | +| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--sbb-autocomplete-z-index` | `var(--sbb-overlay-default-z-index)` | To specify a custom stack order, the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | +| | Use the unnamed slot to add `sbb-autocomplete-grid-row` or `sbb-autocomplete-grid-optgroup` elements to the `sbb-autocomplete-grid`. | diff --git a/src/elements/autocomplete.ts b/src/elements/autocomplete.ts index ef8e791d3a..dc2e624fb5 100644 --- a/src/elements/autocomplete.ts +++ b/src/elements/autocomplete.ts @@ -1 +1,2 @@ export * from './autocomplete/autocomplete.js'; +export * from './autocomplete/autocomplete-base-element.js'; diff --git a/src/elements/autocomplete/autocomplete.scss b/src/elements/autocomplete/autocomplete-base-element.scss similarity index 96% rename from src/elements/autocomplete/autocomplete.scss rename to src/elements/autocomplete/autocomplete-base-element.scss index 5494d0bdd7..c7d6b247fc 100644 --- a/src/elements/autocomplete/autocomplete.scss +++ b/src/elements/autocomplete/autocomplete-base-element.scss @@ -10,6 +10,7 @@ :host { @include sbb.options-panel-overlay-variables; + --sbb-options-pointer-events: all; --sbb-options-panel-internal-z-index: var( --sbb-autocomplete-z-index, var(--sbb-overlay-default-z-index) @@ -25,6 +26,7 @@ :host(:not([data-state])), :host([data-state='closed']) { --sbb-options-panel-visibility: hidden; + --sbb-options-pointer-events: none; } :host([data-state='opening']) { @@ -129,6 +131,8 @@ @include sbb.scrollbar-rules; @include sbb.optionsOverlay; + pointer-events: var(--sbb-options-pointer-events); + @include sbb.if-forced-colors { border: var(--sbb-border-width-1x) solid CanvasText; border-top: none; diff --git a/src/elements/autocomplete/autocomplete-base-element.ts b/src/elements/autocomplete/autocomplete-base-element.ts new file mode 100644 index 0000000000..b06aaaca81 --- /dev/null +++ b/src/elements/autocomplete/autocomplete-base-element.ts @@ -0,0 +1,447 @@ +import { + type CSSResultGroup, + html, + isServer, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; +import { SbbConnectedAbortController } from '../core/controllers.js'; +import { findReferencedElement, isSafari } from '../core/dom.js'; +import { SbbNegativeMixin, SbbHydrationMixin } from '../core/mixins.js'; +import { + isEventOnElement, + overlayGapFixCorners, + removeAriaComboBoxAttributes, + setOverlayPosition, +} from '../core/overlay.js'; +import type { SbbOptionBaseElement } from '../option.js'; + +import style from './autocomplete-base-element.scss?lit&inline'; + +/** + * On Safari, the aria role 'listbox' must be on the host element, or else VoiceOver won't work at all. + * On the other hand, JAWS and NVDA need the role to be "closer" to the options, or else optgroups won't work. + */ +const ariaRoleOnHost = isSafari; + +export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( + SbbHydrationMixin(SbbOpenCloseBaseElement), +) { + public static override styles: CSSResultGroup = style; + + /** + * The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. + * If not set, it will search for the first 'sbb-form-field' ancestor. + */ + @property() public origin?: string | HTMLElement; + + /** + * The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. + * By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. + * If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. + */ + @property() public trigger?: string | HTMLInputElement; + + /** Whether the icon space is preserved when no icon is set. */ + @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) + public preserveIconSpace?: boolean; + + /** Returns the element where autocomplete overlay is attached to. */ + public get originElement(): HTMLElement { + if (!this._originElement) { + this._originElement = this._findOriginElement(); + } + return this._originElement; + } + private _originElement?: HTMLElement; + + /** Returns the trigger element. */ + public get triggerElement(): HTMLInputElement | undefined { + return this._triggerElement; + } + private _triggerElement: HTMLInputElement | undefined; + + protected abstract overlayId: string; + protected abstract panelRole: string; + protected abort = new SbbConnectedAbortController(this); + private _overlay!: HTMLElement; + private _optionContainer!: HTMLElement; + private _triggerEventsController!: AbortController; + private _openPanelEventsController!: AbortController; + private _didLoad = false; + private _isPointerDownEventOnMenu: boolean = false; + + protected abstract get options(): SbbOptionBaseElement[]; + protected abstract syncNegative(): void; + protected abstract setTriggerAttributes(element: HTMLInputElement): void; + protected abstract openedPanelKeyboardInteraction(event: KeyboardEvent): void; + protected abstract selectByKeyboard(event: KeyboardEvent): void; + protected abstract setNextActiveOption(event: KeyboardEvent): void; + protected abstract resetActiveElement(): void; + protected abstract onOptionClick(event: MouseEvent): void; + + /** Opens the autocomplete. */ + public open(): void { + if (this.state !== 'closed' || !this._overlay || this.options.length === 0 || this._readonly) { + return; + } + if (!this.willOpen.emit()) { + return; + } + + this.state = 'opening'; + this._setOverlayPosition(); + } + + /** Closes the autocomplete. */ + public close(): void { + if (this.state !== 'opened') { + return; + } + if (!this.willClose.emit()) { + return; + } + + this.state = 'closing'; + this._openPanelEventsController.abort(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + if (ariaRoleOnHost) { + this.id ||= this.overlayId; + } + const signal = this.abort.signal; + const formField = this.closest('sbb-form-field') ?? this.closest('[data-form-field]'); + + if (formField) { + this.negative = formField.hasAttribute('negative'); + } + + if (this._didLoad) { + this._componentSetup(); + } + this.syncNegative(); + + this.addEventListener('click', (e: MouseEvent) => this.onOptionClick(e), { signal }); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has('origin')) { + this._resetOriginClickListener(this.origin, changedProperties.get('origin')); + } + if (changedProperties.has('trigger')) { + this._resetTriggerClickListener(this.trigger, changedProperties.get('trigger')); + } + if (changedProperties.has('negative')) { + this.syncNegative(); + } + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + + this._componentSetup(); + this._didLoad = true; + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._triggerEventsController?.abort(); + this._openPanelEventsController?.abort(); + } + + /** When an option is selected, update the input value and close the autocomplete. */ + protected onOptionSelected(event: CustomEvent): void { + const target = event.target as SbbOptionBaseElement; + if (!target.selected) { + return; + } + + // Deselect the previous options + this.options + .filter((option) => option.id !== target.id && option.selected) + .forEach((option) => (option.selected = false)); + + if (this.triggerElement) { + // Set the option value + this.triggerElement.value = target.value as string; + + // Manually trigger the change events + this.triggerElement.dispatchEvent(new Event('change', { bubbles: true })); + this.triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + } + + this.close(); + } + + private _handleSlotchange(): void { + this._highlightOptions(this.triggerElement?.value); + } + + /** The autocomplete should inherit 'readonly' state from the trigger. */ + private get _readonly(): boolean { + return this.triggerElement?.hasAttribute('readonly') ?? false; + } + + /** Removes trigger click listener on trigger change. */ + private _resetOriginClickListener( + newValue?: string | HTMLElement, + oldValue?: string | HTMLElement, + ): void { + if (newValue !== oldValue) { + this._componentSetup(); + } + } + + /** Removes trigger click listener on trigger change. */ + private _resetTriggerClickListener( + newValue?: string | HTMLElement, + oldValue?: string | HTMLElement, + ): void { + if (newValue !== oldValue) { + this._componentSetup(); + } + } + + private _componentSetup(): void { + if (isServer) { + return; + } + this._triggerEventsController?.abort(); + this._openPanelEventsController?.abort(); + + this._originElement = undefined; + this.toggleAttribute( + 'data-option-panel-origin-borderless', + !!this.closest?.('sbb-form-field')?.hasAttribute('borderless'), + ); + + this._bindTo(this._getTriggerElement()); + } + + /** + * Retrieve the element where the autocomplete will be attached. + * @returns 'origin' or the first 'sbb-form-field' ancestor. + */ + private _findOriginElement(): HTMLElement { + let result: HTMLElement | undefined | null; + + if (!this.origin) { + result = this.closest?.('sbb-form-field')?.shadowRoot?.querySelector?.('#overlay-anchor'); + } else { + result = findReferencedElement(this.origin); + } + + if (!result) { + throw new Error( + 'Cannot find the origin element. Please specify a valid element or read the "origin" prop documentation', + ); + } + + return result; + } + + /** + * Retrieve the element that will trigger the autocomplete opening. + * @returns 'trigger' or the first 'input' inside the origin element. + */ + private _getTriggerElement(): HTMLInputElement { + if (!this.trigger) { + return this.closest?.('sbb-form-field')?.querySelector('input') as HTMLInputElement; + } + + const result = findReferencedElement(this.trigger); + + if (!result) { + throw new Error( + 'Cannot find the trigger element. Please specify a valid element or read the "trigger" prop documentation', + ); + } + + return result; + } + + private _bindTo(triggerElem: HTMLInputElement): void { + if (!triggerElem) { + return; + } + + // Reset attributes to the old trigger and add them to the new one + this._removeTriggerAttributes(this.triggerElement); + this.setTriggerAttributes(triggerElem); + + this._triggerElement = triggerElem; + + this._setupTriggerEvents(); + } + + private _setupTriggerEvents(): void { + this._triggerEventsController = new AbortController(); + + // Open the overlay on focus, click, input and `ArrowDown` event + this.triggerElement?.addEventListener('focus', () => this.open(), { + signal: this._triggerEventsController.signal, + }); + this.triggerElement?.addEventListener('click', () => this.open(), { + signal: this._triggerEventsController.signal, + }); + this.triggerElement?.addEventListener( + 'input', + (event) => { + this.open(); + this._highlightOptions((event.target as HTMLInputElement).value); + }, + { signal: this._triggerEventsController.signal }, + ); + this.triggerElement?.addEventListener( + 'keydown', + (event: KeyboardEvent) => this._closedPanelKeyboardInteraction(event), + { signal: this._triggerEventsController.signal }, + ); + } + + // Set overlay position, width and max height + private _setOverlayPosition(): void { + setOverlayPosition( + this._overlay, + this.originElement, + this._optionContainer, + this.shadowRoot!.querySelector('.sbb-autocomplete__container')!, + this, + ); + } + + /** On open/close animation end. + * In rare cases it can be that the animationEnd event is triggered twice. + * To avoid entering a corrupt state, exit when state is not expected. + */ + private _onAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this.state === 'opening') { + this._onOpenAnimationEnd(); + } else if (event.animationName === 'close' && this.state === 'closing') { + this._onCloseAnimationEnd(); + } + } + + private _onOpenAnimationEnd(): void { + this.state = 'opened'; + this._attachOpenPanelEvents(); + this.triggerElement?.setAttribute('aria-expanded', 'true'); + this.didOpen.emit(); + } + + private _onCloseAnimationEnd(): void { + this.state = 'closed'; + this.triggerElement?.setAttribute('aria-expanded', 'false'); + this.resetActiveElement(); + this._optionContainer.scrollTop = 0; + this.didClose.emit(); + } + + private _attachOpenPanelEvents(): void { + this._openPanelEventsController = new AbortController(); + + // Recalculate the overlay position on scroll and window resize + document.addEventListener('scroll', () => this._setOverlayPosition(), { + passive: true, + signal: this._openPanelEventsController.signal, + }); + window.addEventListener('resize', () => this._setOverlayPosition(), { + passive: true, + signal: this._openPanelEventsController.signal, + }); + + // Close autocomplete on backdrop click + window.addEventListener('pointerdown', (ev) => this._pointerDownListener(ev), { + signal: this._openPanelEventsController.signal, + }); + window.addEventListener('pointerup', (ev) => this._closeOnBackdropClick(ev), { + signal: this._openPanelEventsController.signal, + }); + + // Keyboard interactions + this.triggerElement?.addEventListener( + 'keydown', + (event: KeyboardEvent) => this.openedPanelKeyboardInteraction(event), + { + signal: this._openPanelEventsController.signal, + }, + ); + } + + // Check if the pointerdown event target is triggered on the menu. + private _pointerDownListener = (event: PointerEvent): void => { + this._isPointerDownEventOnMenu = isEventOnElement(this._overlay, event); + }; + + // If the click is outside the autocomplete, closes the panel. + private _closeOnBackdropClick = (event: PointerEvent): void => { + if ( + !this._isPointerDownEventOnMenu && + !isEventOnElement(this._overlay, event) && + !isEventOnElement(this.originElement, event) + ) { + this.close(); + } + }; + + private _closedPanelKeyboardInteraction(event: KeyboardEvent): void { + if (this.state !== 'closed') { + return; + } + + switch (event.key) { + case 'Enter': + case 'ArrowDown': + case 'ArrowUp': + this.open(); + break; + } + } + + /** Highlight the searched text on the options. */ + private _highlightOptions(searchTerm?: string): void { + if (searchTerm === null || searchTerm === undefined) { + return; + } + this.options.forEach((option) => option.highlight(searchTerm)); + } + + private _removeTriggerAttributes(element?: HTMLInputElement): void { + removeAriaComboBoxAttributes(element); + } + + protected override render(): TemplateResult { + return html` +
+
+
${overlayGapFixCorners()}
+
(this._overlay = overlayRef as HTMLElement))} + > +
+
(this._optionContainer = containerRef as HTMLElement))} + > + +
+
+
+
+ `; + } +} diff --git a/src/elements/autocomplete/autocomplete.spec.ts b/src/elements/autocomplete/autocomplete.spec.ts index cec3ff3584..1a8e1dcfc1 100644 --- a/src/elements/autocomplete/autocomplete.spec.ts +++ b/src/elements/autocomplete/autocomplete.spec.ts @@ -2,9 +2,10 @@ import { assert, expect } from '@open-wc/testing'; import { sendKeys, sendMouse } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; +import { isSafari } from '../core/dom.js'; import { tabKey } from '../core/testing/private/keys.js'; import { fixture } from '../core/testing/private.js'; -import { waitForCondition, waitForLitRender, EventSpy } from '../core/testing.js'; +import { waitForCondition, waitForLitRender, EventSpy, describeIf } from '../core/testing.js'; import { SbbFormFieldElement } from '../form-field.js'; import { SbbOptionElement } from '../option.js'; @@ -29,19 +30,38 @@ describe(`sbb-autocomplete`, () => { element = formField.querySelector('sbb-autocomplete')!; }); - it('renders and sets the correct attributes', () => { - assert.instanceOf(formField, SbbFormFieldElement); - assert.instanceOf(element, SbbAutocompleteElement); - - expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + describeIf(isSafari, 'Safari', async () => { + it('renders and sets the correct attributes', () => { + assert.instanceOf(formField, SbbFormFieldElement); + assert.instanceOf(element, SbbAutocompleteElement); + + expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + + expect(input).to.have.attribute('autocomplete', 'off'); + expect(input).to.have.attribute('role', 'combobox'); + expect(input).to.have.attribute('aria-autocomplete', 'list'); + expect(input).to.have.attribute('aria-haspopup', 'listbox'); + expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); + expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + }); - expect(input).to.have.attribute('autocomplete', 'off'); - expect(input).to.have.attribute('role', 'combobox'); - expect(input).to.have.attribute('aria-autocomplete', 'list'); - expect(input).to.have.attribute('aria-haspopup', 'listbox'); - expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); - expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); - expect(input).to.have.attribute('aria-expanded', 'false'); + describeIf(!isSafari, 'Chrome-Firefox', async () => { + it('renders and sets the correct attributes', () => { + assert.instanceOf(formField, SbbFormFieldElement); + assert.instanceOf(element, SbbAutocompleteElement); + + expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + + expect(input).to.have.attribute('autocomplete', 'off'); + expect(input).to.have.attribute('role', 'combobox'); + expect(input).to.have.attribute('aria-autocomplete', 'list'); + expect(input).to.have.attribute('aria-haspopup', 'listbox'); + expect(input).to.have.attribute('aria-controls', 'sbb-autocomplete-8'); + expect(input).to.have.attribute('aria-owns', 'sbb-autocomplete-8'); + expect(input).to.have.attribute('aria-expanded', 'false'); + }); }); it('opens and closes with mouse and keyboard', async () => { diff --git a/src/elements/autocomplete/autocomplete.ts b/src/elements/autocomplete/autocomplete.ts index 025b1b4d45..0fd7309355 100644 --- a/src/elements/autocomplete/autocomplete.ts +++ b/src/elements/autocomplete/autocomplete.ts @@ -1,24 +1,12 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, isServer, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; +import { customElement } from 'lit/decorators.js'; import { getNextElementIndex } from '../core/a11y.js'; -import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; -import { SbbConnectedAbortController } from '../core/controllers.js'; import { hostAttributes } from '../core/decorators.js'; -import { findReferencedElement, getDocumentWritingMode, isSafari } from '../core/dom.js'; -import { SbbHydrationMixin, SbbNegativeMixin } from '../core/mixins.js'; -import { - isEventOnElement, - overlayGapFixCorners, - removeAriaComboBoxAttributes, - setAriaComboBoxAttributes, - setOverlayPosition, -} from '../core/overlay.js'; +import { getDocumentWritingMode, isSafari } from '../core/dom.js'; +import { setAriaComboBoxAttributes } from '../core/overlay.js'; import type { SbbOptGroupElement, SbbOptionElement } from '../option.js'; -import style from './autocomplete.scss?lit&inline'; +import { SbbAutocompleteBaseElement } from './autocomplete-base-element.js'; let nextId = 0; @@ -45,134 +33,16 @@ const ariaRoleOnHost = isSafari; dir: getDocumentWritingMode(), role: ariaRoleOnHost ? 'listbox' : null, }) -export class SbbAutocompleteElement extends SbbNegativeMixin( - SbbHydrationMixin(SbbOpenCloseBaseElement), -) { - public static override styles: CSSResultGroup = style; - - /** - * The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. - * If not set, will search for the first 'sbb-form-field' ancestor. - */ - @property() public origin?: string | HTMLElement; - - /** - * The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. - * By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. - * If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. - */ - @property() public trigger?: string | HTMLInputElement; - - /** Whether the icon space is preserved when no icon is set. */ - @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) - public preserveIconSpace?: boolean; - - private _overlay!: HTMLElement; - private _optionContainer!: HTMLElement; - - /** Returns the element where autocomplete overlay is attached to. */ - public get originElement(): HTMLElement { - if (!this._originElement) { - this._originElement = this._findOriginElement(); - } - return this._originElement; - } - private _originElement?: HTMLElement; - - /** Returns the trigger element. */ - public get triggerElement(): HTMLInputElement | undefined { - return this._triggerElement; - } - private _triggerElement: HTMLInputElement | undefined; - - private _triggerEventsController!: AbortController; - private _openPanelEventsController!: AbortController; - private _overlayId = `sbb-autocomplete-${++nextId}`; +export class SbbAutocompleteElement extends SbbAutocompleteBaseElement { + protected overlayId = `sbb-autocomplete-${++nextId}`; + protected panelRole = 'listbox'; private _activeItemIndex = -1; - private _didLoad = false; - private _isPointerDownEventOnMenu: boolean = false; - private _abort = new SbbConnectedAbortController(this); - - /** The autocomplete should inherit 'readonly' state from the trigger. */ - private get _readonly(): boolean { - return this.triggerElement?.hasAttribute('readonly') ?? false; - } - private get _options(): SbbOptionElement[] { + protected get options(): SbbOptionElement[] { return Array.from(this.querySelectorAll?.('sbb-option') ?? []); } - /** Opens the autocomplete. */ - public open(): void { - if (this.state !== 'closed' || !this._overlay || this._options.length === 0 || this._readonly) { - return; - } - if (!this.willOpen.emit()) { - return; - } - - this.state = 'opening'; - this._setOverlayPosition(); - } - - /** Closes the autocomplete. */ - public close(): void { - if (this.state !== 'opened') { - return; - } - if (!this.willClose.emit()) { - return; - } - - this.state = 'closing'; - this._openPanelEventsController.abort(); - } - - /** Removes trigger click listener on trigger change. */ - private _resetOriginClickListener( - newValue?: string | HTMLElement, - oldValue?: string | HTMLElement, - ): void { - if (newValue !== oldValue) { - this._componentSetup(); - } - } - - /** Removes trigger click listener on trigger change. */ - private _resetTriggerClickListener( - newValue?: string | HTMLElement, - oldValue?: string | HTMLElement, - ): void { - if (newValue !== oldValue) { - this._componentSetup(); - } - } - - /** When an option is selected, update the input value and close the autocomplete. */ - private _onOptionSelected(event: CustomEvent): void { - const target = event.target as SbbOptionElement; - if (!target.selected) { - return; - } - - // Deselect the previous options - this._options - .filter((option) => option.id !== target.id && option.selected) - .forEach((option) => (option.selected = false)); - - if (this.triggerElement) { - // Set the option value - this.triggerElement.value = target.value as string; - - // Manually trigger the change events - this.triggerElement.dispatchEvent(new Event('change', { bubbles: true })); - this.triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - } - - this.close(); - } - - private _onOptionClick(event: MouseEvent): void { + protected onOptionClick(event: MouseEvent): void { if ( (event.target as Element).localName !== 'sbb-option' || (event.target as SbbOptionElement).disabled @@ -184,51 +54,15 @@ export class SbbAutocompleteElement extends SbbNegativeMixin( public override connectedCallback(): void { super.connectedCallback(); - if (ariaRoleOnHost) { - this.id ||= this._overlayId; - } - - const signal = this._abort.signal; - const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); - - if (formField) { - this.negative = formField.hasAttribute('negative'); - } - - if (this._didLoad) { - this._componentSetup(); - } - this._syncNegative(); - + const signal = this.abort.signal; this.addEventListener( 'optionSelectionChange', - (e: CustomEvent) => this._onOptionSelected(e), + (e: CustomEvent) => this.onOptionSelected(e), { signal }, ); - this.addEventListener('click', (e: MouseEvent) => this._onOptionClick(e), { signal }); - } - - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - - if (changedProperties.has('origin')) { - this._resetOriginClickListener(this.origin, changedProperties.get('origin')); - } - if (changedProperties.has('trigger')) { - this._resetTriggerClickListener(this.trigger, changedProperties.get('trigger')); - } - if (changedProperties.has('negative')) { - this._syncNegative(); - } } - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this._componentSetup(); - this._didLoad = true; - } - - private _syncNegative(): void { + protected syncNegative(): void { this.querySelectorAll?.('sbb-divider').forEach((divider) => (divider.negative = this.negative)); this.querySelectorAll?.( @@ -236,209 +70,7 @@ export class SbbAutocompleteElement extends SbbNegativeMixin( ).forEach((element) => element.toggleAttribute('data-negative', this.negative)); } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - } - - private _componentSetup(): void { - if (isServer) { - return; - } - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - - this._originElement = undefined; - this.toggleAttribute( - 'data-option-panel-origin-borderless', - !!this.closest?.('sbb-form-field')?.hasAttribute('borderless'), - ); - - this._bindTo(this._getTriggerElement()); - } - - /** - * Retrieve the element where the autocomplete will be attached. - * @returns 'origin' or the first 'sbb-form-field' ancestor. - */ - private _findOriginElement(): HTMLElement { - let result: HTMLElement | undefined | null; - - if (!this.origin) { - result = this.closest?.('sbb-form-field')?.shadowRoot?.querySelector?.('#overlay-anchor'); - } else { - result = findReferencedElement(this.origin); - } - - if (!result) { - throw new Error( - 'Cannot find the origin element. Please specify a valid element or read the "origin" prop documentation', - ); - } - - return result; - } - - /** - * Retrieve the element that will trigger the autocomplete opening. - * @returns 'trigger' or the first 'input' inside the origin element. - */ - private _getTriggerElement(): HTMLInputElement { - if (!this.trigger) { - return this.closest?.('sbb-form-field')?.querySelector('input') as HTMLInputElement; - } - - const result = findReferencedElement(this.trigger); - - if (!result) { - throw new Error( - 'Cannot find the trigger element. Please specify a valid element or read the "trigger" prop documentation', - ); - } - - return result; - } - - private _bindTo(triggerElem: HTMLInputElement): void { - if (!triggerElem) { - return; - } - - // Reset attributes to the old trigger and add them to the new one - this._removeTriggerAttributes(this.triggerElement); - this._setTriggerAttributes(triggerElem); - - this._triggerElement = triggerElem; - - this._setupTriggerEvents(); - } - - private _setupTriggerEvents(): void { - this._triggerEventsController = new AbortController(); - - // Open the overlay on focus, click, input and `ArrowDown` event - this.triggerElement?.addEventListener('focus', () => this.open(), { - signal: this._triggerEventsController.signal, - }); - this.triggerElement?.addEventListener('click', () => this.open(), { - signal: this._triggerEventsController.signal, - }); - this.triggerElement?.addEventListener( - 'input', - (event) => { - this.open(); - this._highlightOptions((event.target as HTMLInputElement).value); - }, - { signal: this._triggerEventsController.signal }, - ); - this.triggerElement?.addEventListener( - 'keydown', - (event: KeyboardEvent) => this._closedPanelKeyboardInteraction(event), - { signal: this._triggerEventsController.signal }, - ); - } - - // Set overlay position, width and max height - private _setOverlayPosition(): void { - setOverlayPosition( - this._overlay, - this.originElement, - this._optionContainer, - this.shadowRoot!.querySelector('.sbb-autocomplete__container')!, - this, - ); - } - - /** On open/close animation end. - * In rare cases it can be that the animationEnd event is triggered twice. - * To avoid entering a corrupt state, exit when state is not expected. - */ - private _onAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this.state === 'opening') { - this._onOpenAnimationEnd(); - } else if (event.animationName === 'close' && this.state === 'closing') { - this._onCloseAnimationEnd(); - } - } - - private _onOpenAnimationEnd(): void { - this.state = 'opened'; - this._attachOpenPanelEvents(); - this.triggerElement?.setAttribute('aria-expanded', 'true'); - this.didOpen.emit(); - } - - private _onCloseAnimationEnd(): void { - this.state = 'closed'; - this.triggerElement?.setAttribute('aria-expanded', 'false'); - this._resetActiveElement(); - this._optionContainer.scrollTop = 0; - this.didClose.emit(); - } - - private _attachOpenPanelEvents(): void { - this._openPanelEventsController = new AbortController(); - - // Recalculate the overlay position on scroll and window resize - document.addEventListener('scroll', () => this._setOverlayPosition(), { - passive: true, - signal: this._openPanelEventsController.signal, - }); - window.addEventListener('resize', () => this._setOverlayPosition(), { - passive: true, - signal: this._openPanelEventsController.signal, - }); - - // Close autocomplete on backdrop click - window.addEventListener('pointerdown', (ev) => this._pointerDownListener(ev), { - signal: this._openPanelEventsController.signal, - }); - window.addEventListener('pointerup', (ev) => this._closeOnBackdropClick(ev), { - signal: this._openPanelEventsController.signal, - }); - - // Keyboard interactions - this.triggerElement?.addEventListener( - 'keydown', - (event: KeyboardEvent) => this._openedPanelKeyboardInteraction(event), - { - signal: this._openPanelEventsController.signal, - }, - ); - } - - // Check if the pointerdown event target is triggered on the menu. - private _pointerDownListener = (event: PointerEvent): void => { - this._isPointerDownEventOnMenu = isEventOnElement(this._overlay, event); - }; - - // If the click is outside the autocomplete, closes the panel. - private _closeOnBackdropClick = (event: PointerEvent): void => { - if ( - !this._isPointerDownEventOnMenu && - !isEventOnElement(this._overlay, event) && - !isEventOnElement(this.originElement, event) - ) { - this.close(); - } - }; - - private _closedPanelKeyboardInteraction(event: KeyboardEvent): void { - if (this.state !== 'closed') { - return; - } - - switch (event.key) { - case 'Enter': - case 'ArrowDown': - case 'ArrowUp': - this.open(); - break; - } - } - - private _openedPanelKeyboardInteraction(event: KeyboardEvent): void { + protected openedPanelKeyboardInteraction(event: KeyboardEvent): void { if (this.state !== 'opened') { return; } @@ -450,26 +82,26 @@ export class SbbAutocompleteElement extends SbbNegativeMixin( break; case 'Enter': - this._selectByKeyboard(); + this.selectByKeyboard(); break; case 'ArrowDown': case 'ArrowUp': - this._setNextActiveOption(event); + this.setNextActiveOption(event); break; } } - private _selectByKeyboard(): void { - const activeOption = this._options[this._activeItemIndex]; + protected selectByKeyboard(): void { + const activeOption = this.options[this._activeItemIndex]; if (activeOption) { activeOption.setSelectedViaUserInteraction(true); } } - private _setNextActiveOption(event: KeyboardEvent): void { - const filteredOptions = this._options.filter( + protected setNextActiveOption(event: KeyboardEvent): void { + const filteredOptions = this.options.filter( (opt) => !opt.disabled && !opt.hasAttribute('data-group-disabled'), ); @@ -489,8 +121,8 @@ export class SbbAutocompleteElement extends SbbNegativeMixin( this._activeItemIndex = next; } - private _resetActiveElement(): void { - const activeElement = this._options[this._activeItemIndex]; + protected resetActiveElement(): void { + const activeElement = this.options[this._activeItemIndex]; if (activeElement) { activeElement.active = false; @@ -499,49 +131,8 @@ export class SbbAutocompleteElement extends SbbNegativeMixin( this.triggerElement?.removeAttribute('aria-activedescendant'); } - /** Highlight the searched text on the options. */ - private _highlightOptions(searchTerm?: string): void { - if (!searchTerm) { - return; - } - this._options.forEach((option) => option.highlight(searchTerm)); - } - - private _setTriggerAttributes(element: HTMLInputElement): void { - setAriaComboBoxAttributes(element, this.id || this._overlayId, false); - } - - private _removeTriggerAttributes(element?: HTMLInputElement): void { - removeAriaComboBoxAttributes(element); - } - - private _handleSlotchange(): void { - this._highlightOptions(this.triggerElement?.value); - } - - protected override render(): TemplateResult { - return html` -
-
-
${overlayGapFixCorners()}
-
(this._overlay = overlayRef as HTMLElement))} - > -
-
(this._optionContainer = containerRef as HTMLElement))} - > - -
-
-
-
- `; + protected setTriggerAttributes(element: HTMLInputElement): void { + setAriaComboBoxAttributes(element, ariaRoleOnHost ? this.id : this.overlayId, false); } } diff --git a/src/elements/autocomplete/autocomplete.visual.spec.ts b/src/elements/autocomplete/autocomplete.visual.spec.ts new file mode 100644 index 0000000000..ec4d9415b6 --- /dev/null +++ b/src/elements/autocomplete/autocomplete.visual.spec.ts @@ -0,0 +1,276 @@ +import { sendKeys } from '@web/test-runner-commands'; +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { VisualDiffSetupBuilder } from '../core/testing/private.js'; +import { describeViewports, visualDiffDefault, visualDiffFocus } from '../core/testing/private.js'; + +import '../form-field.js'; +import '../form-error.js'; +import '../option.js'; +import './autocomplete.js'; + +describe('sbb-autocomplete', () => { + const defaultArgs = { + negative: false, + disabled: false, + readonly: false, + required: false, + withIcon: true, + preserveIconSpace: true, + disableOption: false, + borderless: false, + withGroup: false, + disableGroup: false, + withMixedOptionAndGroup: false, + }; + + const textBlock = (): TemplateResult => html` +
+ This text block has a z-index greater than the form field, but it must always be + covered by the autocomplete overlay. +
+ `; + + const createOptionBlockOne = (withIcon: boolean, disableOption: boolean): TemplateResult => html` + ${repeat( + new Array(3), + (_, i) => html` + + ${withIcon && i === 2 + ? html`` + : nothing} + Option ${i} + + `, + )} + `; + + const createOptionBlockTwo = (): TemplateResult => html` + Option 4 + Option 5 + `; + + const createOptions = (withIcon: boolean, disableOption: boolean): TemplateResult => html` + ${createOptionBlockOne(withIcon, disableOption)} ${createOptionBlockTwo()} + `; + + const createOptionsGroup = ( + withIcon: boolean, + disableOption: boolean, + disableGroup: boolean, + ): TemplateResult => html` + + ${createOptionBlockOne(withIcon, disableOption)} + + ${createOptionBlockTwo()} + `; + + const createMixedOptionsGroup = ( + withIcon: boolean, + disableOption: boolean, + disableGroup: boolean, + ): TemplateResult => html` + + + Option Value + + ${createOptionsGroup(withIcon, disableOption, disableGroup)} + `; + + const template = (args: typeof defaultArgs): TemplateResult => html` + + + + + ${args.withGroup + ? args.withMixedOptionAndGroup + ? createMixedOptionsGroup(args.withIcon, args.disableOption, args.disableGroup) + : createOptionsGroup(args.withIcon, args.disableOption, args.disableGroup) + : createOptions(args.withIcon, args.disableOption)} + + ${args.required + ? html`This is a required field.` + : nothing} + + ${textBlock()} + `; + + const openAutocomplete = async (setup: VisualDiffSetupBuilder): Promise => { + const ac = setup.snapshotElement.querySelector('sbb-autocomplete')!; + ac.open(); + const input = setup.snapshotElement.querySelector('input')!; + input.focus(); + await sendKeys({ press: 'O' }); + }; + + describeViewports({ viewports: ['zero', 'medium'], viewportHeight: 500 }, () => { + for (const negative of [false, true]) { + for (const borderless of [false, true]) { + for (const visualDiffState of [visualDiffDefault, visualDiffFocus]) { + it( + `state=above negative=${negative} borderless=${borderless} ${visualDiffState.name}`, + visualDiffState.with(async (setup) => { + await setup.withFixture( + html` +
+ ${template({ ...defaultArgs, negative, borderless })} +
+ `, + { + minHeight: '500px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + } + } + }); + + describeViewports({ viewports: ['zero', 'medium'] }, () => { + for (const negative of [false, true]) { + const style = { + minHeight: '400px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + for (const visualDiffState of [visualDiffDefault, visualDiffFocus]) { + it( + `state=${visualDiffState.name} negative=${negative}`, + visualDiffState.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative }), style); + }), + ); + } + + it( + `state=required negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, required: true }), style); + }), + ); + + it( + `state=disabled negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, disabled: true }), style); + }), + ); + + it( + `state=readonly negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, readonly: true }), style); + }), + ); + + it( + `state=borderless negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, borderless: true }), style); + }), + ); + + it( + `state=noIcon negative=${negative}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ ...defaultArgs, negative, withIcon: false }), style); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + for (const withIcon of [false, true]) { + it( + `state=noSpace negative=${negative} withIcon=${withIcon}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withIcon, preserveIconSpace: false }), + style, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + + for (const withGroup of [false, true]) { + const wrapperStyle = { + minHeight: withGroup ? '800px' : '400px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }; + + it( + `negative=${negative} withGroup=${withGroup}`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup }), + wrapperStyle, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + it( + `negative=${negative} withGroup=${withGroup} disableOption=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup, disableOption: true }), + wrapperStyle, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + + it( + `negative=${negative} withGroup=true disableGroup=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ + ...defaultArgs, + negative, + disableGroup: true, + withGroup: true, + }), + { + minHeight: '800px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + + it( + `negative=${negative} withGroup=true withMixedOptionAndGroup=true`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ ...defaultArgs, negative, withGroup: true, withMixedOptionAndGroup: true }), + { + minHeight: '800px', + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }, + ); + setup.withPostSetupAction(() => openAutocomplete(setup)); + }), + ); + } + }); +}); diff --git a/src/elements/autocomplete/readme.md b/src/elements/autocomplete/readme.md index 4e54c54a6f..50d73055c7 100644 --- a/src/elements/autocomplete/readme.md +++ b/src/elements/autocomplete/readme.md @@ -1,4 +1,6 @@ The `sbb-autocomplete` is a component that can be used to display a panel of suggested options connected to a text input. +Use it when you need a basic autocomplete: a panel with a list of selectable and possibly grouped options. +If you need buttons connected to the options, use the [sbb-autocomplete-grid](/docs/elements-sbb-autocomplete-grid-sbb-autocomplete-grid--docs). It's possible to set the element to which the component's panel will be attached using the `origin` prop, and the input which will work as a trigger using the `trigger` prop. @@ -100,7 +102,7 @@ using `aria-activedescendant` to support navigation though the autocomplete opti | Name | Attribute | Privacy | Type | Default | Description | | ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | -| `origin` | `origin` | public | `string \| HTMLElement \| undefined` | | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, will search for the first 'sbb-form-field' ancestor. | +| `origin` | `origin` | public | `string \| HTMLElement \| undefined` | | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, it will search for the first 'sbb-form-field' ancestor. | | `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | | `preserveIconSpace` | `preserve-icon-space` | public | `boolean \| undefined` | | Whether the icon space is preserved when no icon is set. | | `trigger` | `trigger` | public | `string \| HTMLInputElement \| undefined` | | The input element that will trigger the autocomplete opening; accepts both an element's id or an HTMLElement. By default, the autocomplete will open on focus, click, input or `ArrowDown` keypress of the 'trigger' element. If not set, will search for the first 'input' child of a 'sbb-form-field' ancestor. | diff --git a/src/elements/breadcrumb/breadcrumb-group/breadcrumb-group.ts b/src/elements/breadcrumb/breadcrumb-group/breadcrumb-group.ts index b31709d199..7afb87643d 100644 --- a/src/elements/breadcrumb/breadcrumb-group/breadcrumb-group.ts +++ b/src/elements/breadcrumb/breadcrumb-group/breadcrumb-group.ts @@ -82,6 +82,7 @@ export class SbbBreadcrumbGroupElement extends SbbNamedSlotListMixin< protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); + this._resizeObserver.observe(this); this.toggleAttribute('data-loaded', true); } diff --git a/src/elements/button/mini-button.ts b/src/elements/button/mini-button.ts index 65b7e88ef6..379817a955 100644 --- a/src/elements/button/mini-button.ts +++ b/src/elements/button/mini-button.ts @@ -1 +1,2 @@ +export * from './mini-button/mini-button-base-element.js'; export * from './mini-button/mini-button.js'; diff --git a/src/elements/button/mini-button/mini-button-base-element.ts b/src/elements/button/mini-button/mini-button-base-element.ts new file mode 100644 index 0000000000..7d498911ca --- /dev/null +++ b/src/elements/button/mini-button/mini-button-base-element.ts @@ -0,0 +1,15 @@ +import type { TemplateResult } from 'lit'; + +import { SbbButtonBaseElement } from '../../core/base-elements.js'; +import { slotState } from '../../core/decorators.js'; +import { SbbNegativeMixin } from '../../core/mixins.js'; +import { SbbIconNameMixin } from '../../icon.js'; + +@slotState() +export abstract class SbbMiniButtonBaseElement extends SbbNegativeMixin( + SbbIconNameMixin(SbbButtonBaseElement), +) { + protected override renderTemplate(): TemplateResult { + return super.renderIconSlot(); + } +} diff --git a/src/elements/button/mini-button/mini-button.stories.ts b/src/elements/button/mini-button/mini-button.stories.ts index 77ddfedbae..1a7ba3bf82 100644 --- a/src/elements/button/mini-button/mini-button.stories.ts +++ b/src/elements/button/mini-button/mini-button.stories.ts @@ -53,7 +53,7 @@ const miniButtonDefaultArgs: Args = { slot: slot.options![0], }; -['size', 'text'].forEach((e: string) => { +['size', 'text', 'tag'].forEach((e: string) => { delete miniButtonDefaultArgTypes[e]; delete miniButtonDefaultArgs[e]; }); diff --git a/src/elements/button/mini-button/mini-button.ts b/src/elements/button/mini-button/mini-button.ts index 8e2709bb41..a7fd7bb56f 100644 --- a/src/elements/button/mini-button/mini-button.ts +++ b/src/elements/button/mini-button/mini-button.ts @@ -1,11 +1,9 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; +import type { CSSResultGroup } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { SbbButtonBaseElement } from '../../core/base-elements.js'; -import { slotState } from '../../core/decorators.js'; -import { SbbDisabledTabIndexActionMixin, SbbNegativeMixin } from '../../core/mixins.js'; -import { SbbIconNameMixin } from '../../icon.js'; +import { SbbDisabledTabIndexActionMixin } from '../../core/mixins.js'; +import { SbbMiniButtonBaseElement } from './mini-button-base-element.js'; import style from './mini-button.scss?lit&inline'; /** @@ -15,15 +13,8 @@ import style from './mini-button.scss?lit&inline'; * @slot icon - Slot used to display the icon, if one is set */ @customElement('sbb-mini-button') -@slotState() -export class SbbMiniButtonElement extends SbbNegativeMixin( - SbbIconNameMixin(SbbDisabledTabIndexActionMixin(SbbButtonBaseElement)), -) { +export class SbbMiniButtonElement extends SbbDisabledTabIndexActionMixin(SbbMiniButtonBaseElement) { public static override styles: CSSResultGroup = style; - - protected override renderTemplate(): TemplateResult { - return super.renderIconSlot(); - } } declare global { diff --git a/src/elements/core/overlay/overlay-trigger-attributes.ts b/src/elements/core/overlay/overlay-trigger-attributes.ts index 3f2340cf0b..1adb2f907d 100644 --- a/src/elements/core/overlay/overlay-trigger-attributes.ts +++ b/src/elements/core/overlay/overlay-trigger-attributes.ts @@ -37,6 +37,7 @@ export function setAriaComboBoxAttributes( trigger: HTMLElement, overlayId: string, expanded: boolean, + hasPopup: 'listbox' | 'grid' = 'listbox', ): void { if (!trigger) { return; @@ -45,9 +46,9 @@ export function setAriaComboBoxAttributes( trigger.setAttribute('autocomplete', 'off'); trigger.setAttribute('role', 'combobox'); trigger.setAttribute('aria-autocomplete', 'list'); - trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-haspopup', hasPopup); trigger.setAttribute('aria-controls', overlayId); - trigger.setAttribute('aria-owns', overlayId); // From Aria 1.2 this should not be necessary but safari still needs it + trigger.setAttribute('aria-owns', overlayId); // From Aria 1.2 this should not be necessary, but safari still needs it trigger.setAttribute('aria-expanded', `${expanded}`); } diff --git a/src/elements/core/styles/core.scss b/src/elements/core/styles/core.scss index e769fb784f..1f93b0fec6 100644 --- a/src/elements/core/styles/core.scss +++ b/src/elements/core/styles/core.scss @@ -85,6 +85,7 @@ sbb-form-field { // Hiding components until they are instantiated :is( sbb-autocomplete, + sbb-autocomplete-grid, sbb-dialog, sbb-menu, sbb-navigation, diff --git a/src/elements/core/styles/mixins/buttons.scss b/src/elements/core/styles/mixins/buttons.scss index 24d450c54e..c63769fff8 100644 --- a/src/elements/core/styles/mixins/buttons.scss +++ b/src/elements/core/styles/mixins/buttons.scss @@ -29,19 +29,24 @@ @include icon-button-variables-negative; } - :host(:is([disabled], [data-disabled])) { + :host(:is([disabled], [data-disabled], [data-group-disabled])) { @include icon-button-disabled(#{$button-selector}); } - :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) { + :host( + :is( + [data-focus-visible], + :focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch']) + ) + ) { @include icon-button-focus-visible(#{$button-selector}); } - :host(:not([disabled], [data-disabled], :active, [data-active]):hover) { + :host(:not([disabled], [data-disabled], [data-group-disabled], :active, [data-active]):hover) { @include icon-button-hover(#{$button-selector}); } - :host(:not([disabled], [data-disabled]):is(:active, [data-active])) { + :host(:not([disabled], [data-disabled], [data-group-disabled]):is(:active, [data-active])) { @include icon-button-active(#{$button-selector}); } } diff --git a/src/elements/form-field/form-field-clear/form-field-clear.ts b/src/elements/form-field/form-field-clear/form-field-clear.ts index 923f62b197..8878e77692 100644 --- a/src/elements/form-field/form-field-clear/form-field-clear.ts +++ b/src/elements/form-field/form-field-clear/form-field-clear.ts @@ -51,6 +51,7 @@ export class SbbFormFieldClearElement extends SbbNegativeMixin(SbbButtonBaseElem protected override willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); + this.setAttribute('aria-label', i18nClearInput[this._language.current]); } diff --git a/src/elements/form-field/form-field/form-field.ts b/src/elements/form-field/form-field/form-field.ts index da36688fb0..7d93a9b043 100644 --- a/src/elements/form-field/form-field/form-field.ts +++ b/src/elements/form-field/form-field/form-field.ts @@ -19,7 +19,7 @@ import '../../icon.js'; let nextId = 0; let nextFormFieldErrorId = 0; -const supportedPopupTagNames = ['sbb-autocomplete', 'sbb-select']; +const supportedPopupTagNames = ['sbb-autocomplete', 'sbb-autocomplete-grid', 'sbb-select']; /** * It wraps an input element adding label, errors, icon, etc. @@ -430,7 +430,7 @@ export class SbbFormFieldElement extends SbbNegativeMixin(SbbHydrationMixin(LitE private _syncNegative(): void { this.querySelectorAll?.( - 'sbb-form-error,sbb-mini-button,sbb-popover-trigger,sbb-form-field-clear,sbb-datepicker-next-day,sbb-datepicker-previous-day,sbb-datepicker-toggle,sbb-select,sbb-autocomplete', + 'sbb-form-error,sbb-mini-button,sbb-popover-trigger,sbb-form-field-clear,sbb-datepicker-next-day,sbb-datepicker-previous-day,sbb-datepicker-toggle,sbb-select,sbb-autocomplete,sbb-autocomplete-grid', ).forEach((element) => element.toggleAttribute('negative', this.negative)); } diff --git a/src/elements/icon/icon-base.ts b/src/elements/icon/icon-base.ts index c486439e5e..f856d7906c 100644 --- a/src/elements/icon/icon-base.ts +++ b/src/elements/icon/icon-base.ts @@ -78,6 +78,7 @@ export abstract class SbbIconBase extends LitElement { protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); + this.setAttribute('role', this.getAttribute('role') ?? 'img'); } diff --git a/src/elements/icon/icon.ts b/src/elements/icon/icon.ts index b91d34f623..0dc0ba9aa9 100644 --- a/src/elements/icon/icon.ts +++ b/src/elements/icon/icon.ts @@ -44,6 +44,7 @@ export class SbbIconElement extends SbbIconBase { protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); + if (!this.hasAttribute('aria-hidden')) { this.setAttribute('aria-hidden', 'true'); } diff --git a/src/elements/navigation/navigation-marker/navigation-marker.ts b/src/elements/navigation/navigation-marker/navigation-marker.ts index 2ecf28aa5d..3ca9b7fda3 100644 --- a/src/elements/navigation/navigation-marker/navigation-marker.ts +++ b/src/elements/navigation/navigation-marker/navigation-marker.ts @@ -87,6 +87,7 @@ export class SbbNavigationMarkerElement extends SbbNamedSlotListMixin< protected override firstUpdated(changedProperties: PropertyValues>): void { super.firstUpdated(changedProperties); + setTimeout(() => this._setMarkerPosition()); } diff --git a/src/elements/option/optgroup.ts b/src/elements/option/optgroup.ts index eb0db4f872..81a96bbb7c 100644 --- a/src/elements/option/optgroup.ts +++ b/src/elements/option/optgroup.ts @@ -1 +1,2 @@ export * from './optgroup/optgroup.js'; +export * from './optgroup/optgroup-base-element.js'; diff --git a/src/elements/option/optgroup/optgroup.scss b/src/elements/option/optgroup/optgroup-base-element.scss similarity index 100% rename from src/elements/option/optgroup/optgroup.scss rename to src/elements/option/optgroup/optgroup-base-element.scss diff --git a/src/elements/option/optgroup/optgroup-base-element.ts b/src/elements/option/optgroup/optgroup-base-element.ts new file mode 100644 index 0000000000..a1c5ba8f21 --- /dev/null +++ b/src/elements/option/optgroup/optgroup-base-element.ts @@ -0,0 +1,149 @@ +import { + type CSSResultGroup, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { SbbAutocompleteBaseElement } from '../../autocomplete.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { isSafari, setOrRemoveAttribute } from '../../core/dom.js'; +import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins.js'; +import { AgnosticMutationObserver } from '../../core/observers.js'; +import type { SbbOptionBaseElement } from '../option.js'; + +import style from './optgroup-base-element.scss?lit&inline'; + +import '../../divider.js'; + +/** + * On Safari, the groups labels are not read by VoiceOver. + * To solve the problem, we remove the role="group" and add a hidden span containing the group name + * TODO: We should periodically check if it has been solved and, if so, remove the property. + */ +const inertAriaGroups = isSafari; + +@hostAttributes({ role: !inertAriaGroups ? 'group' : null }) +export abstract class SbbOptgroupBaseElement extends SbbDisabledMixin( + SbbHydrationMixin(LitElement), +) { + public static override styles: CSSResultGroup = style; + + /** Option group label. */ + @property() public label!: string; + + @state() protected negative = false; + + @state() private _inertAriaGroups = false; + + private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); + + protected abstract get options(): SbbOptionBaseElement[]; + protected abstract setAttributeFromParent(): void; + protected abstract getAutocompleteParent(): SbbAutocompleteBaseElement | null; + + public constructor() { + super(); + + if (inertAriaGroups) { + if (this.hydrationRequired) { + this.hydrationComplete.then(() => (this._inertAriaGroups = inertAriaGroups)); + } else { + this._inertAriaGroups = inertAriaGroups; + } + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._negativeObserver?.disconnect(); + this.setAttributeFromParent(); + this._negativeObserver.observe(this, { + attributes: true, + attributeFilter: ['data-negative'], + }); + + this._proxyGroupLabelToOptions(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has('disabled')) { + if (!this._inertAriaGroups) { + this.setAttribute('aria-disabled', this.disabled.toString()); + } + + this.proxyDisabledToOptions(); + } + if (changedProperties.has('label')) { + this._proxyGroupLabelToOptions(); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._negativeObserver?.disconnect(); + } + + private _handleSlotchange(): void { + this.proxyDisabledToOptions(); + this._proxyGroupLabelToOptions(); + this._highlightOptions(); + } + + private _proxyGroupLabelToOptions(): void { + if (!this._inertAriaGroups) { + setOrRemoveAttribute(this, 'aria-label', this.label); + return; + } else if (this.label) { + this.removeAttribute('aria-label'); + for (const option of this.options) { + option.setAttribute('data-group-label', this.label); + option.requestUpdate?.(); + } + } else { + for (const option of this.options) { + option.removeAttribute('data-group-label'); + option.requestUpdate?.(); + } + } + } + + protected proxyDisabledToOptions(): void { + for (const option of this.options) { + option.toggleAttribute('data-group-disabled', this.disabled); + } + } + + private _highlightOptions(): void { + const autocomplete = this.getAutocompleteParent(); + if (!autocomplete) { + return; + } + const value = autocomplete.triggerElement?.value; + if (!value) { + return; + } + this.options.forEach((opt) => opt.highlight(value)); + } + + private _onNegativeChange(): void { + this.negative = this.hasAttribute('data-negative'); + } + + protected override render(): TemplateResult { + return html` +
+ +
+ + + `; + } +} diff --git a/src/elements/option/optgroup/optgroup.stories.ts b/src/elements/option/optgroup/optgroup.stories.ts index 26c61b4e9a..2aee7d1395 100644 --- a/src/elements/option/optgroup/optgroup.stories.ts +++ b/src/elements/option/optgroup/optgroup.stories.ts @@ -119,9 +119,8 @@ const createOptions = (args: Args): TemplateResult[] => value=${`${args.value} ${i + 1}`} ?disabled=${args.disabledSingle && i === 0} icon-name=${args['icon-name'] || nothing} + >${`${args.value} ${i + 1}`} - ${`${args.value} ${i + 1}`} - `; }); diff --git a/src/elements/option/optgroup/optgroup.ts b/src/elements/option/optgroup/optgroup.ts index 48b61f9ccf..9d793dbc34 100644 --- a/src/elements/option/optgroup/optgroup.ts +++ b/src/elements/option/optgroup/optgroup.ts @@ -1,23 +1,9 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement } from 'lit/decorators.js'; -import { hostAttributes } from '../../core/decorators.js'; -import { isSafari, setOrRemoveAttribute } from '../../core/dom.js'; -import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins.js'; -import { AgnosticMutationObserver } from '../../core/observers.js'; +import type { SbbAutocompleteElement } from '../../autocomplete.js'; import type { SbbOptionElement } from '../option.js'; -import style from './optgroup.scss?lit&inline'; - -import '../../divider.js'; - -/** - * On Safari, the groups labels are not read by VoiceOver. - * To solve the problem, we remove the role="group" and add a hidden span containing the group name - * TODO: We should periodically check if it has been solved and, if so, remove the property. - */ -const inertAriaGroups = isSafari; +import { SbbOptgroupBaseElement } from './optgroup-base-element.js'; /** * It can be used as a container for one or more `sbb-option`. @@ -25,72 +11,24 @@ const inertAriaGroups = isSafari; * @slot - Use the unnamed slot to add `sbb-option` elements to the `sbb-optgroup`. */ @customElement('sbb-optgroup') -@hostAttributes({ role: !inertAriaGroups ? 'group' : null }) -export class SbbOptGroupElement extends SbbDisabledMixin(SbbHydrationMixin(LitElement)) { - public static override styles: CSSResultGroup = style; - - /** Option group label. */ - @property() public label!: string; - - @state() private _negative = false; - - @state() private _inertAriaGroups = false; - - private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); - - private get _options(): SbbOptionElement[] { +export class SbbOptGroupElement extends SbbOptgroupBaseElement { + protected get options(): SbbOptionElement[] { return Array.from(this.querySelectorAll?.('sbb-option') ?? []) as SbbOptionElement[]; } - public constructor() { - super(); + protected getAutocompleteParent(): SbbAutocompleteElement | null { + return this.closest?.('sbb-autocomplete') || null; + } - if (inertAriaGroups) { - if (this.hydrationRequired) { - this.hydrationComplete.then(() => (this._inertAriaGroups = inertAriaGroups)); - } else { - this._inertAriaGroups = inertAriaGroups; - } - } + protected setAttributeFromParent(): void { + this.negative = !!this.closest?.(`:is(sbb-autocomplete, sbb-select, sbb-form-field)[negative]`); + this.toggleAttribute('data-negative', this.negative); } public override connectedCallback(): void { super.connectedCallback(); - this._negativeObserver?.disconnect(); - this._negative = !!this.closest?.( - `:is(sbb-autocomplete, sbb-select, sbb-form-field)[negative]`, - ); - this.toggleAttribute('data-negative', this._negative); - - this._negativeObserver.observe(this, { - attributes: true, - attributeFilter: ['data-negative'], - }); - - this._setVariantByContext(); - this._proxyGroupLabelToOptions(); - this.toggleAttribute('data-multiple', !!this.closest('sbb-select[multiple]')); - } - - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - - if (changedProperties.has('disabled')) { - if (!this._inertAriaGroups) { - this.setAttribute('aria-disabled', this.disabled.toString()); - } - - this._proxyDisabledToOptions(); - } - if (changedProperties.has('label')) { - this._proxyGroupLabelToOptions(); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._negativeObserver?.disconnect(); + this._setVariantByContext(); } private _setVariantByContext(): void { @@ -100,65 +38,6 @@ export class SbbOptGroupElement extends SbbDisabledMixin(SbbHydrationMixin(LitEl this.setAttribute('data-variant', 'select'); } } - - private _handleSlotchange(): void { - this._proxyDisabledToOptions(); - this._proxyGroupLabelToOptions(); - this._highlightOptions(); - } - - private _proxyGroupLabelToOptions(): void { - if (!this._inertAriaGroups) { - setOrRemoveAttribute(this, 'aria-label', this.label); - return; - } else if (this.label) { - this.removeAttribute('aria-label'); - for (const option of this._options) { - option.setAttribute('data-group-label', this.label); - option.requestUpdate?.(); - } - } else { - for (const option of this._options) { - option.removeAttribute('data-group-label'); - option.requestUpdate?.(); - } - } - } - - private _proxyDisabledToOptions(): void { - for (const option of this._options) { - option.toggleAttribute('data-group-disabled', this.disabled); - } - } - - private _highlightOptions(): void { - const autocomplete = this.closest('sbb-autocomplete'); - if (!autocomplete) { - return; - } - const value = autocomplete.triggerElement?.value; - if (!value) { - return; - } - this._options.forEach((opt) => opt.highlight(value)); - } - - private _onNegativeChange(): void { - this._negative = this.hasAttribute('data-negative'); - } - - protected override render(): TemplateResult { - return html` -
- -
- - - `; - } } declare global { diff --git a/src/elements/option/optgroup/optgroup.visual.spec.ts b/src/elements/option/optgroup/optgroup.visual.spec.ts new file mode 100644 index 0000000000..a50bdd2469 --- /dev/null +++ b/src/elements/option/optgroup/optgroup.visual.spec.ts @@ -0,0 +1,133 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import '../../form-field.js'; +import '../../autocomplete.js'; +import '../../select.js'; +import '../option.js'; +import './optgroup.js'; + +describe(`sbb-optgroup`, () => { + const defaultArgs = { + iconName: undefined as string | undefined, + disabled: false, + disabledSingle: false, + }; + + const createOptions = ( + iconName: string | undefined, + disabledSingle: boolean, + ): TemplateResult => html` + ${repeat( + new Array(3), + (_, i) => html` + Option ${i + 1} + `, + )} + `; + + const template = (args: typeof defaultArgs): TemplateResult => html` + + ${createOptions(args.iconName, args.disabledSingle)} + + + ${createOptions(args.iconName, args.disabledSingle)} + + `; + + const standaloneTemplate = (args: typeof defaultArgs): TemplateResult => html` +
+ ${template(args)} +
+ `; + + const autocompleteTemplate = (args: typeof defaultArgs): TemplateResult => html` + + + + ${template(args)} + + `; + + const selectTemplate = (args: typeof defaultArgs, multiple: boolean): TemplateResult => html` + + + ${template(args)} + + `; + + describeViewports({ viewports: ['micro', 'medium'] }, () => { + describe('standalone', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate(defaultArgs)); + }), + ); + + it( + `icon`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, iconName: 'clock-small' })); + }), + ); + + it( + `disabled`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, disabled: true })); + }), + ); + + it( + `disabledSingle`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, disabledSingle: true })); + }), + ); + }); + + describe('autocomplete', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(autocompleteTemplate(defaultArgs), { minHeight: '600px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-autocomplete')!.open(), + ); + }), + ); + }); + + describe('select', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(selectTemplate(defaultArgs, false), { minHeight: '600px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-select')!.open(), + ); + }), + ); + + it( + 'multiple', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(selectTemplate(defaultArgs, true), { minHeight: '600px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-select')!.open(), + ); + }), + ); + }); + }); +}); diff --git a/src/elements/option/option.ts b/src/elements/option/option.ts index 700fb16c64..98e8cb10ef 100644 --- a/src/elements/option/option.ts +++ b/src/elements/option/option.ts @@ -1 +1,2 @@ export * from './option/option.js'; +export * from './option/option-base-element.js'; diff --git a/src/elements/option/option/option-base-element.ts b/src/elements/option/option/option-base-element.ts new file mode 100644 index 0000000000..52cd5e4f42 --- /dev/null +++ b/src/elements/option/option/option-base-element.ts @@ -0,0 +1,281 @@ +import { html, LitElement, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import { SbbConnectedAbortController } from '../../core/controllers.js'; +import { slotState } from '../../core/decorators.js'; +import { isAndroid, isSafari, setOrRemoveAttribute } from '../../core/dom.js'; +import type { EventEmitter } from '../../core/eventing.js'; +import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins.js'; +import { AgnosticMutationObserver } from '../../core/observers.js'; +import { SbbIconNameMixin } from '../../icon.js'; +import '../../screen-reader-only.js'; + +let nextId = 0; + +/** + * On Safari, the groups labels are not read by VoiceOver. + * To solve the problem, we remove the role="group" and add an hidden span containing the group name + * TODO: We should periodically check if it has been solved and, if so, remove the property. + */ +const inertAriaGroups = isSafari; + +/** Configuration for the attribute to look at if component is nested in an option group */ +const optionObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-disabled', 'data-negative'], +}; + +@slotState() +export abstract class SbbOptionBaseElement extends SbbDisabledMixin( + SbbIconNameMixin(SbbHydrationMixin(LitElement)), +) { + protected abstract optionId: string; + + /** + * Value of the option. + * + * @description Developer note: In this case updating the attribute must be synchronous. + * Due to this, it is implemented as a getter/setter and the attributeChangedCallback() handles the diff check. + */ + @property() + public set value(value: string) { + this.setAttribute('value', `${value}`); + } + public get value(): string { + return this.getAttribute('value') ?? ''; + } + + /** Whether the option is currently active. */ + @property({ reflect: true, type: Boolean }) public active?: boolean; + + /** Whether the option is selected. */ + @property({ type: Boolean }) + public set selected(value: boolean) { + this.toggleAttribute('selected', value); + this._updateAriaSelected(); + } + public get selected(): boolean { + return this.hasAttribute('selected'); + } + + /** Emits when the option selection status changes. */ + protected abstract selectionChange: EventEmitter; + + /** Emits when an option was selected by user. */ + protected abstract optionSelected: EventEmitter; + + /** Whether to apply the negative styling */ + @state() protected negative = false; + + /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ + @state() protected disabledFromGroup = false; + + @state() protected label?: string; + + /** Disable the highlight of the label. */ + @state() protected disableLabelHighlight: boolean = false; + + /** The portion of the highlighted label. */ + @state() private _highlightString: string | null = null; + + @state() private _inertAriaGroups = false; + + private _abort = new SbbConnectedAbortController(this); + protected abstract selectByClick(event: MouseEvent): void; + protected abstract setAttributeFromParent(): void; + + protected updateDisableHighlight(disabled: boolean): void { + this.disableLabelHighlight = disabled; + this.toggleAttribute('data-disable-highlight', disabled); + } + + /** MutationObserver on data attributes. */ + private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => + this.onOptionAttributesChange(mutationsList), + ); + + public constructor() { + super(); + + if (inertAriaGroups) { + if (this.hydrationRequired) { + this.hydrationComplete.then(() => (this._inertAriaGroups = inertAriaGroups)); + } else { + this._inertAriaGroups = inertAriaGroups; + } + } + } + + public override attributeChangedCallback( + name: string, + old: string | null, + value: string | null, + ): void { + if (name !== 'value' || old !== value) { + super.attributeChangedCallback(name, old, value); + } + } + + /** + * Highlight the label of the option + * @param value the highlighted portion of the label + * @internal + */ + public highlight(value: string): void { + this._highlightString = value; + } + + /** + * @internal + */ + public setSelectedViaUserInteraction(selected: boolean): void { + this.selected = selected; + this.selectionChange.emit(); + if (this.selected) { + this.optionSelected.emit(); + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.id ||= `${this.optionId}-${nextId++}`; + if (this.hydrationRequired) { + this.hydrationComplete.then(() => this.init()); + } else { + this.init(); + } + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has('disabled')) { + setOrRemoveAttribute(this, 'tabindex', isAndroid && !this.disabled && 0); + this.updateAriaDisabled(); + } + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + + // Init first select state because false would not call setter of selected property. + this._updateAriaSelected(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._optionAttributeObserver.disconnect(); + } + + protected init(): void { + this.setAttributeFromParent(); + this._optionAttributeObserver.observe(this, optionObserverConfig); + const signal = this._abort.signal; + this.addEventListener('click', (e: MouseEvent) => this.selectByClick(e), { + signal, + passive: true, + }); + } + + protected updateAriaDisabled(): void { + setOrRemoveAttribute( + this, + 'aria-disabled', + this.disabled || this.disabledFromGroup ? 'true' : null, + ); + } + + private _updateAriaSelected(): void { + this.setAttribute('aria-selected', `${this.selected}`); + } + + /** Observe changes on data attributes and set the appropriate values. */ + protected onOptionAttributesChange(mutationsList: MutationRecord[]): void { + for (const mutation of mutationsList) { + if (mutation.attributeName === 'data-group-disabled') { + this.disabledFromGroup = this.hasAttribute('data-group-disabled'); + this.updateAriaDisabled(); + } else if (mutation.attributeName === 'data-negative') { + this.negative = this.hasAttribute('data-negative'); + } + } + } + + protected handleHighlightState(): void { + const slotNodes = Array.from(this.childNodes ?? []).filter( + (n) => n.nodeType !== Node.COMMENT_NODE && (!(n instanceof Element) || n.slot !== 'icon'), + ); + const labelNodes = slotNodes.filter((el) => el.nodeType === Node.TEXT_NODE) as Text[]; + + // Disable the highlight if the slot contain more than just text nodes. + // We need to ignore template elements, as SSR adds a declarative shadow DOM + // in the form of a template element. + if ( + labelNodes.length === 0 || + slotNodes.filter((n) => !(n instanceof Element) || n.localName !== 'template').length !== + labelNodes.length + ) { + this.updateDisableHighlight(true); + return; + } + this.label = labelNodes + .map((l) => l.wholeText) + .filter((l) => l.trim()) + .join(); + } + + protected getHighlightedLabel(): TemplateResult { + if (!this._highlightString || !this._highlightString.trim()) { + return html`${this.label}`; + } + + const matchIndex = this.label!.toLowerCase().indexOf(this._highlightString.toLowerCase()); + + if (matchIndex === -1) { + return html`${this.label}`; + } + + const prefix = this.label!.substring(0, matchIndex); + const highlighted = this.label!.substring( + matchIndex, + matchIndex + this._highlightString.length, + ); + const postfix = this.label!.substring(matchIndex + this._highlightString.length); + + return html` + ${prefix}${highlighted}${postfix} + `; + } + + protected renderIcon(): TemplateResult { + return html` ${this.renderIconSlot()} `; + } + + protected renderLabel(): TemplateResult | typeof nothing { + return this.label && !this.disableLabelHighlight ? this.getHighlightedLabel() : nothing; + } + + protected renderTick(): TemplateResult | typeof nothing { + return nothing; + } + + protected override render(): TemplateResult { + return html` +
+
+ ${this.renderIcon()} + + + ${this.renderLabel()} + ${this._inertAriaGroups && this.getAttribute('data-group-label') + ? html` + (${this.getAttribute('data-group-label')})` + : nothing} + + ${this.renderTick()} +
+
+ `; + } +} diff --git a/src/elements/option/option/option.scss b/src/elements/option/option/option.scss index 06bb282543..9e1dbb3c1f 100644 --- a/src/elements/option/option/option.scss +++ b/src/elements/option/option/option.scss @@ -45,7 +45,7 @@ --sbb-option-background-color: var(--sbb-option-background-color-active); } -// if the highlight is enabled, hide the slot content +// If highlighting is enabled, hide the original slot content :host(:not([data-disable-highlight])) { .sbb-option__label slot { display: none; diff --git a/src/elements/option/option/option.stories.ts b/src/elements/option/option/option.stories.ts index 5acbbe8a35..4b0231c084 100644 --- a/src/elements/option/option/option.stories.ts +++ b/src/elements/option/option/option.stories.ts @@ -104,9 +104,8 @@ const createOptions = ({ ?disabled=${disabled && i === 0} value=${`${value} ${i + 1}`} ${sbbSpread(args)} + >${`${value} ${i + 1}`} - ${`${value} ${i + 1}`} - `; }), html` diff --git a/src/elements/option/option/option.ts b/src/elements/option/option/option.ts index 5cf7d14222..15d71b36ba 100644 --- a/src/elements/option/option/option.ts +++ b/src/elements/option/option/option.ts @@ -1,40 +1,14 @@ -import { - type CSSResultGroup, - html, - LitElement, - nothing, - type PropertyValues, - type TemplateResult, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { html, nothing } from 'lit'; +import { customElement } from 'lit/decorators.js'; -import { SbbConnectedAbortController } from '../../core/controllers.js'; -import { hostAttributes, slotState } from '../../core/decorators.js'; -import { isAndroid, isSafari, setOrRemoveAttribute } from '../../core/dom.js'; +import { hostAttributes } from '../../core/decorators.js'; import { EventEmitter } from '../../core/eventing.js'; -import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins.js'; -import { AgnosticMutationObserver } from '../../core/observers.js'; -import { SbbIconNameMixin } from '../../icon.js'; +import { SbbOptionBaseElement } from './option-base-element.js'; import style from './option.scss?lit&inline'; - -import '../../screen-reader-only.js'; import '../../visual-checkbox.js'; -/** - * On Safari, the groups labels are not read by VoiceOver. - * To solve the problem, we remove the role="group" and add an hidden span containing the group name - * TODO: We should periodically check if it has been solved and, if so, remove the property. - */ -const inertAriaGroups = isSafari; - -let nextId = 0; - -/** Configuration for the attribute to look at if component is nested in a sbb-checkbox-group */ -const optionObserverConfig: MutationObserverInit = { - attributeFilter: ['data-group-disabled', 'data-negative'], -}; - export type SbbOptionVariant = 'autocomplete' | 'select' | null; /** @@ -51,71 +25,27 @@ export type SbbOptionVariant = 'autocomplete' | 'select' | null; @hostAttributes({ role: 'option', }) -@slotState() -export class SbbOptionElement extends SbbDisabledMixin( - SbbIconNameMixin(SbbHydrationMixin(LitElement)), -) { +export class SbbOptionElement extends SbbOptionBaseElement { public static override styles: CSSResultGroup = style; public static readonly events = { selectionChange: 'optionSelectionChange', optionSelected: 'optionSelected', } as const; - /** - * Value of the option. - * - * @description Developer note: In this case updating the attribute must be synchronous. - * Due to this it is implemented as a getter/setter and the attributeChangedCallback() handles the diff check. - */ - @property() - public set value(value: string) { - this.setAttribute('value', `${value}`); - } - public get value(): string { - return this.getAttribute('value') ?? ''; - } - - /** Whether the option is currently active. */ - @property({ reflect: true, type: Boolean }) public active?: boolean; - - /** Whether the option is selected. */ - @property({ type: Boolean }) - public set selected(value: boolean) { - this.toggleAttribute('selected', value); - this._updateAriaSelected(); - } - public get selected(): boolean { - return this.hasAttribute('selected'); - } + protected optionId = `sbb-option`; /** Emits when the option selection status changes. */ - private _selectionChange: EventEmitter = new EventEmitter( + protected selectionChange: EventEmitter = new EventEmitter( this, SbbOptionElement.events.selectionChange, ); /** Emits when an option was selected by user. */ - private _optionSelected: EventEmitter = new EventEmitter( + protected optionSelected: EventEmitter = new EventEmitter( this, SbbOptionElement.events.optionSelected, ); - /** Whether to apply the negative styling */ - @state() private _negative = false; - - /** Whether the component must be set disabled due disabled attribute on sbb-checkbox-group. */ - @state() private _disabledFromGroup = false; - - @state() private _label?: string; - - /** The portion of the highlighted label. */ - @state() private _highlightString: string | null = null; - - /** Disable the highlight of the label. */ - @state() private _disableLabelHighlight: boolean = false; - - @state() private _inertAriaGroups = false; - private set _variant(state: SbbOptionVariant) { if (state) { this.setAttribute('data-variant', state); @@ -132,62 +62,24 @@ export class SbbOptionElement extends SbbDisabledMixin( return !this.hydrationRequired && this.hasAttribute('data-multiple'); } - private _abort = new SbbConnectedAbortController(this); - - /** MutationObserver on data attributes. */ - private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => - this._onOptionAttributesChange(mutationsList), - ); - - public constructor() { - super(); - - if (inertAriaGroups) { - if (this.hydrationRequired) { - this.hydrationComplete.then(() => (this._inertAriaGroups = inertAriaGroups)); - } else { - this._inertAriaGroups = inertAriaGroups; - } - } - } - - public override attributeChangedCallback( - name: string, - old: string | null, - value: string | null, - ): void { - if (name !== 'value' || old !== value) { - super.attributeChangedCallback(name, old, value); + protected setAttributeFromParent(): void { + const parentGroup = this.closest?.('sbb-optgroup'); + if (parentGroup) { + this.disabledFromGroup = parentGroup.disabled; + this.updateAriaDisabled(); } - } - /** - * Highlight the label of the option - * @param value the highlighted portion of the label - * @internal - */ - public highlight(value: string): void { - this._highlightString = value; - } - - /** - * @internal - */ - public setSelectedViaUserInteraction(selected: boolean): void { - this.selected = selected; - this._selectionChange.emit(); - if (this.selected) { - this._optionSelected.emit(); - } - } + this.negative = !!this.closest?.( + // :is() selector not possible due to test environment + `sbb-autocomplete[negative],sbb-form-field[negative]`, + ); + this.toggleAttribute('data-negative', this.negative); - private _updateDisableHighlight(disabled: boolean): void { - this._disableLabelHighlight = disabled; - this.toggleAttribute('data-disable-highlight', disabled); + this.toggleAttribute('data-multiple', this._isMultiple); } - private _selectByClick(event: MouseEvent): void { - if (this.disabled || this._disabledFromGroup) { + protected selectByClick(event: MouseEvent): void { + if (this.disabled || this.disabledFromGroup) { event.stopPropagation(); return; } @@ -202,74 +94,15 @@ export class SbbOptionElement extends SbbDisabledMixin( public override connectedCallback(): void { super.connectedCallback(); - - this.id ||= `sbb-option-${nextId++}`; - - if (this.hydrationRequired) { - this.hydrationComplete.then(() => this._init()); - } else { - this._init(); - } } - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - - if (changedProperties.has('disabled')) { - setOrRemoveAttribute(this, 'tabindex', isAndroid && !this.disabled && 0); - this._updateAriaDisabled(); - } - } - - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - - // Init first select state because false would not call setter of selected property. - this._updateAriaSelected(); - } - - private _init(): void { - const signal = this._abort.signal; - const parentGroup = this.closest?.('sbb-optgroup'); - if (parentGroup) { - this._disabledFromGroup = parentGroup.disabled; - this._updateAriaDisabled(); - } - this._optionAttributeObserver.observe(this, optionObserverConfig); - - this._negative = !!this.closest?.( - // :is() selector not possible due to test environment - `sbb-autocomplete[negative],sbb-select[negative],sbb-form-field[negative]`, - ); - this.toggleAttribute('data-negative', this._negative); - + protected override init(): void { + super.init(); this._setVariantByContext(); // We need to check highlight state both on slot change, but also when connecting // the element to the DOM. The slot change events might be swallowed when using declarative // shadow DOM with SSR or if the DOM is changed when disconnected. - this._handleHighlightState(); - - this.addEventListener('click', (e: MouseEvent) => this._selectByClick(e), { - signal, - passive: true, - }); - } - - private _updateAriaDisabled(): void { - setOrRemoveAttribute( - this, - 'aria-disabled', - this.disabled || this._disabledFromGroup ? 'true' : null, - ); - } - - private _updateAriaSelected(): void { - this.setAttribute('aria-selected', `${this.selected}`); - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._optionAttributeObserver.disconnect(); + this.handleHighlightState(); } private _setVariantByContext(): void { @@ -281,112 +114,45 @@ export class SbbOptionElement extends SbbDisabledMixin( this._isMultiple = !!this.closest?.('sbb-select[multiple]'); } - /** Observe changes on data attributes and set the appropriate values. */ - private _onOptionAttributesChange(mutationsList: MutationRecord[]): void { - for (const mutation of mutationsList) { - if (mutation.attributeName === 'data-group-disabled') { - this._disabledFromGroup = this.hasAttribute('data-group-disabled'); - this._updateAriaDisabled(); - } else if (mutation.attributeName === 'data-negative') { - this._negative = this.hasAttribute('data-negative'); - } - } - } - - private _handleHighlightState(): void { + protected override handleHighlightState(): void { if (this._variant !== 'autocomplete') { - this._updateDisableHighlight(true); + this.updateDisableHighlight(true); return; } - const slotNodes = Array.from(this.childNodes ?? []).filter( - (n) => !(n instanceof Element) || n.slot !== 'icon', - ); - const labelNodes = slotNodes.filter((el) => el.nodeType === Node.TEXT_NODE) as Text[]; - - // Disable the highlight if the slot contain more than just text nodes. - // We need to ignore template elements, as SSR adds a declarative shadow DOM - // in the form of a template element. - if ( - labelNodes.length === 0 || - slotNodes.filter((n) => !(n instanceof Element) || n.localName !== 'template').length !== - labelNodes.length - ) { - this._updateDisableHighlight(true); - return; - } - this._label = labelNodes - .map((l) => l.wholeText) - .filter((l) => l.trim()) - .join(); + super.handleHighlightState(); } - private _getHighlightedLabel(): TemplateResult { - if (!this._highlightString || !this._highlightString.trim()) { - return html`${this._label}`; - } - - const matchIndex = this._label!.toLowerCase().indexOf(this._highlightString.toLowerCase()); - - if (matchIndex === -1) { - return html`${this._label}`; - } - - const prefix = this._label!.substring(0, matchIndex); - const highlighted = this._label!.substring( - matchIndex, - matchIndex + this._highlightString.length, - ); - const postfix = this._label!.substring(matchIndex + this._highlightString.length); - + protected override renderIcon(): TemplateResult { return html` - ${prefix}${highlighted}${postfix} + + ${!this._isMultiple + ? html` ${this.renderIconSlot()} ` + : nothing} + + + ${this._isMultiple + ? html` + + ` + : nothing} `; } - protected override render(): TemplateResult { - const isMultiple = this._isMultiple; - - return html` -
-
- - ${!isMultiple - ? html` ${this.renderIconSlot()} ` - : nothing} - - - ${isMultiple - ? html` ` - : nothing} - - - - - - - ${this._variant === 'autocomplete' && this._label && !this._disableLabelHighlight - ? this._getHighlightedLabel() - : nothing} - ${this._inertAriaGroups && this.getAttribute('data-group-label') - ? html` - (${this.getAttribute('data-group-label')})` - : nothing} - + protected override renderLabel(): TemplateResult | typeof nothing { + return this._variant === 'autocomplete' && this.label && !this.disableLabelHighlight + ? this.getHighlightedLabel() + : nothing; + } - - ${this._variant === 'select' && !isMultiple && this.selected - ? html`` - : nothing} -
-
- `; + protected override renderTick(): TemplateResult | typeof nothing { + return this._variant === 'select' && !this._isMultiple && this.selected + ? html`` + : nothing; } } diff --git a/src/elements/option/option/option.visual.spec.ts b/src/elements/option/option/option.visual.spec.ts new file mode 100644 index 0000000000..0401bc13ab --- /dev/null +++ b/src/elements/option/option/option.visual.spec.ts @@ -0,0 +1,129 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { describeViewports, visualDiffDefault } from '../../core/testing/private.js'; + +import '../../form-field.js'; +import '../../select.js'; +import '../../autocomplete.js'; +import './option.js'; + +describe(`sbb-option`, () => { + const defaultArgs = { + iconName: undefined as string | undefined, + active: false, + disabled: false, + preserveIconSpace: false, + }; + + const createOptions = ({ + active, + disabled, + preserveIconSpace, + iconName, + }: typeof defaultArgs): TemplateResult => { + const style = preserveIconSpace ? { '--sbb-option-icon-container-display': 'block' } : {}; + return html` + ${repeat( + new Array(5), + (_, i) => html` + Value ${i + 1} + `, + )} + `; + }; + + const standaloneTemplate = (args: typeof defaultArgs): TemplateResult => html` +
+ ${createOptions(args)} +
+ `; + + const autocompleteTemplate = (args: typeof defaultArgs): TemplateResult => html` + + + + ${createOptions(args)} + + `; + + const selectTemplate = (args: typeof defaultArgs): TemplateResult => html` + + + ${createOptions(args)} + + `; + + describeViewports({ viewports: ['micro', 'medium'] }, () => { + describe('standalone', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate(defaultArgs)); + }), + ); + + it( + `icon`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, iconName: 'clock-small' })); + }), + ); + + it( + `disabled`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, disabled: true })); + }), + ); + + it( + `active`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, active: true })); + }), + ); + + it( + `preserveIconSpace`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(standaloneTemplate({ ...defaultArgs, preserveIconSpace: true })); + }), + ); + }); + + describe('autocomplete', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(autocompleteTemplate(defaultArgs), { minHeight: '400px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-autocomplete')!.open(), + ); + }), + ); + }); + + describe('select', () => { + it( + visualDiffDefault.name, + visualDiffDefault.with(async (setup) => { + await setup.withFixture(selectTemplate(defaultArgs), { minHeight: '400px' }); + setup.withPostSetupAction(() => + setup.snapshotElement.querySelector('sbb-select')!.open(), + ); + }), + ); + }); + }); +}); diff --git a/src/elements/option/option/readme.md b/src/elements/option/option/readme.md index b745c47a1b..a13b1db43c 100644 --- a/src/elements/option/option/readme.md +++ b/src/elements/option/option/readme.md @@ -25,7 +25,7 @@ If the `sbb-option` is nested in a `sbb-optgroup` component, it inherits from th Option label -Option label +Option label ``` ## Events diff --git a/src/elements/stepper/stepper/__snapshots__/stepper.snapshot.spec.snap.js b/src/elements/stepper/stepper/__snapshots__/stepper.snapshot.spec.snap.js index 053a4acebd..f6909850dc 100644 --- a/src/elements/stepper/stepper/__snapshots__/stepper.snapshot.spec.snap.js +++ b/src/elements/stepper/stepper/__snapshots__/stepper.snapshot.spec.snap.js @@ -109,7 +109,7 @@ snapshots["sbb-stepper renders Shadow DOM"] = `; /* end snapshot sbb-stepper renders Shadow DOM */ -snapshots["sbb-stepper renders A11y tree Chrome"] = +snapshots["sbb-stepper renders A11y tree Chrome"] = `

{ "role": "WebArea", diff --git a/src/elements/table/table-wrapper/__snapshots__/table-wrapper.snapshot.spec.snap.js b/src/elements/table/table-wrapper/__snapshots__/table-wrapper.snapshot.spec.snap.js index af5743f875..956b7eacac 100644 --- a/src/elements/table/table-wrapper/__snapshots__/table-wrapper.snapshot.spec.snap.js +++ b/src/elements/table/table-wrapper/__snapshots__/table-wrapper.snapshot.spec.snap.js @@ -40,7 +40,7 @@ snapshots["sbb-table-wrapper renders Shadow DOM"] = `; /* end snapshot sbb-table-wrapper renders Shadow DOM */ -snapshots["sbb-table-wrapper renders A11y tree Chrome"] = +snapshots["sbb-table-wrapper renders A11y tree Chrome"] = `

{ "role": "WebArea", diff --git a/src/elements/time-input/__snapshots__/time-input.snapshot.spec.snap.js b/src/elements/time-input/__snapshots__/time-input.snapshot.spec.snap.js index 4e883f9860..3cad41657a 100644 --- a/src/elements/time-input/__snapshots__/time-input.snapshot.spec.snap.js +++ b/src/elements/time-input/__snapshots__/time-input.snapshot.spec.snap.js @@ -1,7 +1,7 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["sbb-time-input A11y tree Chrome"] = +snapshots["sbb-time-input A11y tree Chrome"] = `

{ "role": "WebArea", @@ -17,7 +17,7 @@ snapshots["sbb-time-input A11y tree Chrome"] = `; /* end snapshot sbb-time-input A11y tree Chrome */ -snapshots["sbb-time-input A11y tree Firefox"] = +snapshots["sbb-time-input A11y tree Firefox"] = `

{ "role": "document", @@ -55,7 +55,7 @@ snapshots["sbb-time-input renders Shadow DOM"] = `; /* end snapshot sbb-time-input renders Shadow DOM */ -snapshots["sbb-time-input renders A11y tree Chrome"] = +snapshots["sbb-time-input renders A11y tree Chrome"] = `

{ "role": "WebArea", diff --git a/tools/manifest/custom-elements-manifest.config.js b/tools/manifest/custom-elements-manifest.config.js index baccff68fb..927f02bdf5 100644 --- a/tools/manifest/custom-elements-manifest.config.js +++ b/tools/manifest/custom-elements-manifest.config.js @@ -52,7 +52,8 @@ export function createManifestConfig(library = '') { for (const module of customElementsManifest.modules) { fixModulePaths(module, fixTsPaths); for (const declaration of module.declarations.filter((d) => d.kind === 'class')) { - if (declaration.name === 'SbbIconBase') { + // Abstract base classes are considered components even if they don't have the `customElement` annotation. + if (declaration.name.includes('Base')) { delete declaration.customElement; } for (const member of declaration.members) {