From bafaf5a224c48d09bd794a0e3003ced9337014ad Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Tue, 12 Mar 2024 10:12:46 +0100 Subject: [PATCH 01/67] feat: introduce base class for mini-button --- .../button/mini-button/mini-button.stories.ts | 2 +- .../button/mini-button/mini-button.ts | 21 ++++--------------- src/components/button/mini-button/readme.md | 2 +- .../core/base-elements/button-base-element.ts | 18 +++++++++++++++- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/components/button/mini-button/mini-button.stories.ts b/src/components/button/mini-button/mini-button.stories.ts index b57eff97bf..8f653832d1 100644 --- a/src/components/button/mini-button/mini-button.stories.ts +++ b/src/components/button/mini-button/mini-button.stories.ts @@ -58,7 +58,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/components/button/mini-button/mini-button.ts b/src/components/button/mini-button/mini-button.ts index 7896129f0d..ffceb92605 100644 --- a/src/components/button/mini-button/mini-button.ts +++ b/src/components/button/mini-button/mini-button.ts @@ -1,10 +1,8 @@ -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 { SbbSlotStateController } from '../../core/controllers.js'; -import { SbbDisabledTabIndexActionMixin, SbbNegativeMixin } from '../../core/mixins.js'; -import { SbbIconNameMixin } from '../../icon.js'; +import { SbbMiniButtonBaseElement } from '../../core/base-elements.js'; +import { SbbDisabledTabIndexActionMixin } from '../../core/mixins.js'; import style from './mini-button.scss?lit&inline'; @@ -15,19 +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') -export class SbbMiniButtonElement extends SbbNegativeMixin( - SbbIconNameMixin(SbbDisabledTabIndexActionMixin(SbbButtonBaseElement)), -) { +export class SbbMiniButtonElement extends SbbDisabledTabIndexActionMixin(SbbMiniButtonBaseElement) { public static override styles: CSSResultGroup = style; - - public constructor() { - super(); - new SbbSlotStateController(this); - } - - protected override renderTemplate(): TemplateResult { - return super.renderIconSlot(); - } } declare global { diff --git a/src/components/button/mini-button/readme.md b/src/components/button/mini-button/readme.md index 4d01e60bc5..021d86e036 100644 --- a/src/components/button/mini-button/readme.md +++ b/src/components/button/mini-button/readme.md @@ -76,9 +76,9 @@ Use the accessibility properties to describe the purpose of the `sbb-mini-button | Name | Attribute | Privacy | Type | Default | Description | | ---------- | ----------- | ------- | --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | | `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. | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | | `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. | | `name` | `name` | public | `string` | | The name of the button element. | | `value` | `value` | public | `string` | | The value of the button element. | diff --git a/src/components/core/base-elements/button-base-element.ts b/src/components/core/base-elements/button-base-element.ts index 87a30d2056..a024102daf 100644 --- a/src/components/core/base-elements/button-base-element.ts +++ b/src/components/core/base-elements/button-base-element.ts @@ -1,8 +1,11 @@ -import { isServer } from 'lit'; +import { isServer, type TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; +import { SbbIconNameMixin } from '../../icon.js'; +import { SbbSlotStateController } from '../controllers.js'; import { hostAttributes } from '../decorators.js'; import { isEventPrevented } from '../eventing.js'; +import { SbbNegativeMixin } from '../mixins.js'; import { SbbActionBaseElement } from './action-base-element.js'; @@ -103,3 +106,16 @@ export abstract class SbbButtonBaseElement extends SbbActionBaseElement { } } } + +export abstract class SbbMiniButtonBaseElement extends SbbNegativeMixin( + SbbIconNameMixin(SbbButtonBaseElement), +) { + public constructor() { + super(); + new SbbSlotStateController(this); + } + + protected override renderTemplate(): TemplateResult { + return super.renderIconSlot(); + } +} From 298706b8417f39aa379486049f29c701acacfb2a Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Wed, 13 Mar 2024 12:05:21 +0100 Subject: [PATCH 02/67] feat: autocomplete-grid 1st commit --- .../autocomplete-grid-actions.spec.snap.js | 78 ++ .../autocomplete-grid-actions.e2e.ts | 16 + .../autocomplete-grid-actions.scss | 10 + .../autocomplete-grid-actions.spec.ts | 36 + .../autocomplete-grid-actions.stories.ts | 94 +++ .../autocomplete-grid-actions.ts | 48 ++ .../autocomplete-grid-actions/index.ts | 1 + .../autocomplete-grid-actions/readme.md | 50 ++ .../autocomplete-grid-button.spec.snap.js | 80 +++ .../autocomplete-grid-button.e2e.ts | 16 + .../autocomplete-grid-button.scss | 13 + .../autocomplete-grid-button.spec.ts | 36 + .../autocomplete-grid-button.stories.ts | 187 +++++ .../autocomplete-grid-button.ts | 77 ++ .../autocomplete-grid-button/index.ts | 1 + .../autocomplete-grid-button/readme.md | 68 ++ .../autocomplete-grid-option.spec.snap.js | 82 +++ .../autocomplete-grid-option.e2e.ts | 16 + .../autocomplete-grid-option.scss | 133 ++++ .../autocomplete-grid-option.spec.ts | 33 + .../autocomplete-grid-option.stories.ts | 25 + .../autocomplete-grid-option.ts | 264 +++++++ .../autocomplete-grid-option/index.ts | 1 + .../autocomplete-grid-option/readme.md | 80 +++ .../autocomplete-grid-row.spec.snap.js | 105 +++ .../autocomplete-grid-row.e2e.ts | 16 + .../autocomplete-grid-row.scss | 13 + .../autocomplete-grid-row.spec.ts | 36 + .../autocomplete-grid-row.stories.ts | 49 ++ .../autocomplete-grid-row.ts | 60 ++ .../autocomplete-grid-row/index.ts | 1 + .../autocomplete-grid-row/readme.md | 44 ++ .../autocomplete-grid.spec.snap.js | 134 ++++ .../autocomplete-grid.e2e.ts | 16 + .../autocomplete-grid/autocomplete-grid.scss | 160 +++++ .../autocomplete-grid.spec.ts | 40 ++ .../autocomplete-grid.stories.ts | 286 ++++++++ .../autocomplete-grid/autocomplete-grid.ts | 674 ++++++++++++++++++ .../autocomplete-grid/index.ts | 1 + .../autocomplete-grid/readme.md | 84 +++ src/components/autocomplete-grid/index.ts | 5 + src/components/autocomplete/autocomplete.ts | 2 +- .../overlay/overlay-trigger-attributes.ts | 3 +- .../core/styles/mixins/buttons.scss | 7 +- src/components/core/styles/typography.scss | 1 + .../form-field/form-field/form-field.ts | 4 +- .../option/option/option.stories.ts | 3 +- src/components/option/option/option.ts | 4 +- 48 files changed, 3184 insertions(+), 9 deletions(-) create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-actions/readme.md create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-button/readme.md create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/readme.md create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-row/readme.md create mode 100644 src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid/readme.md create mode 100644 src/components/autocomplete-grid/index.ts diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js new file mode 100644 index 0000000000..90f1ad5e81 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js @@ -0,0 +1,78 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-actions Dom"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-actions Dom */ + +snapshots["sbb-autocomplete-grid-actions ShadowDom"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-actions ShadowDom */ + +snapshots["sbb-autocomplete-grid-actions A11y tree Chrome"] = +`

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

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

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

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

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

+`; +/* end snapshot sbb-autocomplete-grid-actions A11y tree Safari */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.e2e.ts new file mode 100644 index 0000000000..a6d82d6a68 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.e2e.ts @@ -0,0 +1,16 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridActionsElement } from './autocomplete-grid-actions'; + +describe('sbb-autocomplete-grid-actions', () => { + let element: SbbAutocompleteGridActionsElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridActionsElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss new file mode 100644 index 0000000000..da287ce25d --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss @@ -0,0 +1,10 @@ +@use '../../core/styles/index' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +.sbb-autocomplete-grid-action { + display: flex; + column-gap: var(--sbb-spacing-responsive-xxxs); +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.spec.ts new file mode 100644 index 0000000000..23880f73fe --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.spec.ts @@ -0,0 +1,36 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteGridActionsElement } from './autocomplete-grid-actions'; +import '../autocomplete-grid'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import './autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +describe('sbb-autocomplete-grid-actions', () => { + let root: SbbAutocompleteGridActionsElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + Option 1 + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-actions')!; + }); + + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts new file mode 100644 index 0000000000..22a04f891d --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts @@ -0,0 +1,94 @@ +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 { styleMap } from 'lit/directives/style-map.js'; + +import { sbbSpread } from '../../core/dom'; + +import readme from './readme.md?raw'; +import './autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', +}); + +const numberOfButtons: InputType = { + control: { + type: 'number', + }, +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + numberOfButtons, + negative, +}; + +const defaultArgs: Args = { + numberOfButtons: 1, + negative: 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 Multiple: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, numberOfButtons: 3 }, +}; + +const meta: Meta = { + decorators: [ + (story, context) => html` +
${story()}
+ `, + withActions as Decorator, + ], + parameters: { + actions: { + handles: ['click'], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid-actions', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts new file mode 100644 index 0000000000..33f34f0dd0 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts @@ -0,0 +1,48 @@ +import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { hostAttributes } from '../../core/common-behaviors'; +import type { SbbAutocompleteGridButtonElement } from '../index'; + +import style from './autocomplete-grid-actions.scss?lit&inline'; + +/** + * A wrapper component for autocomplete-grid action buttons. + * + * @slot - Use the unnamed slot to add `sbb-mini-button` elements. + */ +@hostAttributes({ + role: 'gridcell', +}) +@customElement('sbb-autocomplete-grid-actions') +export class SbbAutocompleteGridActionsElement extends LitElement { + public static override styles: CSSResultGroup = style; + + private _setChildrenParameters(event: Event): void { + const elements = (event.target as HTMLSlotElement).assignedElements(); + if (!elements.length) { + return; + } + + elements + .filter( + (e): e is SbbAutocompleteGridButtonElement => e.tagName === 'SBB-AUTOCOMPLETE-GRID-BUTTON', + ) + .forEach((element, index) => element.setAttribute('id', `${this.id}x${index}`)); + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-actions': SbbAutocompleteGridActionsElement; + } +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/index.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/index.ts new file mode 100644 index 0000000000..cf7f89d97e --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-actions'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md new file mode 100644 index 0000000000..f2236dfad6 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md @@ -0,0 +1,50 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid-actions` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-mini-button` elements. | diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js new file mode 100644 index 0000000000..47aa54461b --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js @@ -0,0 +1,80 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-button Dom"] = +` + +`; +/* end snapshot sbb-autocomplete-grid-button Dom */ + +snapshots["sbb-autocomplete-grid-button ShadowDom"] = +` + + + + +`; +/* end snapshot sbb-autocomplete-grid-button ShadowDom */ + +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 */ + +snapshots["sbb-autocomplete-grid-button A11y tree Safari"] = +`

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

+`; +/* end snapshot sbb-autocomplete-grid-button A11y tree Safari */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.e2e.ts new file mode 100644 index 0000000000..20f317dfa1 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.e2e.ts @@ -0,0 +1,16 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridButtonElement } from './autocomplete-grid-button'; + +describe('sbb-autocomplete-grid-button', () => { + let element: SbbAutocompleteGridButtonElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridButtonElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss new file mode 100644 index 0000000000..dc6cbcb350 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.scss @@ -0,0 +1,13 @@ +@use '../../core/styles/index' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + // Use !important here to not interfere with Firefox focus ring definition + // which appears in normalize css of several frameworks. + outline: none !important; +} + +@include sbb.icon-button('.sbb-autocomplete-grid-button', '::slotted(sbb-icon), sbb-icon'); diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts new file mode 100644 index 0000000000..476ceef94a --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.spec.ts @@ -0,0 +1,36 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteGridButtonElement } from './autocomplete-grid-button'; +import '../autocomplete-grid'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import './autocomplete-grid-button'; + +describe('sbb-autocomplete-grid-button', () => { + let root: SbbAutocompleteGridButtonElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + Option 1 + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-button')!; + }); + + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts new file mode 100644 index 0000000000..6b5f0f8d2b --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts @@ -0,0 +1,187 @@ +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 { styleMap } from 'lit/directives/style-map.js'; + +import { sbbSpread } from '../../core/dom'; + +import readme from './readme.md?raw'; +import './autocomplete-grid-button'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', +}); + +const type: InputType = { + control: { + type: 'select', + }, + options: ['button', 'reset', 'submit'], + table: { + category: 'Button', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, + table: { + category: 'Button', + }, +}; + +const name: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const value: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const form: InputType = { + control: { + type: 'text', + }, + 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: 'text', + }, +}; + +const focusVisible: InputType = { + control: { + type: 'text', + }, +}; + +const defaultArgTypes: ArgTypes = { + type, + disabled, + name, + value, + form, + negative, + 'icon-name': iconName, + 'aria-label': ariaLabel, + active, + focusVisible, +}; + +const defaultArgs: Args = { + type: type.options[0], + disabled: false, + name: 'Button Name', + value: undefined, + form: undefined, + 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 }, +}; + +const meta: Meta = { + decorators: [ + (story, context) => html` +
${story()}
+ `, + withActions as Decorator, + ], + parameters: { + actions: { + handles: ['click'], + }, + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid-button', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts new file mode 100644 index 0000000000..02d1b2afa8 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts @@ -0,0 +1,77 @@ +import { type CSSResultGroup, type TemplateResult } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { + hostAttributes, + SbbDisabledMixin, + SbbMiniButtonBaseElement, +} from '../../core/common-behaviors'; +import { isValidAttribute, setAttribute } from '../../core/dom'; +import { AgnosticMutationObserver } from '../../core/observers'; + +import style from './autocomplete-grid-button.scss?lit&inline'; + +/** Configuration for the attribute to look at if component is nested in a sbb-optgroup */ +const buttonObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-disabled', 'data-negative'], // fixme negative +}; + +/** + * 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 + */ +@hostAttributes({ + tabindex: null, +}) +@customElement('sbb-autocomplete-grid-button') +export class SbbAutocompleteGridButtonElement extends SbbDisabledMixin(SbbMiniButtonBaseElement) { + public static override styles: CSSResultGroup = style; + + /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ + @state() private _disabledFromGroup = false; + + /** MutationObserver on data attributes. */ + private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => + this._onOptionAttributesChange(mutationsList), + ); + + /** 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 = isValidAttribute(this, 'data-group-disabled'); + } + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + const parentGroup = this.closest?.('sbb-optgroup'); // fixme + if (parentGroup) { + this._disabledFromGroup = parentGroup.disabled; + } + this._optionAttributeObserver.observe(this, buttonObserverConfig); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._optionAttributeObserver.disconnect(); + } + + public dispatchClick(event: KeyboardEvent): void { + return super.dispatchClickEvent(event); + } + + protected override renderTemplate(): TemplateResult { + setAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + return super.renderTemplate(); + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-button': SbbAutocompleteGridButtonElement; + } +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/index.ts b/src/components/autocomplete-grid/autocomplete-grid-button/index.ts new file mode 100644 index 0000000000..5d475645c1 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-button'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/readme.md b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md new file mode 100644 index 0000000000..28b083ca7c --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md @@ -0,0 +1,68 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid-button` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `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. | +| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. | +| `name` | `name` | public | `string` | | The name of the button element. | +| `value` | `value` | public | `string` | | The value of the button element. | +| `form` | `form` | public | `string \| undefined` | | The
element to associate the button with. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| --------------- | ------- | ----------- | ---------------------- | ------ | -------------- | +| `dispatchClick` | public | | `event: KeyboardEvent` | `void` | | + +## Slots + +| Name | Description | +| ------ | -------------------------------------------- | +| `icon` | Slot used to display the icon, if one is set | diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js new file mode 100644 index 0000000000..f38e6a3005 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js @@ -0,0 +1,82 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-option Dom"] = +` + Option 1 + +`; +/* end snapshot sbb-autocomplete-grid-option Dom */ + +snapshots["sbb-autocomplete-grid-option ShadowDom"] = +`
+
+ + + + + + + + Option 1 + +
+
+`; +/* end snapshot sbb-autocomplete-grid-option ShadowDom */ + +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 */ + +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 Safari"] = +`

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

+`; +/* end snapshot sbb-autocomplete-grid-option A11y tree Safari */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.e2e.ts new file mode 100644 index 0000000000..8c5dea8ab4 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.e2e.ts @@ -0,0 +1,16 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option'; + +describe('sbb-autocomplete-grid-option', () => { + let element: SbbAutocompleteGridOptionElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridOptionElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss new file mode 100644 index 0000000000..b96fc128e8 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss @@ -0,0 +1,133 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-option-color: var(--sbb-color-charcoal); + --sbb-option-background-color: inherit; + --sbb-option-background-color-hover: var(--sbb-color-milk); + --sbb-option-background-color-active: var(--sbb-color-cloud); + --sbb-option-disabled-border-color: var(--sbb-color-graphite); + --sbb-option-disabled-background-color: var(--sbb-color-milk); + --sbb-option-column-gap: var(--sbb-spacing-responsive-xxxs); + --sbb-option-justify-content: start; + --sbb-option-min-height: var(--sbb-size-button-m-min-height); + --sbb-option-cursor: pointer; + --sbb-option-border-radius: var(--sbb-border-radius-4x); + --sbb-option-icon-color: var(--sbb-color-metal); +} + +:host([data-negative]) { + --sbb-option-color: var(--sbb-color-milk); + --sbb-option-icon-color: var(--sbb-color-smoke); + --sbb-option-background-color-hover: var(--sbb-color-charcoal); + --sbb-option-background-color-active: var(--sbb-color-iron); + --sbb-option-disabled-border-color: var(--sbb-color-smoke); + --sbb-option-disabled-background-color: var(--sbb-color-charcoal); + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); +} + +:host([active]) { + --sbb-focus-outline-offset: calc(-1 * var(--sbb-spacing-fixed-1x)); +} + +:host(:hover:not([disabled], [data-group-disabled])) { + @include sbb.hover-mq($hover: true) { + --sbb-option-background-color: var(--sbb-option-background-color-hover); + } +} + +:host(:active:not([disabled], [data-group-disabled])) { + --sbb-option-background-color: var(--sbb-option-background-color-active); +} + +// if the highlight is enabled, hide the slot content +:host(:not([data-disable-highlight])) { + .sbb-option__label slot { + display: none; + } +} + +:host(:is([data-group-disabled], [disabled])) { + --sbb-option-cursor: default; + + @include sbb.if-forced-colors { + --sbb-option-color: GrayText; + } +} + +.sbb-option__label--highlight { + :host(:not(:is([disabled], [data-group-disabled]))) & { + @include sbb.text--bold; + @include sbb.if-forced-colors { + color: Highlight; + } + } +} + +.sbb-option__container { + background-color: var(--sbb-option-background-color); +} + +.sbb-option { + @include sbb.text-s--regular; + + display: flex; + align-items: center; + column-gap: var(--sbb-option-column-gap); + justify-content: var(--sbb-option-justify-content); + color: var(--sbb-option-color); + background-color: var(--sbb-option-background-color); + cursor: var(--sbb-option-cursor); + -webkit-tap-highlight-color: transparent; + -webkit-text-fill-color: var(--sbb-option-color); + + :host([active]) & { + @include sbb.focus-outline; + + border-radius: var(--sbb-option-border-radius); + } + + // Add inner border and background for disabled option when it's not multiple + :host(:is([data-group-disabled], [disabled]):not([data-multiple])) & { + 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-option-disabled-border-color); + border-radius: var(--sbb-border-radius-2x); + background-color: var(--sbb-option-disabled-background-color); + z-index: -1; + + @include sbb.if-forced-colors { + border-color: GrayText; + } + } + } +} + +.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); + } +} + +.sbb-option__label { + white-space: initial; +} + +.sbb-option__group-label--visually-hidden { + @include sbb.screen-reader-only; +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts new file mode 100644 index 0000000000..ab25f70d44 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.spec.ts @@ -0,0 +1,33 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteGridOptionElement } from './autocomplete-grid-option'; +import '../autocomplete-grid'; +import '../autocomplete-grid-row'; +import './autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +describe('sbb-autocomplete-grid-option', () => { + 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('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts new file mode 100644 index 0000000000..2a0b110920 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import readme from './readme.md?raw'; + +const Template = (): TemplateResult => html`See 'sbb-autocomplete-grid' for demonstration.`; + +export const Default: StoryObj = { + render: Template, +}; + +const meta: Meta = { + decorators: [(story) => html`
${story()}
`], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid-option', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts new file mode 100644 index 0000000000..72f89aff8d --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -0,0 +1,264 @@ +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { assignId } from '../../core/a11y'; +import { + hostAttributes, + NamedSlotStateController, + SbbDisabledMixin, + SbbIconNameMixin, +} from '../../core/common-behaviors'; +import { + isSafari, + isValidAttribute, + isAndroid, + toggleDatasetEntry, + setAttribute, +} from '../../core/dom'; +import { EventEmitter, ConnectedAbortController } from '../../core/eventing'; +import { AgnosticMutationObserver } from '../../core/observers'; + +import style from './autocomplete-grid-option.scss?lit&inline'; +import '../../icon'; + +let nextId = 0; + +/** Configuration for the attribute to look at if component is nested in a sbb-optgroup */ +const optionObserverConfig: MutationObserverInit = { + attributeFilter: ['data-group-disabled', 'data-negative'], +}; + +/** + * It displays on 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. + */ +@hostAttributes({ + role: 'gridcell', +}) +@customElement('sbb-autocomplete-grid-option') +export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( + SbbIconNameMixin(LitElement), +) { + public static override styles: CSSResultGroup = style; + public static readonly events = { + selectionChange: 'autocompleteOptionSelectionChange', + optionSelected: 'autocompleteOptionSelected', + } as const; + + /** Value of the option. */ + @property() public value?: string; + + /** Whether the option is currently active. */ + @property({ reflect: true, type: Boolean }) public active?: boolean; + + /** Whether the option is selected. */ + @property({ reflect: true, type: Boolean }) public selected = false; + + /** Emits when the option selection status changes. */ + private _selectionChange: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridOptionElement.events.selectionChange, + ); + + /** Emits when an option was selected by user. */ + private _optionSelected: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridOptionElement.events.optionSelected, + ); + + /** Whether to apply the negative styling */ + @state() private _negative = false; + + /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ + @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 _groupLabel: string | null = null; + + private _optionId = `sbb-autocomplete-grid-option-${++nextId}`; + private _abort = new ConnectedAbortController(this); + + /** + * 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. + */ + private _inertAriaGroups = isSafari(); + + /** MutationObserver on data attributes. */ + private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => + this._onOptionAttributesChange(mutationsList), + ); + + public constructor() { + super(); + new NamedSlotStateController(this); + } + + /** + * Highlight the label of the option + * @param value the highlighted portion of the label + * @internal + */ + public highlight(value: string): void { + this._highlightString = value; + } + + /** + * Set the option group label (used for a11y) + * @param value the label of the option group + */ + public setGroupLabel(value: string): void { + this._groupLabel = value; + } + + /** + * @internal + */ + public setSelectedViaUserInteraction(selected: boolean): void { + this.selected = selected; + this._selectionChange.emit(); + if (this.selected) { + this._optionSelected.emit(); + } + } + + private _selectByClick(event: MouseEvent): void { + if (this.disabled || this._disabledFromGroup) { + event.stopPropagation(); + return; + } + + this.setSelectedViaUserInteraction(true); + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + const parentGroup = this.closest?.('sbb-optgroup'); // fixme + if (parentGroup) { + this._disabledFromGroup = parentGroup.disabled; + } + this._optionAttributeObserver.observe(this, optionObserverConfig); + + this._negative = !!this.closest?.( + // :is() selector not possible due to test environment + `sbb-autocomplete-grid[negative],sbb-form-field[negative]`, + ); + toggleDatasetEntry(this, 'negative', this._negative); + + this.addEventListener('click', (e: MouseEvent) => this._selectByClick(e), { + signal, + passive: true, + }); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._optionAttributeObserver.disconnect(); + } + + /** 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 = isValidAttribute(this, 'data-group-disabled'); + } else if (mutation.attributeName === 'data-negative') { + this._negative = isValidAttribute(this, 'data-negative'); + } + } + } + + private _setupHighlightHandler(event: Event): void { + const slotNodes = (event.target as HTMLSlotElement).assignedNodes(); + const labelNodes = slotNodes.filter((el) => el.nodeType === Node.TEXT_NODE) as Text[]; + + // Disable the highlight if the slot contain more than just text nodes + if (labelNodes.length === 0 || slotNodes.length !== labelNodes.length) { + this._disableLabelHighlight = true; + return; + } + this._label = labelNodes + .map((l) => l.wholeText) + .filter((l) => l.trim()) + .join(); + } + + 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); + + return html` + ${prefix}${highlighted}${postfix} + `; + } + + protected override render(): TemplateResult { + setAttribute(this, 'tabindex', isAndroid() && !this.disabled && 0); + setAttribute(this, 'data-disable-highlight', this._disableLabelHighlight); + setAttribute(this, 'aria-selected', `${this.selected}`); // fixme check this on keynav + setAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); + assignId(() => this._optionId)(this); + + return html` +
+
+ ${this.renderIconSlot()} + + + + + ${this._label && !this._disableLabelHighlight ? this._getHighlightedLabel() : nothing} + ${this._inertAriaGroups && this._groupLabel + ? html` + + (${this._groupLabel}) + + ` + : nothing} + +
+
+ `; + } +} + +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/components/autocomplete-grid/autocomplete-grid-option/index.ts b/src/components/autocomplete-grid/autocomplete-grid-option/index.ts new file mode 100644 index 0000000000..2cd3739210 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-option'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/readme.md b/src/components/autocomplete-grid/autocomplete-grid-option/readme.md new file mode 100644 index 0000000000..c8ade4c295 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-option/readme.md @@ -0,0 +1,80 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid-option` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `value` | public | `string \| undefined` | | Value of the option. | +| `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | +| `selected` | `selected` | public | `boolean` | `false` | Whether the option is selected. | +| `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. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| --------------- | ------- | ------------------------------------------ | --------------- | ------ | -------------- | +| `setGroupLabel` | public | Set the option group label (used for a11y) | `value: string` | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ----------------------------------- | ------------------- | ----------------------------------------------- | -------------- | +| `autocompleteOptionSelectionChange` | `CustomEvent` | Emits when the option selection status changes. | | +| `autocompleteOptionSelected` | `CustomEvent` | Emits when an option was selected by user. | | + +## 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/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js new file mode 100644 index 0000000000..d4d3bf18b9 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js @@ -0,0 +1,105 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-row Dom"] = +` + + Option 1 + + + + + + +`; +/* end snapshot sbb-autocomplete-grid-row Dom */ + +snapshots["sbb-autocomplete-grid-row ShadowDom"] = +` + + + +`; +/* end snapshot sbb-autocomplete-grid-row ShadowDom */ + +snapshots["sbb-autocomplete-grid-row A11y tree Chrome"] = +`

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

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

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

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

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

+`; +/* end snapshot sbb-autocomplete-grid-row A11y tree Safari */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.e2e.ts new file mode 100644 index 0000000000..4d394fba0f --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.e2e.ts @@ -0,0 +1,16 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridRowElement } from './autocomplete-grid-row'; + +describe('sbb-autocomplete-grid-row', () => { + let element: SbbAutocompleteGridRowElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridRowElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss new file mode 100644 index 0000000000..73a75f8540 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.scss @@ -0,0 +1,13 @@ +@use '../../core/styles/index' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +.sbb-autocomplete-grid-row { + display: flex; + justify-content: space-between; + align-items: center; + padding-inline: var(--sbb-spacing-responsive-xxxs); + padding-block: calc(var(--sbb-spacing-fixed-2x) + var(--sbb-border-width-2x)); +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts new file mode 100644 index 0000000000..95ad63caa6 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.spec.ts @@ -0,0 +1,36 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteGridRowElement } from './autocomplete-grid-row'; +import '../autocomplete-grid'; +import './autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +describe('sbb-autocomplete-grid-row', () => { + let root: SbbAutocompleteGridRowElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + Option 1 + + + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-row')!; + }); + + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts new file mode 100644 index 0000000000..2fe59e383a --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts @@ -0,0 +1,49 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import { sbbSpread } from '../../core/dom'; + +import readme from './readme.md?raw'; + +import './autocomplete-grid-row'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-option'; +import '../../button/mini-button'; + +const Template = (args: Args): TemplateResult => html` + + Opt 1 + + + + + + Opt 2 + + + + +`; + +export const Default: StoryObj = { + render: Template, +}; + +const meta: Meta = { + decorators: [ + (story) => html`
${story()}
`, + withActions as Decorator, + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid-row', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts new file mode 100644 index 0000000000..646e27aead --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts @@ -0,0 +1,60 @@ +import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { hostAttributes } from '../../core/common-behaviors'; +import type { SbbAutocompleteGridOptionElement, SbbAutocompleteGridActionsElement } from '../index'; + +import style from './autocomplete-grid-row.scss?lit&inline'; + +let autocompleteRowNextId = 0; + +/** + * Describe the purpose of the component with a single short sentence. + */ +@hostAttributes({ + role: 'row', +}) +@customElement('sbb-autocomplete-grid-row') +export class SbbAutocompleteGridRowElement extends LitElement { + public static override styles: CSSResultGroup = style; + private _rowId = ++autocompleteRowNextId; + + public constructor() { + super(); + this.setAttribute('id', `sbb-autocomplete-grid-row-${this._rowId}`); + } + + private _setChildrenParameters(event: Event): void { + const elements = (event.target as HTMLSlotElement).assignedElements(); + if (!elements.length) { + return; + } + + elements + .find( + (e): e is SbbAutocompleteGridOptionElement => e.tagName === 'SBB-AUTOCOMPLETE-GRID-OPTION', + ) + ?.setAttribute('id', `sbb-autocomplete-grid-item-${this._rowId}x0`); + elements + .find( + (e): e is SbbAutocompleteGridActionsElement => + e.tagName === 'SBB-AUTOCOMPLETE-GRID-ACTIONS', + ) + ?.setAttribute('id', `sbb-autocomplete-grid-item-${this._rowId}x1`); + } + + 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/components/autocomplete-grid/autocomplete-grid-row/index.ts b/src/components/autocomplete-grid/autocomplete-grid-row/index.ts new file mode 100644 index 0000000000..71d47cdf78 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-row'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/readme.md b/src/components/autocomplete-grid/autocomplete-grid-row/readme.md new file mode 100644 index 0000000000..897148097c --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-row/readme.md @@ -0,0 +1,44 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid-row` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + diff --git a/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js new file mode 100644 index 0000000000..ad038b8ebe --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js @@ -0,0 +1,134 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid Dom"] = +` + + + Option 1 + + + + + + + + + Option 2 + + + + + + + +`; +/* end snapshot sbb-autocomplete-grid Dom */ + +snapshots["sbb-autocomplete-grid ShadowDom"] = +`
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+`; +/* end snapshot sbb-autocomplete-grid ShadowDom */ + +snapshots["sbb-autocomplete-grid A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "" +} +

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

+ { + "role": "document", + "name": "" +} +

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

+ { + "role": "WebArea", + "name": "" +} +

+`; +/* end snapshot sbb-autocomplete-grid A11y tree Safari */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.e2e.ts new file mode 100644 index 0000000000..1247fd9ac9 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.e2e.ts @@ -0,0 +1,16 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridElement } from './autocomplete-grid'; + +describe('sbb-autocomplete-grid', () => { + let element: SbbAutocompleteGridElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss new file mode 100644 index 0000000000..f1c12c5a76 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss @@ -0,0 +1,160 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +// Fixes the gap between the origin and the overlay by creating conjunction +// corners based on the origin element border radius +@include sbb.overlay-gap-fix; + +:host { + @include sbb.options-panel-overlay-variables; + + --sbb-options-panel-internal-z-index: var(--sbb-autocomplete-z-index, var(--sbb-overlay-z-index)); +} + +:host([negative]) { + @include sbb.options-panel-overlay-negative-variables; +} + +:host(:not([data-state])), +:host([data-state='closed']) { + --sbb-options-panel-visibility: hidden; +} + +:host([data-state='opening']) { + --sbb-options-panel-animation-name: open; +} + +:host([data-state='closing']) { + --sbb-options-panel-animation-name: close; +} + +:host([data-state='opened']), +:host([data-state='opening']) { + --sbb-options-panel-gap-fix-opacity: 1; +} + +:host([data-options-panel-position='below']) { + --sbb-options-panel-animation-transform: translateY( + calc((var(--sbb-options-panel-origin-height) / 2) * -1) + ); +} + +:host([data-options-panel-position='above']) { + --sbb-options-panel-options-border-radius: var(--sbb-options-panel-border-radius) + var(--sbb-options-panel-border-radius) 0 0; + --sbb-options-panel-gap-fix-top: var(--sbb-options-panel-max-height); + --sbb-options-panel-gap-fix-transform: rotate(180deg); + --sbb-options-panel-animation-transform: translateY( + calc(var(--sbb-options-panel-origin-height) / 2) + ); +} + +:host([disable-animation]) { + --sbb-options-panel-animation-duration: 0.1ms; +} + +:host([preserve-icon-space]) { + --sbb-option-icon-container-display: block; +} + +::slotted(sbb-divider) { + margin-block: var(--sbb-spacing-fixed-3x); +} + +.sbb-autocomplete__container { + @include sbb.options-panel-overlay-container; +} + +.sbb-autocomplete__gap-fix { + @include sbb.options-panel-overlay-gap; +} + +.sbb-autocomplete__panel { + @include sbb.options-panel-overlay; + + :host([data-options-panel-position='below']) & { + inset-block-start: calc( + var(--sbb-options-panel-position-y) - var(--sbb-options-panel-origin-height) + ); + } + + :host(:is([data-state='opened'], [data-state='opening'])) & { + @include sbb.shadow-level-5-hard; + } + + :host(:is([data-state='opened'], [data-state='opening'])[negative]) & { + @include sbb.shadow-level-5-hard-negative; + } + + &::before { + :host([data-options-panel-position='below']) & { + display: block; + } + } + + &::after { + :host([data-options-panel-position='above']) & { + display: block; + } + } + + /* stylelint-disable-next-line no-descending-specificity */ + &::before, + &::after { + :host(:is([data-state='opened'], [data-state='opening'])[data-option-panel-origin-borderless]) + & { + @include sbb.shadow-level-5-hard; + } + + :host( + :is( + [data-state='opened'], + [data-state='opening'] + )[data-option-panel-origin-borderless][negative] + ) + & { + @include sbb.shadow-level-5-hard-negative; + } + } +} + +.sbb-autocomplete__wrapper { + overflow: hidden; +} + +.sbb-autocomplete__options { + @include sbb.scrollbar-rules; + @include sbb.optionsOverlay; + + @include sbb.if-forced-colors { + border: var(--sbb-border-width-1x) solid CanvasText; + border-top: none; + } +} + +@keyframes open { + from { + transform: var(--sbb-options-panel-animation-transform); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes close { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: var(--sbb-options-panel-animation-transform); + opacity: 0; + } +} diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts new file mode 100644 index 0000000000..1986758144 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts @@ -0,0 +1,40 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import type { SbbAutocompleteGridElement } from './autocomplete-grid'; +import './autocomplete-grid'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +describe('sbb-autocomplete-grid', () => { + let root: SbbAutocompleteGridElement; + + beforeEach(async () => { + root = await fixture(html` + + + Option 1 + + + + + + Option 2 + + + + + + `); + }); + + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts new file mode 100644 index 0000000000..18588f89cd --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts @@ -0,0 +1,286 @@ +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, nothing, type TemplateResult } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; + +import { SbbAutocompleteGridElement } from './autocomplete-grid'; +import readme from './readme.md?raw'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; +import '../../form-field'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', +}); + +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 disableAnimation: 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 buttonIconName: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Button', + }, +}; + +const defaultArgTypes: ArgTypes = { + // Form field args + negative, + borderless, + floatingLabel, + + // Input args + disabled, + readonly, + + // Autocomplete args + disableAnimation, + preserveIconSpace, + + // Option args + optionIconName, + + // Button args + buttonIconName, +}; + +const defaultArgs: Args = { + // Form field args + negative: false, + borderless: false, + floatingLabel: false, + + // Input args + disabled: false, + readonly: false, + + // Autocomplete args + disableAnimation: false, + preserveIconSpace: true, + + // Option args + optionIconName: 'clock-small', + + // Button args + buttonIconName: 'pen-small', +}; + +const createRows1 = (optionIconName: string, buttonIconName: string): TemplateResult => html` + ${repeat( + new Array(3), + (_, i: number) => html` + + ${`Option 1-${i + 1}`} + + + + + `, + )} +`; + +const createRows2 = (buttonIconName: string): TemplateResult => html` + ${repeat( + new Array(3), + (_, i: number) => html` + + ${`Option 2-${i + 1}`} + + + + + + `, + )} +`; + +const textBlockStyle: Args = { + 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: Args = { + 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 Template = (args: Args): TemplateResult => html` +
+ + + + ${createRows1(args.optionIconName, args.buttonIconName)} ${createRows2(args.buttonIconName)} + + + ${textBlock()} +
+`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +const meta: Meta = { + decorators: [ + (story, context) => html` +
+ ${story()} +
+ `, + 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, + ], + }, + backgrounds: { + disable: true, + }, + docs: { + story: { inline: false, iframeHeight: '500px' }, + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts new file mode 100644 index 0000000000..c2545ba1dc --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -0,0 +1,674 @@ +import type { CSSResultGroup, TemplateResult, PropertyValues } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { assignId, getNextElementIndex } from '../../core/a11y'; +import { hostAttributes, SbbNegativeMixin, SlotChildObserver } from '../../core/common-behaviors'; +import { + setAttribute, + getDocumentWritingMode, + findReferencedElement, + isSafari, + isValidAttribute, + toggleDatasetEntry, + isBrowser, +} from '../../core/dom'; +import { ConnectedAbortController, EventEmitter } from '../../core/eventing'; +import type { SbbOverlayState } from '../../core/overlay'; +import { + isEventOnElement, + overlayGapFixCorners, + removeAriaComboBoxAttributes, + setAriaComboBoxAttributes, + setOverlayPosition, +} from '../../core/overlay'; +import type { SbbOptionElement, SbbOptGroupElement } from '../../option'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button'; +import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; +import type { SbbAutocompleteGridRowElement } from '../autocomplete-grid-row'; + +import style from './autocomplete-grid.scss?lit&inline'; + +let nextId = 0; + +/** + * Combined with a native input, it displays a panel with a list of available options. + * + * @slot - Use the unnamed slot to add `sbb-option` or `sbb-optgroup` elements to the `sbb-autocomplete`. + * @event {CustomEvent} willOpen - Emits whenever the `sbb-autocomplete` starts the opening transition. Can be canceled. + * @event {CustomEvent} didOpen - Emits whenever the `sbb-autocomplete` is opened. + * @event {CustomEvent} willClose - Emits whenever the `sbb-autocomplete` begins the closing transition. Can be canceled. + * @event {CustomEvent} didClose - Emits whenever the `sbb-autocomplete` is closed. + * @cssprop [--sbb-autocomplete-z-index=var(--sbb-overlay-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-z-index)` with a value of `1000`. + */ +@hostAttributes({ + dir: getDocumentWritingMode(), +}) +@customElement('sbb-autocomplete-grid') +export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMixin(LitElement)) { + public static override styles: CSSResultGroup = style; + public static readonly events = { + willOpen: 'willOpen', + didOpen: 'didOpen', + willClose: 'willClose', + didClose: 'didClose', + } as const; + + /** + * 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 animation is disabled. */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** Whether the icon space is preserved when no icon is set. */ + @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) + public preserveIconSpace?: boolean; + + /** The state of the autocomplete. */ + @state() private _state: SbbOverlayState = 'closed'; + + /** Emits whenever the `sbb-autocomplete` starts the opening transition. */ + private _willOpen: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridElement.events.willOpen, + ); + + /** Emits whenever the `sbb-autocomplete` is opened. */ + private _didOpen: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridElement.events.didOpen, + ); + + /** Emits whenever the `sbb-autocomplete` begins the closing transition. */ + private _willClose: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridElement.events.willClose, + ); + + /** Emits whenever the `sbb-autocomplete` is closed. */ + private _didClose: EventEmitter = new EventEmitter( + this, + SbbAutocompleteGridElement.events.didClose, + ); + + 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-grid-${++nextId}`; + private _activeItemIndex = -1; + private _activeColumnIndex = 0; + private _didLoad = false; + private _isPointerDownEventOnMenu: boolean = false; + private _abort = new ConnectedAbortController(this); + + /** + * 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. + */ + private _ariaRoleOnHost = isSafari(); + + /** The autocomplete should inherit 'readonly' state from the trigger. */ + private get _readonly(): boolean { + return !!this.triggerElement && isValidAttribute(this.triggerElement, 'readonly'); + } + + private get _options(): SbbAutocompleteGridOptionElement[] { + return Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? []); + } + + // fixme + private get _row(): SbbAutocompleteGridRowElement[] { + return Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-row') ?? []); + } + + /** 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 SbbAutocompleteGridOptionElement; + 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 { + if ( + (event.target as Element).tagName !== 'SBB-AUTOCOMPLETE-GRID-OPTION' || + (event.target as SbbOptionElement).disabled + ) { + return; + } + this.close(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); + + if (formField) { + this.negative = isValidAttribute(formField, 'negative'); + } + + if (this._didLoad) { + this._componentSetup(); + } + this._syncNegative(); + + this.addEventListener( + 'autocompleteOptionSelectionChange', + (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; + } + + public override checkChildren(): void { + this._highlightOptions(this.triggerElement?.value); + } + + private _syncNegative(): void { + this.querySelectorAll?.('sbb-divider').forEach((divider) => (divider.negative = this.negative)); + + this.querySelectorAll?.( + 'sbb-autocomplete-grid-option, sbb-optgroup', // FIXME + ).forEach((element) => toggleDatasetEntry(element, 'negative', this.negative)); + + this.querySelectorAll?.( + 'sbb-autocomplete-grid-button', + ).forEach((element) => (element.negative = this.negative)); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._triggerEventsController?.abort(); + this._openPanelEventsController?.abort(); + } + + private _componentSetup(): void { + if (!isBrowser()) { + return; + } + this._triggerEventsController?.abort(); + this._openPanelEventsController?.abort(); + + this._originElement = undefined; + toggleDatasetEntry( + this, + 'optionPanelOriginBorderless', + !!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 { + if (this._state !== 'opened') { + return; + } + + switch (event.key) { + case 'Escape': + case 'Tab': + this.close(); + break; + + case 'Enter': + this._selectByKeyboard(event); + break; + + // FIXME + case 'ArrowDown': + case 'ArrowUp': + this._setNextActiveOption(event); + break; + + // FIXME + case 'ArrowRight': + case 'ArrowLeft': + this._setNextHorizontalActiveElement(event); + break; + } + } + + // TODO + private _selectByKeyboard(event: KeyboardEvent): void { + if (this._activeColumnIndex !== 0) { + ( + this._row[this._activeItemIndex].querySelectorAll( + 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-button', + )[this._activeColumnIndex] as SbbAutocompleteGridButtonElement + ).dispatchClick(event); + } else { + const activeOption = this._options[this._activeItemIndex]; + if (activeOption) { + activeOption.setSelectedViaUserInteraction(true); + } + } + } + + private _setNextActiveOption(event: KeyboardEvent): void { + const filteredOptions = this._options.filter( + (opt) => !opt.disabled && !isValidAttribute(opt, 'data-group-disabled'), + ); + + // Get and activate the next active option + const next = getNextElementIndex(event, this._activeItemIndex, filteredOptions.length); + const nextActiveOption = filteredOptions[next]; + nextActiveOption.active = true; + this.triggerElement?.setAttribute('aria-activedescendant', nextActiveOption.id); + nextActiveOption.scrollIntoView({ block: 'nearest' }); + + // Reset the previous active option + const lastActiveOption = filteredOptions[this._activeItemIndex]; + if (lastActiveOption) { + lastActiveOption.active = false; + } + if (this._activeColumnIndex !== 0) { + this._row[this._activeItemIndex] + .querySelectorAll('sbb-autocomplete-grid-button') + .forEach((e) => toggleDatasetEntry(e, 'focusVisible', false)); + } + + this._activeItemIndex = next; + this._activeColumnIndex = 0; + } + + // FIXME + private _setNextHorizontalActiveElement(event: KeyboardEvent): void { + if (this._activeItemIndex < 0) { + return; + } + + const elementsInRow: NodeListOf< + SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement + > = this._row[this._activeItemIndex].querySelectorAll( + 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-button', + ); + const next: number = getNextElementIndex(event, this._activeColumnIndex, elementsInRow.length); + const nextElement: SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement = + elementsInRow[next]; + if (nextElement instanceof SbbAutocompleteGridOptionElement) { + nextElement.active = true; + } else { + toggleDatasetEntry(nextElement, 'focusVisible', true); + } + + const lastActiveElement: SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement = + elementsInRow[this._activeColumnIndex]; + if (lastActiveElement instanceof SbbAutocompleteGridOptionElement) { + lastActiveElement.active = false; + } else { + toggleDatasetEntry(lastActiveElement, 'focusVisible', false); + } + this.triggerElement?.setAttribute('aria-activedescendant', nextElement.id); + nextElement.scrollIntoView({ block: 'nearest' }); + this._activeColumnIndex = next; + } + + // FIXME + private _resetActiveElement(): void { + const activeElement = this._options[this._activeItemIndex]; + + if (activeElement) { + activeElement.active = false; + } + if (this._activeColumnIndex !== 0) { + this._row[this._activeItemIndex] + .querySelectorAll('sbb-autocomplete-grid-button') + .forEach((e) => toggleDatasetEntry(e, 'focusVisible', false)); + } + this._activeItemIndex = -1; + this._activeColumnIndex = 0; + this.triggerElement?.removeAttribute('aria-activedescendant'); + } + + /** 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 _setTriggerAttributes(element: HTMLInputElement): void { + setAriaComboBoxAttributes(element, this.id || this._overlayId, false, 'grid'); + } + + private _removeTriggerAttributes(element?: HTMLInputElement): void { + removeAriaComboBoxAttributes(element); + } + + // FIXME + protected override render(): TemplateResult { + setAttribute(this, 'data-state', this._state); + setAttribute(this, 'role', this._ariaRoleOnHost ? 'grid' : null); + this._ariaRoleOnHost && assignId(() => this._overlayId)(this); + + return html` +
+
+
${overlayGapFixCorners()}
+
(this._overlay = overlayRef as HTMLElement))} + > +
+
(this._optionContainer = containerRef as HTMLElement))} + > + +
+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid': SbbAutocompleteGridElement; + } +} diff --git a/src/components/autocomplete-grid/autocomplete-grid/index.ts b/src/components/autocomplete-grid/autocomplete-grid/index.ts new file mode 100644 index 0000000000..866566084f --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid'; diff --git a/src/components/autocomplete-grid/autocomplete-grid/readme.md b/src/components/autocomplete-grid/autocomplete-grid/readme.md new file mode 100644 index 0000000000..38c7e41af9 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid/readme.md @@ -0,0 +1,84 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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. | +| `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is disabled. | +| `preserveIconSpace` | `preserve-icon-space` | public | `boolean \| undefined` | | Whether the icon space is preserved when no icon is set. | +| `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | +| `triggerElement` | - | public | `HTMLInputElement \| undefined` | | Returns the trigger element. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | + +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------ | ---------- | ------ | -------------- | +| `open` | public | Opens the autocomplete. | | `void` | | +| `close` | public | Closes the autocomplete. | | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ------------------------------------------------------------------------------------- | -------------- | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` starts the opening transition. Can be canceled. | | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is opened. | | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` begins the closing transition. Can be canceled. | | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is closed. | | + +## CSS Properties + +| Name | Default | Description | +| ---------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--sbb-autocomplete-z-index` | `var(--sbb-overlay-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-z-index)` with a value of `1000`. | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-option` or `sbb-optgroup` elements to the `sbb-autocomplete`. | diff --git a/src/components/autocomplete-grid/index.ts b/src/components/autocomplete-grid/index.ts new file mode 100644 index 0000000000..d4a232760a --- /dev/null +++ b/src/components/autocomplete-grid/index.ts @@ -0,0 +1,5 @@ +export * from './autocomplete-grid'; +export * from './autocomplete-grid-row'; +export * from './autocomplete-grid-option'; +export * from './autocomplete-grid-actions'; +export * from './autocomplete-grid-button'; diff --git a/src/components/autocomplete/autocomplete.ts b/src/components/autocomplete/autocomplete.ts index ea0a29f913..e84836e9b8 100644 --- a/src/components/autocomplete/autocomplete.ts +++ b/src/components/autocomplete/autocomplete.ts @@ -538,7 +538,7 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L /** Highlight the searched text on the options. */ private _highlightOptions(searchTerm?: string): void { - if (!searchTerm) { + if (searchTerm === null || searchTerm === undefined) { return; } this._options.forEach((option) => option.highlight(searchTerm)); diff --git a/src/components/core/overlay/overlay-trigger-attributes.ts b/src/components/core/overlay/overlay-trigger-attributes.ts index 3f2340cf0b..6e682a363f 100644 --- a/src/components/core/overlay/overlay-trigger-attributes.ts +++ b/src/components/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,7 +46,7 @@ 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-expanded', `${expanded}`); diff --git a/src/components/core/styles/mixins/buttons.scss b/src/components/core/styles/mixins/buttons.scss index 734b5e9c12..d3a2692812 100644 --- a/src/components/core/styles/mixins/buttons.scss +++ b/src/components/core/styles/mixins/buttons.scss @@ -33,7 +33,12 @@ @include icon-button-disabled(#{$button-selector}); } - :host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) { + :host( + :is(:focus-visible, [data-focus-visible]):not( + [data-focus-origin='mouse'], + [data-focus-origin='touch'] + ) + ) { @include icon-button-focus-visible(#{$button-selector}); } diff --git a/src/components/core/styles/typography.scss b/src/components/core/styles/typography.scss index 31cce5eb78..4d46b55a0c 100644 --- a/src/components/core/styles/typography.scss +++ b/src/components/core/styles/typography.scss @@ -55,6 +55,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/components/form-field/form-field/form-field.ts b/src/components/form-field/form-field/form-field.ts index 521b1576c1..9cacbc7c1d 100644 --- a/src/components/form-field/form-field/form-field.ts +++ b/src/components/form-field/form-field/form-field.ts @@ -21,7 +21,7 @@ import style from './form-field.scss?lit&inline'; 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. @@ -434,7 +434,7 @@ export class SbbFormFieldElement extends SbbNegativeMixin(LitElement) { 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/components/option/option/option.stories.ts b/src/components/option/option/option.stories.ts index 5b35b2fdd2..3585e430f9 100644 --- a/src/components/option/option/option.stories.ts +++ b/src/components/option/option/option.stories.ts @@ -107,9 +107,8 @@ const createOptions = ({ ?disabled=${disabled && i === 0} value=${`${value} ${i + 1}`} ${sbbSpread(args)} + >${`${value} ${i + 1}`} - ${`${value} ${i + 1}`} - `; }), html` diff --git a/src/components/option/option/option.ts b/src/components/option/option/option.ts index 5575e401b2..e5f640d7a2 100644 --- a/src/components/option/option/option.ts +++ b/src/components/option/option/option.ts @@ -92,10 +92,10 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme SbbOptionElement.events.optionSelected, ); - /** Wheter to apply the negative styling */ + /** Whether to apply the negative styling */ @state() private _negative = false; - /** Whether the component must be set disabled due disabled attribute on sbb-checkbox-group. */ + /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ @state() private _disabledFromGroup = false; @state() private _label?: string; From 9d3f025bf154ac75d39f6204b40ebeba78e31061 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Wed, 13 Mar 2024 17:20:27 +0100 Subject: [PATCH 03/67] feat: add autocomplete-grid-optgroup --- .../autocomplete-grid-button.ts | 2 +- .../autocomplete-grid-optgroup.spec.snap.js | 62 +++++++ .../autocomplete-grid-optgroup.e2e.ts | 18 ++ .../autocomplete-grid-optgroup.scss | 51 ++++++ .../autocomplete-grid-optgroup.spec.ts | 39 +++++ .../autocomplete-grid-optgroup.stories.ts | 165 ++++++++++++++++++ .../autocomplete-grid-optgroup.ts | 132 ++++++++++++++ .../autocomplete-grid-optgroup/index.ts | 1 + .../autocomplete-grid-optgroup/readme.md | 57 ++++++ .../autocomplete-grid-option.ts | 2 +- .../autocomplete-grid/autocomplete-grid.ts | 2 +- src/components/autocomplete-grid/index.ts | 1 + .../option/optgroup/optgroup.stories.ts | 3 +- 13 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.e2e.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/index.ts create mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts index 02d1b2afa8..e167380a9a 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts @@ -47,7 +47,7 @@ export class SbbAutocompleteGridButtonElement extends SbbDisabledMixin(SbbMiniBu public override connectedCallback(): void { super.connectedCallback(); - const parentGroup = this.closest?.('sbb-optgroup'); // fixme + const parentGroup = this.closest?.('sbb-autocomplete-grid-optgroup'); if (parentGroup) { this._disabledFromGroup = parentGroup.disabled; } diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js new file mode 100644 index 0000000000..33dca108d0 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js @@ -0,0 +1,62 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-autocomplete-grid-optgroup Dom"] = +` + + + Option 1 + + + + + Option 2 + + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup Dom */ + +snapshots["sbb-autocomplete-grid-optgroup ShadowDom"] = +`
+ + +
+ + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup ShadowDom */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.e2e.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.e2e.ts new file mode 100644 index 0000000000..f2ee454e00 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.e2e.ts @@ -0,0 +1,18 @@ +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { SbbAutocompleteGridOptgroupElement } from './autocomplete-grid-optgroup'; + +describe('sbb-autocomplete-grid-optgroup', () => { + let element: SbbAutocompleteGridOptgroupElement; + + beforeEach(async () => { + element = await fixture( + html``, + ); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbAutocompleteGridOptgroupElement); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss new file mode 100644 index 0000000000..db9c36e504 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss @@ -0,0 +1,51 @@ +@use '../../core/styles' as sbb; + +// Default component properties, defined for :host. Properties which can not +// travel the shadow boundary are defined through this mixin +@include sbb.host-component-properties; + +:host { + --sbb-optgroup-divider-display: block; + --sbb-optgroup-divider-spacing: 0; + --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-4x); + --sbb-optgroup-label-padding-inline: var(--sbb-spacing-fixed-4x); + --sbb-optgroup-label-font-size: var(--sbb-typo-scale-0-75x); + --sbb-optgroup-label-color: var(--sbb-color-metal); +} + +:host(:first-child) { + --sbb-optgroup-divider-display: none; + --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-2x); +} + +:host([data-negative]) { + --sbb-optgroup-label-color: var(--sbb-color-smoke); +} + +.sbb-optgroup { + margin-block: var(--sbb-spacing-fixed-4x); + margin-inline: var(--sbb-spacing-fixed-4x); +} + +.sbb-optgroup__label { + @include sbb.text-xxs--regular; + + display: flex; + column-gap: var(--sbb-spacing-responsive-xxxs); + color: var(--sbb-optgroup-label-color); + -webkit-text-fill-color: var(--sbb-optgroup-label-color); + padding-inline: var(--sbb-optgroup-label-padding-inline); + padding-block: var(--sbb-optgroup-label-padding-start) var(--sbb-spacing-fixed-2x); +} + +.sbb-optgroup__divider { + display: var(--sbb-optgroup-divider-display); + padding-block: var(--sbb-optgroup-divider-spacing); +} + +// Align the group label to the option label +.sbb-optgroup__icon-space { + // Can be overridden by the 'preserve-icon-space' on the autocomplete + display: var(--sbb-option-icon-container-display, none); + min-width: var(--sbb-size-icon-ui-small); +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts new file mode 100644 index 0000000000..eddac15fc0 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts @@ -0,0 +1,39 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import './autocomplete-grid-optgroup'; +import '../autocomplete-grid'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; +import type { SbbAutocompleteGridOptgroupElement } from './autocomplete-grid-optgroup'; + +describe('sbb-autocomplete-grid-optgroup', () => { + let root: SbbAutocompleteGridOptgroupElement; + beforeEach(async () => { + root = ( + await fixture(html` + + + + Option 1 + + + Option 2 + + + +
+ `) + ).querySelector('sbb-autocomplete-grid-optgroup')!; + }); + + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); +}); diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts new file mode 100644 index 0000000000..eddf5d714b --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts @@ -0,0 +1,165 @@ +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, nothing, type TemplateResult } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import readme from './readme.md?raw'; +import '../../form-field'; +import './autocomplete-grid-optgroup'; +import '../autocomplete-grid'; +import '../autocomplete-grid-row'; +import '../autocomplete-grid-option'; +import '../autocomplete-grid-actions'; +import '../autocomplete-grid-button'; + +const wrapperStyle = (context: StoryContext): Record => ({ + 'background-color': context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', +}); + +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 = { + decorators: [ + (story, context) => html` +
${story()}
+ `, + withActions as Decorator, + ], + parameters: { + backgrounds: { + disable: true, + }, + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'components/sbb-autocomplete-grid/sbb-autocomplete-grid-optgroup', +}; + +export default meta; diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts new file mode 100644 index 0000000000..6bf5bb8895 --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts @@ -0,0 +1,132 @@ +import type { CSSResultGroup, TemplateResult, PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { SbbDisabledMixin, SlotChildObserver } from '../../core/common-behaviors'; +import { isSafari, isValidAttribute, toggleDatasetEntry, setAttribute } from '../../core/dom'; +import { AgnosticMutationObserver } from '../../core/observers'; +import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; + +import style from './autocomplete-grid-optgroup.scss?lit&inline'; +import '../../divider'; + +/** + * 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 SlotChildObserver( + SbbDisabledMixin(LitElement), +) { + public static override styles: CSSResultGroup = style; + + /** Option group label. */ + @property() public label!: string; + + @state() private _negative = false; + + private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); + + /** + * 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. + */ + private _inertAriaGroups = isSafari(); + + private get _options(): SbbAutocompleteGridOptionElement[] { + return Array.from( + this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? [], + ) as SbbAutocompleteGridOptionElement[]; + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._negativeObserver?.disconnect(); + this._negative = !!this.closest?.(`:is(sbb-autocomplete-grid, sbb-form-field)[negative]`); + toggleDatasetEntry(this, 'negative', this._negative); + + this._negativeObserver.observe(this, { + attributes: true, + attributeFilter: ['data-negative'], + }); + + this._proxyGroupLabelToOptions(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (changedProperties.has('disabled')) { + this._proxyDisabledToOptions(); + } + if (changedProperties.has('label')) { + this._proxyGroupLabelToOptions(); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._negativeObserver?.disconnect(); + } + + protected override checkChildren(): void { + this._proxyDisabledToOptions(); + this._proxyGroupLabelToOptions(); + this._highlightOptions(); + } + + private _proxyGroupLabelToOptions(): void { + if (!this._inertAriaGroups) { + return; + } + + this._options.forEach((opt) => opt.setGroupLabel(this.label)); + } + + private _proxyDisabledToOptions(): void { + for (const option of this._options) { + toggleDatasetEntry(option, 'groupDisabled', this.disabled); + } + } + + private _highlightOptions(): void { + const autocomplete = this.closest('sbb-autocomplete-grid'); + if (!autocomplete) { + return; + } + const value = autocomplete.triggerElement?.value; + if (!value) { + return; + } + this._options.forEach((opt) => opt.highlight(value)); + } + + private _onNegativeChange(): void { + this._negative = isValidAttribute(this, 'data-negative'); + } + + protected override render(): TemplateResult { + setAttribute(this, 'role', !this._inertAriaGroups ? 'group' : null); + setAttribute(this, 'aria-label', !this._inertAriaGroups && this.label); + setAttribute(this, 'aria-disabled', !this._inertAriaGroups && this.disabled.toString()); + + return html` +
+ +
+ + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete-grid-optgroup': SbbAutocompleteGridOptgroupElement; + } +} diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/index.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/index.ts new file mode 100644 index 0000000000..12b3b2913b --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/index.ts @@ -0,0 +1 @@ +export * from './autocomplete-grid-optgroup'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md b/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md new file mode 100644 index 0000000000..974400a3dd --- /dev/null +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md @@ -0,0 +1,57 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-autocomplete-grid-optgroup` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ---------- | ------- | --------- | ------- | ---------------------------------- | +| `label` | `label` | public | `string` | | Option group label. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | + +## Slots + +| Name | Description | +| ---- | ------------------------------------------------------------------------------------------------------------ | +| | Use the unnamed slot to add `sbb-autocomplete-grid-option` elements to the `sbb-autocomplete-grid-optgroup`. | diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts index 72f89aff8d..e3e65b6d67 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -149,7 +149,7 @@ export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( public override connectedCallback(): void { super.connectedCallback(); const signal = this._abort.signal; - const parentGroup = this.closest?.('sbb-optgroup'); // fixme + const parentGroup = this.closest?.('sbb-autocomplete-grid-optgroup'); if (parentGroup) { this._disabledFromGroup = parentGroup.disabled; } diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index c2545ba1dc..a513a7263e 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -286,7 +286,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix this.querySelectorAll?.('sbb-divider').forEach((divider) => (divider.negative = this.negative)); this.querySelectorAll?.( - 'sbb-autocomplete-grid-option, sbb-optgroup', // FIXME + 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-optgroup', ).forEach((element) => toggleDatasetEntry(element, 'negative', this.negative)); this.querySelectorAll?.( diff --git a/src/components/autocomplete-grid/index.ts b/src/components/autocomplete-grid/index.ts index d4a232760a..e51fd72019 100644 --- a/src/components/autocomplete-grid/index.ts +++ b/src/components/autocomplete-grid/index.ts @@ -1,5 +1,6 @@ export * from './autocomplete-grid'; export * from './autocomplete-grid-row'; export * from './autocomplete-grid-option'; +export * from './autocomplete-grid-optgroup'; export * from './autocomplete-grid-actions'; export * from './autocomplete-grid-button'; diff --git a/src/components/option/optgroup/optgroup.stories.ts b/src/components/option/optgroup/optgroup.stories.ts index d6abaa3132..696f79c693 100644 --- a/src/components/option/optgroup/optgroup.stories.ts +++ b/src/components/option/optgroup/optgroup.stories.ts @@ -120,9 +120,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}`} - `; }); From 34f58632bec7a2ca5542a31538aef1e4bc3efd27 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Wed, 13 Mar 2024 18:21:39 +0100 Subject: [PATCH 04/67] fix: remove toggleDatasetEntry, fix test --- .../autocomplete-grid-optgroup.ts | 6 ++-- .../autocomplete-grid-option.ts | 10 ++----- .../autocomplete-grid.spec.snap.js | 30 ------------------- .../autocomplete-grid/autocomplete-grid.ts | 16 +++++----- 4 files changed, 12 insertions(+), 50 deletions(-) diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts index 6bf5bb8895..a55b012bcb 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { SbbDisabledMixin, SlotChildObserver } from '../../core/common-behaviors'; -import { isSafari, isValidAttribute, toggleDatasetEntry, setAttribute } from '../../core/dom'; +import { isSafari, isValidAttribute, setAttribute } from '../../core/dom'; import { AgnosticMutationObserver } from '../../core/observers'; import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; @@ -45,7 +45,7 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver( super.connectedCallback(); this._negativeObserver?.disconnect(); this._negative = !!this.closest?.(`:is(sbb-autocomplete-grid, sbb-form-field)[negative]`); - toggleDatasetEntry(this, 'negative', this._negative); + this.toggleAttribute('data-negative', this._negative); this._negativeObserver.observe(this, { attributes: true, @@ -86,7 +86,7 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver( private _proxyDisabledToOptions(): void { for (const option of this._options) { - toggleDatasetEntry(option, 'groupDisabled', this.disabled); + option.toggleAttribute('data-group-disabled', this.disabled); } } diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts index e3e65b6d67..ecca0e8f64 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -9,13 +9,7 @@ import { SbbDisabledMixin, SbbIconNameMixin, } from '../../core/common-behaviors'; -import { - isSafari, - isValidAttribute, - isAndroid, - toggleDatasetEntry, - setAttribute, -} from '../../core/dom'; +import { isSafari, isValidAttribute, isAndroid, setAttribute } from '../../core/dom'; import { EventEmitter, ConnectedAbortController } from '../../core/eventing'; import { AgnosticMutationObserver } from '../../core/observers'; @@ -159,7 +153,7 @@ export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( // :is() selector not possible due to test environment `sbb-autocomplete-grid[negative],sbb-form-field[negative]`, ); - toggleDatasetEntry(this, 'negative', this._negative); + this.toggleAttribute('data-negative', this._negative); this.addEventListener('click', (e: MouseEvent) => this._selectByClick(e), { signal, diff --git a/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js index ad038b8ebe..a64b1c9852 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js @@ -102,33 +102,3 @@ snapshots["sbb-autocomplete-grid ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid ShadowDom */ -snapshots["sbb-autocomplete-grid A11y tree Chrome"] = -`

- { - "role": "WebArea", - "name": "" -} -

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

- { - "role": "document", - "name": "" -} -

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

- { - "role": "WebArea", - "name": "" -} -

-`; -/* end snapshot sbb-autocomplete-grid A11y tree Safari */ - diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index a513a7263e..c888bb8e98 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -11,7 +11,6 @@ import { findReferencedElement, isSafari, isValidAttribute, - toggleDatasetEntry, isBrowser, } from '../../core/dom'; import { ConnectedAbortController, EventEmitter } from '../../core/eventing'; @@ -287,7 +286,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix this.querySelectorAll?.( 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-optgroup', - ).forEach((element) => toggleDatasetEntry(element, 'negative', this.negative)); + ).forEach((element) => element.toggleAttribute('data-negative', this.negative)); this.querySelectorAll?.( 'sbb-autocomplete-grid-button', @@ -308,9 +307,8 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix this._openPanelEventsController?.abort(); this._originElement = undefined; - toggleDatasetEntry( - this, - 'optionPanelOriginBorderless', + this.toggleAttribute( + 'data-option-panel-origin-borderless', !!this.closest?.('sbb-form-field')?.hasAttribute('borderless'), ); @@ -562,7 +560,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix if (this._activeColumnIndex !== 0) { this._row[this._activeItemIndex] .querySelectorAll('sbb-autocomplete-grid-button') - .forEach((e) => toggleDatasetEntry(e, 'focusVisible', false)); + .forEach((e) => e.toggleAttribute('data-focus-visible', false)); } this._activeItemIndex = next; @@ -586,7 +584,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix if (nextElement instanceof SbbAutocompleteGridOptionElement) { nextElement.active = true; } else { - toggleDatasetEntry(nextElement, 'focusVisible', true); + nextElement.toggleAttribute('data-focus-visible', true); } const lastActiveElement: SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement = @@ -594,7 +592,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix if (lastActiveElement instanceof SbbAutocompleteGridOptionElement) { lastActiveElement.active = false; } else { - toggleDatasetEntry(lastActiveElement, 'focusVisible', false); + lastActiveElement.toggleAttribute('data-focus-visible', false); } this.triggerElement?.setAttribute('aria-activedescendant', nextElement.id); nextElement.scrollIntoView({ block: 'nearest' }); @@ -611,7 +609,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix if (this._activeColumnIndex !== 0) { this._row[this._activeItemIndex] .querySelectorAll('sbb-autocomplete-grid-button') - .forEach((e) => toggleDatasetEntry(e, 'focusVisible', false)); + .forEach((e) => e.toggleAttribute('data-focus-visible', false)); } this._activeItemIndex = -1; this._activeColumnIndex = 0; From da18e6157e696fafa3386662d4f896331ec3d175 Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 14 Mar 2024 12:21:39 +0100 Subject: [PATCH 05/67] fix: annotations order --- .../autocomplete-grid-actions/autocomplete-grid-actions.ts | 2 +- .../autocomplete-grid-button/autocomplete-grid-button.ts | 2 +- .../autocomplete-grid-option/autocomplete-grid-option.ts | 2 +- .../autocomplete-grid-row/autocomplete-grid-row.ts | 2 +- .../autocomplete-grid/autocomplete-grid/autocomplete-grid.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts index 33f34f0dd0..4b94e86f5c 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts @@ -11,10 +11,10 @@ import style from './autocomplete-grid-actions.scss?lit&inline'; * * @slot - Use the unnamed slot to add `sbb-mini-button` elements. */ +@customElement('sbb-autocomplete-grid-actions') @hostAttributes({ role: 'gridcell', }) -@customElement('sbb-autocomplete-grid-actions') export class SbbAutocompleteGridActionsElement extends LitElement { public static override styles: CSSResultGroup = style; diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts index e167380a9a..d5e591213b 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts @@ -21,10 +21,10 @@ const buttonObserverConfig: MutationObserverInit = { * * @slot icon - Slot used to display the icon, if one is set */ +@customElement('sbb-autocomplete-grid-button') @hostAttributes({ tabindex: null, }) -@customElement('sbb-autocomplete-grid-button') export class SbbAutocompleteGridButtonElement extends SbbDisabledMixin(SbbMiniButtonBaseElement) { public static override styles: CSSResultGroup = style; diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts index ecca0e8f64..4eefbeb9a3 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -33,10 +33,10 @@ const optionObserverConfig: MutationObserverInit = { * @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', }) -@customElement('sbb-autocomplete-grid-option') export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( SbbIconNameMixin(LitElement), ) { diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts index 646e27aead..dcee3eb7f4 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts @@ -11,10 +11,10 @@ let autocompleteRowNextId = 0; /** * Describe the purpose of the component with a single short sentence. */ +@customElement('sbb-autocomplete-grid-row') @hostAttributes({ role: 'row', }) -@customElement('sbb-autocomplete-grid-row') export class SbbAutocompleteGridRowElement extends LitElement { public static override styles: CSSResultGroup = style; private _rowId = ++autocompleteRowNextId; diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index c888bb8e98..937997844a 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -43,10 +43,10 @@ let nextId = 0; * the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the * component is set to `var(--sbb-overlay-z-index)` with a value of `1000`. */ +@customElement('sbb-autocomplete-grid') @hostAttributes({ dir: getDocumentWritingMode(), }) -@customElement('sbb-autocomplete-grid') export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMixin(LitElement)) { public static override styles: CSSResultGroup = style; public static readonly events = { From 740ba14c39a9642d659b2cdbde591d151b89c42a Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Thu, 14 Mar 2024 14:37:25 +0100 Subject: [PATCH 06/67] test: regenerate snap --- .../autocomplete-grid-actions.spec.snap.js | 48 --------------- .../autocomplete-grid-button.spec.snap.js | 48 --------------- .../autocomplete-grid-option.spec.snap.js | 48 --------------- .../autocomplete-grid-row.spec.snap.js | 60 ------------------- .../transparent-button-static.spec.snap.js | 14 +++++ 5 files changed, 14 insertions(+), 204 deletions(-) diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js index 90f1ad5e81..7d7c5096c1 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/__snapshots__/autocomplete-grid-actions.spec.snap.js @@ -28,51 +28,3 @@ snapshots["sbb-autocomplete-grid-actions ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid-actions ShadowDom */ -snapshots["sbb-autocomplete-grid-actions A11y tree Chrome"] = -`

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

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

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

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

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

-`; -/* end snapshot sbb-autocomplete-grid-actions A11y tree Safari */ - diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js index 47aa54461b..e237e4a1a9 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid-button/__snapshots__/autocomplete-grid-button.spec.snap.js @@ -30,51 +30,3 @@ snapshots["sbb-autocomplete-grid-button ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid-button ShadowDom */ -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 */ - -snapshots["sbb-autocomplete-grid-button A11y tree Safari"] = -`

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

-`; -/* end snapshot sbb-autocomplete-grid-button A11y tree Safari */ - diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js index f38e6a3005..9d15cf55de 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid-option/__snapshots__/autocomplete-grid-option.spec.snap.js @@ -32,51 +32,3 @@ snapshots["sbb-autocomplete-grid-option ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid-option ShadowDom */ -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 */ - -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 Safari"] = -`

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

-`; -/* end snapshot sbb-autocomplete-grid-option A11y tree Safari */ - diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js index d4d3bf18b9..f0b75dbe6f 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid-row/__snapshots__/autocomplete-grid-row.spec.snap.js @@ -43,63 +43,3 @@ snapshots["sbb-autocomplete-grid-row ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid-row ShadowDom */ -snapshots["sbb-autocomplete-grid-row A11y tree Chrome"] = -`

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

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

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

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

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

-`; -/* end snapshot sbb-autocomplete-grid-row A11y tree Safari */ - diff --git a/src/components/button/transparent-button-static/__snapshots__/transparent-button-static.spec.snap.js b/src/components/button/transparent-button-static/__snapshots__/transparent-button-static.spec.snap.js index 82ed2a1361..bbe8b28f9f 100644 --- a/src/components/button/transparent-button-static/__snapshots__/transparent-button-static.spec.snap.js +++ b/src/components/button/transparent-button-static/__snapshots__/transparent-button-static.spec.snap.js @@ -28,6 +28,20 @@ snapshots["sbb-transparent-button-static renders a sbb-transparent-button-static `; /* end snapshot sbb-transparent-button-static renders a sbb-transparent-button-static without icon ShadowDom */ +snapshots["ShadowDom"] = +` + + + + + + + + + +`; +/* end snapshot ShadowDom */ + snapshots["sbb-transparent-button-static renders a sbb-transparent-button-static with slotted icon Dom"] = ` Date: Thu, 14 Mar 2024 18:03:11 +0100 Subject: [PATCH 07/67] feat: introduce option common class, add method on button to retrieve the related option --- .../autocomplete-grid-actions.scss | 2 +- .../autocomplete-grid-actions.stories.ts | 10 +- .../autocomplete-grid-actions.ts | 4 +- .../autocomplete-grid-actions/readme.md | 6 +- .../autocomplete-grid-button.stories.ts | 6 +- .../autocomplete-grid-button.ts | 19 +- .../autocomplete-grid-button/readme.md | 19 +- .../autocomplete-grid-optgroup.spec.snap.js | 121 ++++++++ .../autocomplete-grid-optgroup.spec.ts | 23 +- .../autocomplete-grid-optgroup.stories.ts | 5 +- .../autocomplete-grid-optgroup.ts | 33 +- .../autocomplete-grid-option.scss | 133 -------- .../autocomplete-grid-option.ts | 214 ++----------- .../autocomplete-grid-option/readme.md | 12 +- .../autocomplete-grid-row.stories.ts | 2 +- .../autocomplete-grid-row.ts | 2 +- .../autocomplete-grid.spec.snap.js | 204 +++++++++++++ .../autocomplete-grid.spec.ts | 23 +- .../autocomplete-grid.stories.ts | 12 + .../autocomplete-grid/autocomplete-grid.ts | 31 +- .../base-elements/option-base-element.scss} | 2 +- .../core/base-elements/option-base-element.ts | 237 +++++++++++++++ .../core/styles/mixins/buttons.scss | 6 +- src/components/option/option/option.ts | 285 +++--------------- src/components/option/option/readme.md | 4 +- 25 files changed, 776 insertions(+), 639 deletions(-) delete mode 100644 src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss rename src/components/{option/option/option.scss => core/base-elements/option-base-element.scss} (99%) create mode 100644 src/components/core/base-elements/option-base-element.ts diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss index da287ce25d..7bfd87bc1c 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.scss @@ -6,5 +6,5 @@ .sbb-autocomplete-grid-action { display: flex; - column-gap: var(--sbb-spacing-responsive-xxxs); + column-gap: var(--sbb-spacing-responsive-xxxs); // FIXME } diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts index 22a04f891d..6ed59dc1fc 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.stories.ts @@ -12,7 +12,7 @@ import { html, type TemplateResult } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { sbbSpread } from '../../core/dom'; +import { sbbSpread } from '../../../storybook/helpers/spread'; import readme from './readme.md?raw'; import './autocomplete-grid-actions'; @@ -51,7 +51,7 @@ const Template = ({ numberOfButtons, ...args }: Args): TemplateResult => html` (_, i) => html` `, )} @@ -64,6 +64,12 @@ export const Default: StoryObj = { args: { ...defaultArgs }, }; +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + export const Multiple: StoryObj = { render: Template, argTypes: defaultArgTypes, diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts index 4b94e86f5c..093498f665 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/autocomplete-grid-actions.ts @@ -1,7 +1,7 @@ import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { hostAttributes } from '../../core/common-behaviors'; +import { hostAttributes } from '../../core/decorators'; import type { SbbAutocompleteGridButtonElement } from '../index'; import style from './autocomplete-grid-actions.scss?lit&inline'; @@ -9,7 +9,7 @@ import style from './autocomplete-grid-actions.scss?lit&inline'; /** * A wrapper component for autocomplete-grid action buttons. * - * @slot - Use the unnamed slot to add `sbb-mini-button` elements. + * @slot - Use the unnamed slot to add `sbb-autocomplete-grid-button` elements. */ @customElement('sbb-autocomplete-grid-actions') @hostAttributes({ diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md index f2236dfad6..9ce2d05e88 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md @@ -45,6 +45,6 @@ The `sbb-autocomplete-grid-actions` is a component . . . ## Slots -| Name | Description | -| ---- | ------------------------------------------------------- | -| | Use the unnamed slot to add `sbb-mini-button` elements. | +| Name | Description | +| ---- | -------------------------------------------------------------------- | +| | Use the unnamed slot to add `sbb-autocomplete-grid-button` elements. | diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts index 6b5f0f8d2b..b88f0fb496 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.stories.ts @@ -11,7 +11,7 @@ import type { import { html, type TemplateResult } from 'lit'; import { styleMap } from 'lit/directives/style-map.js'; -import { sbbSpread } from '../../core/dom'; +import { sbbSpread } from '../../../storybook/helpers/spread'; import readme from './readme.md?raw'; import './autocomplete-grid-button'; @@ -89,13 +89,13 @@ const ariaLabel: InputType = { const active: InputType = { control: { - type: 'text', + type: 'boolean', }, }; const focusVisible: InputType = { control: { - type: 'text', + type: 'boolean', }, }; diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts index d5e591213b..6651a79199 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-button/autocomplete-grid-button.ts @@ -1,19 +1,19 @@ import { type CSSResultGroup, type TemplateResult } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import { - hostAttributes, - SbbDisabledMixin, - SbbMiniButtonBaseElement, -} from '../../core/common-behaviors'; +import { SbbMiniButtonBaseElement } from '../../core/base-elements'; +import { hostAttributes } from '../../core/decorators'; import { isValidAttribute, setAttribute } from '../../core/dom'; +import { SbbDisabledMixin } from '../../core/mixins'; import { AgnosticMutationObserver } from '../../core/observers'; +import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; +import '../../icon'; import style from './autocomplete-grid-button.scss?lit&inline'; /** Configuration for the attribute to look at if component is nested in a sbb-optgroup */ const buttonObserverConfig: MutationObserverInit = { - attributeFilter: ['data-group-disabled', 'data-negative'], // fixme negative + attributeFilter: ['data-group-disabled'], }; /** @@ -63,6 +63,13 @@ export class SbbAutocompleteGridButtonElement extends SbbDisabledMixin(SbbMiniBu return super.dispatchClickEvent(event); } + /** Gets the SbbAutocompleteGridOptionElement on the same row of the button. */ + public get optionOnSameRow(): SbbAutocompleteGridOptionElement | null { + return this.closest?.('sbb-autocomplete-grid-row')!.querySelector( + 'sbb-autocomplete-grid-option', + ); + } + protected override renderTemplate(): TemplateResult { setAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); return super.renderTemplate(); diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/readme.md b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md index 28b083ca7c..42730034dd 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md @@ -45,15 +45,16 @@ The `sbb-autocomplete-grid-button` is a component . . . ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ---------- | ----------- | ------- | --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | -| `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. | -| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. | -| `name` | `name` | public | `string` | | The name of the button element. | -| `value` | `value` | public | `string` | | The value of the button element. | -| `form` | `form` | public | `string \| undefined` | | The element to associate the button with. | +| Name | Attribute | Privacy | Type | Default | Description | +| ----------------- | ----------- | ------- | ------------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `optionOnSameRow` | - | public | `SbbAutocompleteGridOptionElement \| null` | | Gets the SbbAutocompleteGridOptionElement on the same row of the button. | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `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. | +| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. | +| `name` | `name` | public | `string` | | The name of the button element. | +| `value` | `value` | public | `string` | | The value of the button element. | +| `form` | `form` | public | `string \| undefined` | | The element to associate the button with. | ## Methods diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js index 33dca108d0..8cf7802dce 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/__snapshots__/autocomplete-grid-optgroup.spec.snap.js @@ -60,3 +60,124 @@ snapshots["sbb-autocomplete-grid-optgroup ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid-optgroup ShadowDom */ +snapshots["sbb-autocomplete-grid-optgroup Chrome-Firefox Dom"] = +` + + + Option 1 + + + + + Option 2 + + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup Chrome-Firefox Dom */ + +snapshots["sbb-autocomplete-grid-optgroup Chrome-Firefox ShadowDom"] = +`
+ + +
+ + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup Chrome-Firefox ShadowDom */ + +snapshots["sbb-autocomplete-grid-optgroup Safari Dom"] = +` + + + Option 1 + + + + + Option 2 + + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup Safari Dom */ + +snapshots["sbb-autocomplete-grid-optgroup Safari ShadowDom"] = +`
+ + +
+ + + +`; +/* end snapshot sbb-autocomplete-grid-optgroup Safari ShadowDom */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts index eddac15fc0..305be8ceec 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.spec.ts @@ -1,6 +1,9 @@ import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; +import { isSafari } from '../../core/dom'; +import { describeIf } from '../../core/testing'; + import './autocomplete-grid-optgroup'; import '../autocomplete-grid'; import '../autocomplete-grid-row'; @@ -29,11 +32,23 @@ describe('sbb-autocomplete-grid-optgroup', () => { ).querySelector('sbb-autocomplete-grid-optgroup')!; }); - it('Dom', async () => { - await expect(root).dom.to.be.equalSnapshot(); + describeIf(!isSafari(), 'Chrome-Firefox', async () => { + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); }); - it('ShadowDom', async () => { - await expect(root).shadowDom.to.be.equalSnapshot(); + describeIf(isSafari(), 'Safari', async () => { + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); }); }); diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts index eddf5d714b..56377315c9 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.stories.ts @@ -115,7 +115,10 @@ const createOptions = (args: Args): TemplateResult[] => >${`${args.value} ${i + 1}`} - + `; diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts index a55b012bcb..1e04550fc8 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts @@ -2,9 +2,10 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { SbbDisabledMixin, SlotChildObserver } from '../../core/common-behaviors'; import { isSafari, isValidAttribute, setAttribute } from '../../core/dom'; +import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins'; import { AgnosticMutationObserver } from '../../core/observers'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button'; import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; import style from './autocomplete-grid-optgroup.scss?lit&inline'; @@ -16,8 +17,8 @@ import '../../divider'; * @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 SlotChildObserver( - SbbDisabledMixin(LitElement), +export class SbbAutocompleteGridOptgroupElement extends SbbDisabledMixin( + SbbHydrationMixin(LitElement), ) { public static override styles: CSSResultGroup = style; @@ -41,6 +42,12 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver( ) as SbbAutocompleteGridOptionElement[]; } + private get _buttons(): SbbAutocompleteGridButtonElement[] { + return Array.from( + this.querySelectorAll?.('sbb-autocomplete-grid-button') ?? [], + ) as SbbAutocompleteGridButtonElement[]; + } + public override connectedCallback(): void { super.connectedCallback(); this._negativeObserver?.disconnect(); @@ -70,7 +77,7 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver( this._negativeObserver?.disconnect(); } - protected override checkChildren(): void { + private _handleSlotchange(): void { this._proxyDisabledToOptions(); this._proxyGroupLabelToOptions(); this._highlightOptions(); @@ -79,14 +86,22 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver( private _proxyGroupLabelToOptions(): void { if (!this._inertAriaGroups) { return; + } else if (this.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?.(); + } } - - this._options.forEach((opt) => opt.setGroupLabel(this.label)); } private _proxyDisabledToOptions(): void { - for (const option of this._options) { - option.toggleAttribute('data-group-disabled', this.disabled); + for (const el of [...this._options, ...this._buttons]) { + el.toggleAttribute('data-group-disabled', this.disabled); } } @@ -119,7 +134,7 @@ export class SbbAutocompleteGridOptgroupElement extends SlotChildObserver(
${this.label} - + `; } } diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss deleted file mode 100644 index b96fc128e8..0000000000 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.scss +++ /dev/null @@ -1,133 +0,0 @@ -@use '../../core/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-option-color: var(--sbb-color-charcoal); - --sbb-option-background-color: inherit; - --sbb-option-background-color-hover: var(--sbb-color-milk); - --sbb-option-background-color-active: var(--sbb-color-cloud); - --sbb-option-disabled-border-color: var(--sbb-color-graphite); - --sbb-option-disabled-background-color: var(--sbb-color-milk); - --sbb-option-column-gap: var(--sbb-spacing-responsive-xxxs); - --sbb-option-justify-content: start; - --sbb-option-min-height: var(--sbb-size-button-m-min-height); - --sbb-option-cursor: pointer; - --sbb-option-border-radius: var(--sbb-border-radius-4x); - --sbb-option-icon-color: var(--sbb-color-metal); -} - -:host([data-negative]) { - --sbb-option-color: var(--sbb-color-milk); - --sbb-option-icon-color: var(--sbb-color-smoke); - --sbb-option-background-color-hover: var(--sbb-color-charcoal); - --sbb-option-background-color-active: var(--sbb-color-iron); - --sbb-option-disabled-border-color: var(--sbb-color-smoke); - --sbb-option-disabled-background-color: var(--sbb-color-charcoal); - --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); -} - -:host([active]) { - --sbb-focus-outline-offset: calc(-1 * var(--sbb-spacing-fixed-1x)); -} - -:host(:hover:not([disabled], [data-group-disabled])) { - @include sbb.hover-mq($hover: true) { - --sbb-option-background-color: var(--sbb-option-background-color-hover); - } -} - -:host(:active:not([disabled], [data-group-disabled])) { - --sbb-option-background-color: var(--sbb-option-background-color-active); -} - -// if the highlight is enabled, hide the slot content -:host(:not([data-disable-highlight])) { - .sbb-option__label slot { - display: none; - } -} - -:host(:is([data-group-disabled], [disabled])) { - --sbb-option-cursor: default; - - @include sbb.if-forced-colors { - --sbb-option-color: GrayText; - } -} - -.sbb-option__label--highlight { - :host(:not(:is([disabled], [data-group-disabled]))) & { - @include sbb.text--bold; - @include sbb.if-forced-colors { - color: Highlight; - } - } -} - -.sbb-option__container { - background-color: var(--sbb-option-background-color); -} - -.sbb-option { - @include sbb.text-s--regular; - - display: flex; - align-items: center; - column-gap: var(--sbb-option-column-gap); - justify-content: var(--sbb-option-justify-content); - color: var(--sbb-option-color); - background-color: var(--sbb-option-background-color); - cursor: var(--sbb-option-cursor); - -webkit-tap-highlight-color: transparent; - -webkit-text-fill-color: var(--sbb-option-color); - - :host([active]) & { - @include sbb.focus-outline; - - border-radius: var(--sbb-option-border-radius); - } - - // Add inner border and background for disabled option when it's not multiple - :host(:is([data-group-disabled], [disabled]):not([data-multiple])) & { - 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-option-disabled-border-color); - border-radius: var(--sbb-border-radius-2x); - background-color: var(--sbb-option-disabled-background-color); - z-index: -1; - - @include sbb.if-forced-colors { - border-color: GrayText; - } - } - } -} - -.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); - } -} - -.sbb-option__label { - white-space: initial; -} - -.sbb-option__group-label--visually-hidden { - @include sbb.screen-reader-only; -} diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts index 4eefbeb9a3..b784ef2487 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -1,28 +1,12 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; -import { assignId } from '../../core/a11y'; -import { - hostAttributes, - NamedSlotStateController, - SbbDisabledMixin, - SbbIconNameMixin, -} from '../../core/common-behaviors'; -import { isSafari, isValidAttribute, isAndroid, setAttribute } from '../../core/dom'; -import { EventEmitter, ConnectedAbortController } from '../../core/eventing'; -import { AgnosticMutationObserver } from '../../core/observers'; +import { SbbOptionBaseElement } from '../../core/base-elements/option-base-element'; +import { hostAttributes } from '../../core/decorators'; +import { EventEmitter } from '../../core/eventing'; -import style from './autocomplete-grid-option.scss?lit&inline'; import '../../icon'; -let nextId = 0; - -/** Configuration for the attribute to look at if component is nested in a sbb-optgroup */ -const optionObserverConfig: MutationObserverInit = { - attributeFilter: ['data-group-disabled', 'data-negative'], -}; - /** * It displays on option item which can be used in `sbb-autocomplete-grid`. * @@ -37,102 +21,28 @@ const optionObserverConfig: MutationObserverInit = { @hostAttributes({ role: 'gridcell', }) -export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( - SbbIconNameMixin(LitElement), -) { - public static override styles: CSSResultGroup = style; +export class SbbAutocompleteGridOptionElement extends SbbOptionBaseElement { public static readonly events = { selectionChange: 'autocompleteOptionSelectionChange', optionSelected: 'autocompleteOptionSelected', } as const; - /** Value of the option. */ - @property() public value?: string; - - /** Whether the option is currently active. */ - @property({ reflect: true, type: Boolean }) public active?: boolean; - - /** Whether the option is selected. */ - @property({ reflect: true, type: Boolean }) public selected = false; + protected optionId = `sbb-autocomplete-grid-option`; /** Emits when the option selection status changes. */ - private _selectionChange: EventEmitter = new EventEmitter( + protected selectionChange: EventEmitter = new EventEmitter( this, SbbAutocompleteGridOptionElement.events.selectionChange, ); /** Emits when an option was selected by user. */ - private _optionSelected: EventEmitter = new EventEmitter( + protected optionSelected: EventEmitter = new EventEmitter( this, SbbAutocompleteGridOptionElement.events.optionSelected, ); - /** Whether to apply the negative styling */ - @state() private _negative = false; - - /** Whether the component must be set disabled due disabled attribute on sbb-optgroup. */ - @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 _groupLabel: string | null = null; - - private _optionId = `sbb-autocomplete-grid-option-${++nextId}`; - private _abort = new ConnectedAbortController(this); - - /** - * 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. - */ - private _inertAriaGroups = isSafari(); - - /** MutationObserver on data attributes. */ - private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => - this._onOptionAttributesChange(mutationsList), - ); - - public constructor() { - super(); - new NamedSlotStateController(this); - } - - /** - * Highlight the label of the option - * @param value the highlighted portion of the label - * @internal - */ - public highlight(value: string): void { - this._highlightString = value; - } - - /** - * Set the option group label (used for a11y) - * @param value the label of the option group - */ - public setGroupLabel(value: string): void { - this._groupLabel = value; - } - - /** - * @internal - */ - public setSelectedViaUserInteraction(selected: boolean): void { - this.selected = selected; - this._selectionChange.emit(); - if (this.selected) { - this._optionSelected.emit(); - } - } - - private _selectByClick(event: MouseEvent): void { - if (this.disabled || this._disabledFromGroup) { + protected selectByClick(event: MouseEvent): void { + if (this.disabled || this.disabledFromGroup) { event.stopPropagation(); return; } @@ -140,109 +50,27 @@ export class SbbAutocompleteGridOptionElement extends SbbDisabledMixin( this.setSelectedViaUserInteraction(true); } - public override connectedCallback(): void { - super.connectedCallback(); - const signal = this._abort.signal; - const parentGroup = this.closest?.('sbb-autocomplete-grid-optgroup'); - if (parentGroup) { - this._disabledFromGroup = parentGroup.disabled; - } - this._optionAttributeObserver.observe(this, optionObserverConfig); - - this._negative = !!this.closest?.( - // :is() selector not possible due to test environment - `sbb-autocomplete-grid[negative],sbb-form-field[negative]`, - ); - this.toggleAttribute('data-negative', this._negative); - - this.addEventListener('click', (e: MouseEvent) => this._selectByClick(e), { - signal, - passive: true, - }); - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._optionAttributeObserver.disconnect(); - } - - /** 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 = isValidAttribute(this, 'data-group-disabled'); - } else if (mutation.attributeName === 'data-negative') { - this._negative = isValidAttribute(this, 'data-negative'); - } - } - } - - private _setupHighlightHandler(event: Event): void { + protected setupHighlightHandler(event: Event): void { const slotNodes = (event.target as HTMLSlotElement).assignedNodes(); const labelNodes = slotNodes.filter((el) => el.nodeType === Node.TEXT_NODE) as Text[]; - // Disable the highlight if the slot contain more than just text nodes - if (labelNodes.length === 0 || slotNodes.length !== labelNodes.length) { - this._disableLabelHighlight = true; + // Disable the highlight if the slot contains more than just text nodes + if ( + labelNodes.length === 0 || + slotNodes.filter((n) => !(n instanceof Element) || n.localName !== 'template').length !== + labelNodes.length + ) { + this.updateDisableHighlight(true); return; } - this._label = labelNodes + this.label = labelNodes .map((l) => l.wholeText) .filter((l) => l.trim()) .join(); } - 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); - - return html` - ${prefix}${highlighted}${postfix} - `; - } - protected override render(): TemplateResult { - setAttribute(this, 'tabindex', isAndroid() && !this.disabled && 0); - setAttribute(this, 'data-disable-highlight', this._disableLabelHighlight); - setAttribute(this, 'aria-selected', `${this.selected}`); // fixme check this on keynav - setAttribute(this, 'aria-disabled', `${this.disabled || this._disabledFromGroup}`); - assignId(() => this._optionId)(this); - - return html` -
-
- ${this.renderIconSlot()} - - - - - ${this._label && !this._disableLabelHighlight ? this._getHighlightedLabel() : nothing} - ${this._inertAriaGroups && this._groupLabel - ? html` - - (${this._groupLabel}) - - ` - : nothing} - -
-
- `; + return super.render(); } } diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/readme.md b/src/components/autocomplete-grid/autocomplete-grid-option/readme.md index c8ade4c295..09e17822ff 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-option/readme.md @@ -47,17 +47,11 @@ The `sbb-autocomplete-grid-option` is a component . . . | Name | Attribute | Privacy | Type | Default | Description | | ---------- | ----------- | ------- | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `value` | public | `string \| undefined` | | Value of the option. | -| `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | -| `selected` | `selected` | public | `boolean` | `false` | Whether the option is selected. | | `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. | - -## Methods - -| Name | Privacy | Description | Parameters | Return | Inherited From | -| --------------- | ------- | ------------------------------------------ | --------------- | ------ | -------------- | -| `setGroupLabel` | public | Set the option group label (used for a11y) | `value: string` | `void` | | +| `value` | `value` | public | `string` | | Value of the option. | +| `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | +| `selected` | `selected` | public | `boolean` | | Whether the option is selected. | ## Events diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts index 2fe59e383a..f4e0b4bf1b 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.stories.ts @@ -2,7 +2,7 @@ import { withActions } from '@storybook/addon-actions/decorator'; import type { Args, Decorator, Meta, StoryObj } from '@storybook/web-components'; import { html, type TemplateResult } from 'lit'; -import { sbbSpread } from '../../core/dom'; +import { sbbSpread } from '../../../storybook/helpers/spread'; import readme from './readme.md?raw'; diff --git a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts index dcee3eb7f4..9a651d6d3f 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-row/autocomplete-grid-row.ts @@ -1,7 +1,7 @@ import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { hostAttributes } from '../../core/common-behaviors'; +import { hostAttributes } from '../../core/decorators'; import type { SbbAutocompleteGridOptionElement, SbbAutocompleteGridActionsElement } from '../index'; import style from './autocomplete-grid-row.scss?lit&inline'; diff --git a/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js index a64b1c9852..9b765281cc 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js +++ b/src/components/autocomplete-grid/autocomplete-grid/__snapshots__/autocomplete-grid.spec.snap.js @@ -102,3 +102,207 @@ snapshots["sbb-autocomplete-grid ShadowDom"] = `; /* end snapshot sbb-autocomplete-grid ShadowDom */ +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 ShadowDom"] = +`
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+`; +/* end snapshot sbb-autocomplete-grid Chrome-Firefox ShadowDom */ + +snapshots["sbb-autocomplete-grid Safari Dom"] = +` + + + Option 1 + + + + + + + + + Option 2 + + + + + + + +`; +/* end snapshot sbb-autocomplete-grid Safari Dom */ + +snapshots["sbb-autocomplete-grid Safari ShadowDom"] = +`
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+`; +/* end snapshot sbb-autocomplete-grid Safari ShadowDom */ + diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts index 1986758144..57d63dbf6e 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts @@ -1,6 +1,9 @@ import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; +import { isSafari } from '../../core/dom'; +import { describeIf } from '../../core/testing'; + import type { SbbAutocompleteGridElement } from './autocomplete-grid'; import './autocomplete-grid'; import '../autocomplete-grid-row'; @@ -30,11 +33,23 @@ describe('sbb-autocomplete-grid', () => { `); }); - it('Dom', async () => { - await expect(root).dom.to.be.equalSnapshot(); + describeIf(!isSafari(), 'Chrome-Firefox', async () => { + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); }); - it('ShadowDom', async () => { - await expect(root).shadowDom.to.be.equalSnapshot(); + describeIf(isSafari(), 'Safari', async () => { + it('Dom', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('ShadowDom', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); }); }); diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts index 18588f89cd..89380eda41 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.stories.ts @@ -12,6 +12,7 @@ import { html, nothing, type TemplateResult } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; +import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button'; import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; import { SbbAutocompleteGridElement } from './autocomplete-grid'; @@ -21,6 +22,13 @@ import '../autocomplete-grid-actions'; import '../autocomplete-grid-button'; import '../../form-field'; +const getOption = (event: Event): void => { + const button = event.target as SbbAutocompleteGridButtonElement; + const div: HTMLDivElement = document.createElement('div'); + div.innerText = `Button has been clicked on row with label: '${button.optionOnSameRow?.textContent}' and value: '${button.optionOnSameRow?.value}'`; + (event.currentTarget as HTMLElement).closest('div')!.querySelector('#container')!.prepend(div); +}; + const wrapperStyle = (context: StoryContext): Record => ({ 'background-color': context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', }); @@ -160,6 +168,7 @@ const createRows1 = (optionIconName: string, buttonIconName: string): TemplateRe getOption(event)} > @@ -179,10 +188,12 @@ const createRows2 = (buttonIconName: string): TemplateResult => html` getOption(event)} > getOption(event)} > @@ -235,6 +246,7 @@ const Template = (args: Args): TemplateResult => html` ${textBlock()} +
`; diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index 937997844a..443b1a8572 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -4,7 +4,8 @@ import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; import { assignId, getNextElementIndex } from '../../core/a11y'; -import { hostAttributes, SbbNegativeMixin, SlotChildObserver } from '../../core/common-behaviors'; +import { SbbConnectedAbortController } from '../../core/controllers'; +import { hostAttributes } from '../../core/decorators'; import { setAttribute, getDocumentWritingMode, @@ -13,7 +14,8 @@ import { isValidAttribute, isBrowser, } from '../../core/dom'; -import { ConnectedAbortController, EventEmitter } from '../../core/eventing'; +import { EventEmitter } from '../../core/eventing'; +import { SbbHydrationMixin, SbbNegativeMixin } from '../../core/mixins'; import type { SbbOverlayState } from '../../core/overlay'; import { isEventOnElement, @@ -47,7 +49,7 @@ let nextId = 0; @hostAttributes({ dir: getDocumentWritingMode(), }) -export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMixin(LitElement)) { +export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { public static override styles: CSSResultGroup = style; public static readonly events = { willOpen: 'willOpen', @@ -129,7 +131,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix private _activeColumnIndex = 0; private _didLoad = false; private _isPointerDownEventOnMenu: boolean = false; - private _abort = new ConnectedAbortController(this); + private _abort = new SbbConnectedAbortController(this); /** * On Safari, the aria role 'listbox' must be on the host element, or else VoiceOver won't work at all. @@ -277,7 +279,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix this._didLoad = true; } - public override checkChildren(): void { + private _handleSlotchange(): void { this._highlightOptions(this.triggerElement?.value); } @@ -547,6 +549,9 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix // 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); @@ -573,12 +578,16 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix return; } - const elementsInRow: NodeListOf< - SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement - > = this._row[this._activeItemIndex].querySelectorAll( - 'sbb-autocomplete-grid-option, sbb-autocomplete-grid-button', - ); + 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 && !isValidAttribute(el, '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) { @@ -655,7 +664,7 @@ export class SbbAutocompleteGridElement extends SlotChildObserver(SbbNegativeMix id=${!this._ariaRoleOnHost ? this._overlayId : nothing} ${ref((containerRef) => (this._optionContainer = containerRef as HTMLElement))} > - + diff --git a/src/components/option/option/option.scss b/src/components/core/base-elements/option-base-element.scss similarity index 99% rename from src/components/option/option/option.scss rename to src/components/core/base-elements/option-base-element.scss index 06bb282543..9e3b723cea 100644 --- a/src/components/option/option/option.scss +++ b/src/components/core/base-elements/option-base-element.scss @@ -1,4 +1,4 @@ -@use '../../core/styles' as sbb; +@use '../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; diff --git a/src/components/core/base-elements/option-base-element.ts b/src/components/core/base-elements/option-base-element.ts new file mode 100644 index 0000000000..84962413ec --- /dev/null +++ b/src/components/core/base-elements/option-base-element.ts @@ -0,0 +1,237 @@ +import { type CSSResultGroup, html, LitElement, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import { SbbIconNameMixin } from '../../icon'; +import { SbbConnectedAbortController, SbbSlotStateController } from '../controllers'; +import { isAndroid, isSafari, setOrRemoveAttribute } from '../dom'; +import type { EventEmitter } from '../eventing'; +import { SbbDisabledMixin } from '../mixins'; +import { AgnosticMutationObserver } from '../observers'; + +import style from './option-base-element.scss?lit&inline'; + +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'], +}; + +export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameMixin(LitElement)) { + public static override styles: CSSResultGroup = style; + + protected abstract optionId: string; + + /** Value of the option. */ + @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; + + private _abort = new SbbConnectedAbortController(this); + protected abstract selectByClick(event: MouseEvent): void; + protected abstract setupHighlightHandler(event: Event): 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(); + new SbbSlotStateController(this); + } + + /** + * 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++}`; + + const signal = this._abort.signal; + const parentGroup = this.closest?.('sbb-autocomplete-grid-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-grid[negative],sbb-form-field[negative]`, + ); + this.toggleAttribute('data-group-negative', this.negative); + + this.addEventListener('click', (e: MouseEvent) => this.selectByClick(e), { + signal, + passive: true, + }); + } + + 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(); + } + + private _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. */ + 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'); + } + } + } + + 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()} + ${inertAriaGroups && this.getAttribute('data-group-label') + ? html` + (${this.getAttribute('data-group-label')})` + : nothing} + + ${this.renderTick()} +
+
+ `; + } +} diff --git a/src/components/core/styles/mixins/buttons.scss b/src/components/core/styles/mixins/buttons.scss index d3a2692812..fd2794f873 100644 --- a/src/components/core/styles/mixins/buttons.scss +++ b/src/components/core/styles/mixins/buttons.scss @@ -29,7 +29,7 @@ @include icon-button-variables-negative; } - :host(:is([disabled], [data-disabled])) { + :host(:is([disabled], [data-disabled], [data-group-disabled])) { @include icon-button-disabled(#{$button-selector}); } @@ -42,11 +42,11 @@ @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/components/option/option/option.ts b/src/components/option/option/option.ts index e5f640d7a2..20bc710c57 100644 --- a/src/components/option/option/option.ts +++ b/src/components/option/option/option.ts @@ -1,40 +1,15 @@ -import { - type CSSResultGroup, - html, - LitElement, - nothing, - type PropertyValues, - type TemplateResult, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { TemplateResult } from 'lit'; +import { html, nothing } from 'lit'; +import { customElement } from 'lit/decorators.js'; -import { SbbConnectedAbortController, SbbSlotStateController } from '../../core/controllers.js'; +import { SbbOptionBaseElement } from '../../core/base-elements/option-base-element.js'; import { hostAttributes } from '../../core/decorators.js'; -import { isAndroid, isSafari, setOrRemoveAttribute } from '../../core/dom.js'; import { EventEmitter } from '../../core/eventing.js'; -import { SbbDisabledMixin } from '../../core/mixins.js'; -import { AgnosticMutationObserver } from '../../core/observers.js'; -import { SbbIconNameMixin } from '../../icon.js'; - -import style from './option.scss?lit&inline'; +import '../../icon.js'; 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'; /** @@ -51,61 +26,26 @@ export type SbbOptionVariant = 'autocomplete' | 'select'; @hostAttributes({ role: 'option', }) -export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitElement)) { - public static override styles: CSSResultGroup = style; +export class SbbOptionElement extends SbbOptionBaseElement { public static readonly events = { selectionChange: 'optionSelectionChange', optionSelected: 'optionSelected', } as const; - /** Value of the option. */ - @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-optgroup. */ - @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; - private set _variant(state: SbbOptionVariant) { this.setAttribute('data-variant', state); } @@ -113,13 +53,6 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme return this.getAttribute('data-variant') as SbbOptionVariant; } - private _abort = new SbbConnectedAbortController(this); - - /** MutationObserver on data attributes. */ - private _optionAttributeObserver = new AgnosticMutationObserver((mutationsList) => - this._onOptionAttributesChange(mutationsList), - ); - private get _isAutocomplete(): boolean { return this._variant === 'autocomplete'; } @@ -132,38 +65,8 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme return !!this.closest?.('sbb-select[multiple]'); } - public constructor() { - super(); - new SbbSlotStateController(this); - } - - /** - * 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(); - } - } - - private _updateDisableHighlight(disabled: boolean): void { - this._disableLabelHighlight = disabled; - this.toggleAttribute('data-disable-highlight', disabled); - } - - private _selectByClick(event: MouseEvent): void { - if (this.disabled || this._disabledFromGroup) { + protected selectByClick(event: MouseEvent): void { + if (this.disabled || this.disabledFromGroup) { event.stopPropagation(); return; } @@ -178,64 +81,8 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme public override connectedCallback(): void { super.connectedCallback(); - - this.id ||= `sbb-option-${nextId++}`; - - 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); - this._setVariantByContext(); - this.toggleAttribute('data-multiple', this._isMultiple); - - this.addEventListener('click', (e: MouseEvent) => this._selectByClick(e), { - signal, - passive: true, - }); - } - - 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 _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(); } private _setVariantByContext(): void { @@ -246,21 +93,9 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme } } - /** 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 _setupHighlightHandler(event: Event): void { + protected setupHighlightHandler(event: Event): void { if (!this._isAutocomplete) { - this._updateDisableHighlight(true); + this.updateDisableHighlight(true); return; } @@ -271,83 +106,51 @@ export class SbbOptionElement extends SbbDisabledMixin(SbbIconNameMixin(LitEleme if ( labelNodes.length === 0 || slotNodes.filter((n) => !(n instanceof Element) || n.localName !== 'template').length !== - labelNodes.length + labelNodes.length ) { - this._updateDisableHighlight(true); + this.updateDisableHighlight(true); return; } - this._label = labelNodes + this.label = labelNodes .map((l) => l.wholeText) .filter((l) => l.trim()) .join(); } - 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} - - - - + protected override renderLabel(): TemplateResult | typeof nothing { + return this._isAutocomplete && this.label && !this.disableLabelHighlight + ? this.getHighlightedLabel() + : nothing; + } - - ${this._isAutocomplete && this._label && !this._disableLabelHighlight - ? this._getHighlightedLabel() - : nothing} - ${inertAriaGroups && this.getAttribute('data-group-label') - ? html` - (${this.getAttribute('data-group-label')})` - : nothing} - + protected override renderTick(): TemplateResult | typeof nothing { + return this._isSelect && !this._isMultiple && this.selected + ? html`` + : nothing; + } - - ${this._isSelect && !isMultiple && this.selected - ? html`` - : nothing} -
-
- `; + protected override render(): TemplateResult { + return super.render(); } } diff --git a/src/components/option/option/readme.md b/src/components/option/option/readme.md index b0a5aa6d51..656ca5b14d 100644 --- a/src/components/option/option/readme.md +++ b/src/components/option/option/readme.md @@ -60,11 +60,11 @@ If the label slot contains only a **text node**, it is possible to search for te | 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. | | `value` | `value` | public | `string` | | Value of the option. | | `active` | `active` | public | `boolean \| undefined` | | Whether the option is currently active. | | `selected` | `selected` | public | `boolean` | | Whether the option is selected. | -| `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. | ## Events From 2d3640a3d4e9145ded950d45a0edac5899f691be Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Fri, 15 Mar 2024 17:58:52 +0100 Subject: [PATCH 08/67] feat: introduce base class for optgroup and autocomplete --- .../autocomplete-grid-optgroup.scss | 51 -- .../autocomplete-grid-optgroup.ts | 122 +---- .../autocomplete-grid-optgroup/readme.md | 2 +- .../autocomplete-grid-option.ts | 14 + .../autocomplete-grid/autocomplete-grid.scss | 160 ------ .../autocomplete-grid/autocomplete-grid.ts | 481 ++---------------- .../autocomplete-grid/readme.md | 12 +- src/components/autocomplete/autocomplete.ts | 457 ++--------------- src/components/autocomplete/readme.md | 12 +- .../autocomplete-base-element.scss} | 2 +- .../autocomplete-base-element.ts | 450 ++++++++++++++++ .../base-elements/optgroup-base-element.scss} | 2 +- .../base-elements/optgroup-base-element.ts | 131 +++++ .../core/base-elements/option-base-element.ts | 27 +- src/components/option/optgroup/optgroup.ts | 123 +---- src/components/option/optgroup/readme.md | 2 +- src/components/option/option/option.ts | 16 + 17 files changed, 743 insertions(+), 1321 deletions(-) delete mode 100644 src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss delete mode 100644 src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss rename src/components/{autocomplete/autocomplete.scss => core/base-elements/autocomplete-base-element.scss} (99%) create mode 100644 src/components/core/base-elements/autocomplete-base-element.ts rename src/components/{option/optgroup/optgroup.scss => core/base-elements/optgroup-base-element.scss} (98%) create mode 100644 src/components/core/base-elements/optgroup-base-element.ts diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss deleted file mode 100644 index db9c36e504..0000000000 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.scss +++ /dev/null @@ -1,51 +0,0 @@ -@use '../../core/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -:host { - --sbb-optgroup-divider-display: block; - --sbb-optgroup-divider-spacing: 0; - --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-4x); - --sbb-optgroup-label-padding-inline: var(--sbb-spacing-fixed-4x); - --sbb-optgroup-label-font-size: var(--sbb-typo-scale-0-75x); - --sbb-optgroup-label-color: var(--sbb-color-metal); -} - -:host(:first-child) { - --sbb-optgroup-divider-display: none; - --sbb-optgroup-label-padding-start: var(--sbb-spacing-fixed-2x); -} - -:host([data-negative]) { - --sbb-optgroup-label-color: var(--sbb-color-smoke); -} - -.sbb-optgroup { - margin-block: var(--sbb-spacing-fixed-4x); - margin-inline: var(--sbb-spacing-fixed-4x); -} - -.sbb-optgroup__label { - @include sbb.text-xxs--regular; - - display: flex; - column-gap: var(--sbb-spacing-responsive-xxxs); - color: var(--sbb-optgroup-label-color); - -webkit-text-fill-color: var(--sbb-optgroup-label-color); - padding-inline: var(--sbb-optgroup-label-padding-inline); - padding-block: var(--sbb-optgroup-label-padding-start) var(--sbb-spacing-fixed-2x); -} - -.sbb-optgroup__divider { - display: var(--sbb-optgroup-divider-display); - padding-block: var(--sbb-optgroup-divider-spacing); -} - -// Align the group label to the option label -.sbb-optgroup__icon-space { - // Can be overridden by the 'preserve-icon-space' on the autocomplete - display: var(--sbb-option-icon-container-display, none); - min-width: var(--sbb-size-icon-ui-small); -} diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts index 1e04550fc8..ae515188cb 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/autocomplete-grid-optgroup.ts @@ -1,14 +1,10 @@ -import type { CSSResultGroup, TemplateResult, PropertyValues } from 'lit'; -import { html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement } from 'lit/decorators.js'; -import { isSafari, isValidAttribute, setAttribute } from '../../core/dom'; -import { SbbDisabledMixin, SbbHydrationMixin } from '../../core/mixins'; -import { AgnosticMutationObserver } from '../../core/observers'; +import { SbbOptgroupBaseElement } from '../../core/base-elements/optgroup-base-element'; +import type { SbbAutocompleteGridElement } from '../autocomplete-grid'; import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button'; import type { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; -import style from './autocomplete-grid-optgroup.scss?lit&inline'; import '../../divider'; /** @@ -17,26 +13,8 @@ import '../../divider'; * @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 SbbDisabledMixin( - SbbHydrationMixin(LitElement), -) { - public static override styles: CSSResultGroup = style; - - /** Option group label. */ - @property() public label!: string; - - @state() private _negative = false; - - private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); - - /** - * 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. - */ - private _inertAriaGroups = isSafari(); - - private get _options(): SbbAutocompleteGridOptionElement[] { +export class SbbAutocompleteGridOptgroupElement extends SbbOptgroupBaseElement { + protected get options(): SbbAutocompleteGridOptionElement[] { return Array.from( this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? [], ) as SbbAutocompleteGridOptionElement[]; @@ -48,95 +26,21 @@ export class SbbAutocompleteGridOptgroupElement extends SbbDisabledMixin( ) as SbbAutocompleteGridButtonElement[]; } - public override connectedCallback(): void { - super.connectedCallback(); - this._negativeObserver?.disconnect(); - this._negative = !!this.closest?.(`:is(sbb-autocomplete-grid, sbb-form-field)[negative]`); - this.toggleAttribute('data-negative', this._negative); - - this._negativeObserver.observe(this, { - attributes: true, - attributeFilter: ['data-negative'], - }); - - this._proxyGroupLabelToOptions(); - } - - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - if (changedProperties.has('disabled')) { - this._proxyDisabledToOptions(); - } - if (changedProperties.has('label')) { - this._proxyGroupLabelToOptions(); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._negativeObserver?.disconnect(); + protected getAutocompleteParent(): SbbAutocompleteGridElement { + return this.closest('sbb-autocomplete-grid')!; // fixme } - private _handleSlotchange(): void { - this._proxyDisabledToOptions(); - this._proxyGroupLabelToOptions(); - this._highlightOptions(); + protected setAttributeFromParent(): void { + this.negative = !!this.closest?.(`:is(sbb-autocomplete-grid, sbb-form-field)[negative]`); + this.toggleAttribute('data-negative', this.negative); } - private _proxyGroupLabelToOptions(): void { - if (!this._inertAriaGroups) { - return; - } else if (this.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 el of [...this._options, ...this._buttons]) { + protected override proxyDisabledToOptions(): void { + super.proxyDisabledToOptions(); + for (const el of this._buttons) { el.toggleAttribute('data-group-disabled', this.disabled); } } - - private _highlightOptions(): void { - const autocomplete = this.closest('sbb-autocomplete-grid'); - if (!autocomplete) { - return; - } - const value = autocomplete.triggerElement?.value; - if (!value) { - return; - } - this._options.forEach((opt) => opt.highlight(value)); - } - - private _onNegativeChange(): void { - this._negative = isValidAttribute(this, 'data-negative'); - } - - protected override render(): TemplateResult { - setAttribute(this, 'role', !this._inertAriaGroups ? 'group' : null); - setAttribute(this, 'aria-label', !this._inertAriaGroups && this.label); - setAttribute(this, 'aria-disabled', !this._inertAriaGroups && this.disabled.toString()); - - return html` -
- -
- - - `; - } } declare global { diff --git a/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md b/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md index 974400a3dd..65d8c5b64e 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-optgroup/readme.md @@ -47,8 +47,8 @@ The `sbb-autocomplete-grid-optgroup` is a component . . . | Name | Attribute | Privacy | Type | Default | Description | | ---------- | ---------- | ------- | --------- | ------- | ---------------------------------- | -| `label` | `label` | public | `string` | | Option group label. | | `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `label` | `label` | public | `string` | | Option group label. | ## Slots diff --git a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts index b784ef2487..a0ced89e2e 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts +++ b/src/components/autocomplete-grid/autocomplete-grid-option/autocomplete-grid-option.ts @@ -41,6 +41,20 @@ export class SbbAutocompleteGridOptionElement extends SbbOptionBaseElement { SbbAutocompleteGridOptionElement.events.optionSelected, ); + protected setAttributeFromParent(): void { + const parentGroup = this.closest?.('sbb-autocomplete-grid-optgroup'); + if (parentGroup) { + this.disabledFromGroup = parentGroup.disabled; + this.updateAriaDisabled(); + } + + this.negative = !!this.closest?.( + // :is() selector not possible due to test environment + `sbb-autocomplete-grid[negative],sbb-form-field[negative]`, + ); + this.toggleAttribute('data-group-negative', this.negative); + } + protected selectByClick(event: MouseEvent): void { if (this.disabled || this.disabledFromGroup) { event.stopPropagation(); diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss deleted file mode 100644 index f1c12c5a76..0000000000 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.scss +++ /dev/null @@ -1,160 +0,0 @@ -@use '../../core/styles' as sbb; - -// Default component properties, defined for :host. Properties which can not -// travel the shadow boundary are defined through this mixin -@include sbb.host-component-properties; - -// Fixes the gap between the origin and the overlay by creating conjunction -// corners based on the origin element border radius -@include sbb.overlay-gap-fix; - -:host { - @include sbb.options-panel-overlay-variables; - - --sbb-options-panel-internal-z-index: var(--sbb-autocomplete-z-index, var(--sbb-overlay-z-index)); -} - -:host([negative]) { - @include sbb.options-panel-overlay-negative-variables; -} - -:host(:not([data-state])), -:host([data-state='closed']) { - --sbb-options-panel-visibility: hidden; -} - -:host([data-state='opening']) { - --sbb-options-panel-animation-name: open; -} - -:host([data-state='closing']) { - --sbb-options-panel-animation-name: close; -} - -:host([data-state='opened']), -:host([data-state='opening']) { - --sbb-options-panel-gap-fix-opacity: 1; -} - -:host([data-options-panel-position='below']) { - --sbb-options-panel-animation-transform: translateY( - calc((var(--sbb-options-panel-origin-height) / 2) * -1) - ); -} - -:host([data-options-panel-position='above']) { - --sbb-options-panel-options-border-radius: var(--sbb-options-panel-border-radius) - var(--sbb-options-panel-border-radius) 0 0; - --sbb-options-panel-gap-fix-top: var(--sbb-options-panel-max-height); - --sbb-options-panel-gap-fix-transform: rotate(180deg); - --sbb-options-panel-animation-transform: translateY( - calc(var(--sbb-options-panel-origin-height) / 2) - ); -} - -:host([disable-animation]) { - --sbb-options-panel-animation-duration: 0.1ms; -} - -:host([preserve-icon-space]) { - --sbb-option-icon-container-display: block; -} - -::slotted(sbb-divider) { - margin-block: var(--sbb-spacing-fixed-3x); -} - -.sbb-autocomplete__container { - @include sbb.options-panel-overlay-container; -} - -.sbb-autocomplete__gap-fix { - @include sbb.options-panel-overlay-gap; -} - -.sbb-autocomplete__panel { - @include sbb.options-panel-overlay; - - :host([data-options-panel-position='below']) & { - inset-block-start: calc( - var(--sbb-options-panel-position-y) - var(--sbb-options-panel-origin-height) - ); - } - - :host(:is([data-state='opened'], [data-state='opening'])) & { - @include sbb.shadow-level-5-hard; - } - - :host(:is([data-state='opened'], [data-state='opening'])[negative]) & { - @include sbb.shadow-level-5-hard-negative; - } - - &::before { - :host([data-options-panel-position='below']) & { - display: block; - } - } - - &::after { - :host([data-options-panel-position='above']) & { - display: block; - } - } - - /* stylelint-disable-next-line no-descending-specificity */ - &::before, - &::after { - :host(:is([data-state='opened'], [data-state='opening'])[data-option-panel-origin-borderless]) - & { - @include sbb.shadow-level-5-hard; - } - - :host( - :is( - [data-state='opened'], - [data-state='opening'] - )[data-option-panel-origin-borderless][negative] - ) - & { - @include sbb.shadow-level-5-hard-negative; - } - } -} - -.sbb-autocomplete__wrapper { - overflow: hidden; -} - -.sbb-autocomplete__options { - @include sbb.scrollbar-rules; - @include sbb.optionsOverlay; - - @include sbb.if-forced-colors { - border: var(--sbb-border-width-1x) solid CanvasText; - border-top: none; - } -} - -@keyframes open { - from { - transform: var(--sbb-options-panel-animation-transform); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } -} - -@keyframes close { - from { - transform: translateY(0); - opacity: 1; - } - - to { - transform: var(--sbb-options-panel-animation-transform); - opacity: 0; - } -} diff --git a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index 443b1a8572..fcdfc9c0cd 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/components/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -1,38 +1,25 @@ -import type { CSSResultGroup, TemplateResult, PropertyValues } from 'lit'; -import { html, LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; +import { nothing, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; -import { assignId, getNextElementIndex } from '../../core/a11y'; -import { SbbConnectedAbortController } from '../../core/controllers'; +import { getNextElementIndex } from '../../core/a11y'; +import { SbbAutocompleteBaseElement } from '../../core/base-elements/autocomplete-base-element'; import { hostAttributes } from '../../core/decorators'; -import { - setAttribute, - getDocumentWritingMode, - findReferencedElement, - isSafari, - isValidAttribute, - isBrowser, -} from '../../core/dom'; +import { getDocumentWritingMode, isSafari } from '../../core/dom'; import { EventEmitter } from '../../core/eventing'; -import { SbbHydrationMixin, SbbNegativeMixin } from '../../core/mixins'; -import type { SbbOverlayState } from '../../core/overlay'; -import { - isEventOnElement, - overlayGapFixCorners, - removeAriaComboBoxAttributes, - setAriaComboBoxAttributes, - setOverlayPosition, -} from '../../core/overlay'; -import type { SbbOptionElement, SbbOptGroupElement } from '../../option'; +import { setAriaComboBoxAttributes } from '../../core/overlay'; +import type { SbbOptGroupElement, SbbOptionElement } from '../../option'; import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button'; import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option'; import type { SbbAutocompleteGridRowElement } from '../autocomplete-grid-row'; -import style from './autocomplete-grid.scss?lit&inline'; - 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. * @@ -48,9 +35,9 @@ let nextId = 0; @customElement('sbb-autocomplete-grid') @hostAttributes({ dir: getDocumentWritingMode(), + role: ariaRoleOnHost ? 'grid' : null, }) -export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { - public static override styles: CSSResultGroup = style; +export class SbbAutocompleteGridElement extends SbbAutocompleteBaseElement { public static readonly events = { willOpen: 'willOpen', didOpen: 'didOpen', @@ -58,93 +45,35 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix didClose: 'didClose', } as const; - /** - * 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 animation is disabled. */ - @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) - public disableAnimation = false; - - /** Whether the icon space is preserved when no icon is set. */ - @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) - public preserveIconSpace?: boolean; - - /** The state of the autocomplete. */ - @state() private _state: SbbOverlayState = 'closed'; - /** Emits whenever the `sbb-autocomplete` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter( + protected willOpen: EventEmitter = new EventEmitter( this, SbbAutocompleteGridElement.events.willOpen, ); /** Emits whenever the `sbb-autocomplete` is opened. */ - private _didOpen: EventEmitter = new EventEmitter( + protected didOpen: EventEmitter = new EventEmitter( this, SbbAutocompleteGridElement.events.didOpen, ); /** Emits whenever the `sbb-autocomplete` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter( + protected willClose: EventEmitter = new EventEmitter( this, SbbAutocompleteGridElement.events.willClose, ); /** Emits whenever the `sbb-autocomplete` is closed. */ - private _didClose: EventEmitter = new EventEmitter( + protected didClose: EventEmitter = new EventEmitter( this, SbbAutocompleteGridElement.events.didClose, ); - 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-grid-${++nextId}`; + protected overlayId = `sbb-autocomplete-grid-${++nextId}`; private _activeItemIndex = -1; private _activeColumnIndex = 0; - private _didLoad = false; - private _isPointerDownEventOnMenu: boolean = false; - private _abort = new SbbConnectedAbortController(this); - - /** - * 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. - */ - private _ariaRoleOnHost = isSafari(); - - /** The autocomplete should inherit 'readonly' state from the trigger. */ - private get _readonly(): boolean { - return !!this.triggerElement && isValidAttribute(this.triggerElement, 'readonly'); - } - private get _options(): SbbAutocompleteGridOptionElement[] { + protected get options(): SbbAutocompleteGridOptionElement[] { return Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-option') ?? []); } @@ -153,66 +82,15 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix return Array.from(this.querySelectorAll?.('sbb-autocomplete-grid-row') ?? []); } - /** 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 { + protected onOptionSelected(event: CustomEvent): void { const target = event.target as SbbAutocompleteGridOptionElement; if (!target.selected) { return; } // Deselect the previous options - this._options + this.options .filter((option) => option.id !== target.id && option.selected) .forEach((option) => (option.selected = false)); @@ -228,7 +106,7 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix this.close(); } - private _onOptionClick(event: MouseEvent): void { + protected onOptionClick(event: MouseEvent): void { if ( (event.target as Element).tagName !== 'SBB-AUTOCOMPLETE-GRID-OPTION' || (event.target as SbbOptionElement).disabled @@ -240,50 +118,15 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix public override connectedCallback(): void { super.connectedCallback(); - const signal = this._abort.signal; - const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); - - if (formField) { - this.negative = isValidAttribute(formField, 'negative'); - } - - if (this._didLoad) { - this._componentSetup(); - } - this._syncNegative(); - + const signal = this.abort.signal; this.addEventListener( 'autocompleteOptionSelectionChange', - (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 _handleSlotchange(): void { - this._highlightOptions(this.triggerElement?.value); } - private _syncNegative(): void { + protected syncNegative(): void { this.querySelectorAll?.('sbb-divider').forEach((divider) => (divider.negative = this.negative)); this.querySelectorAll?.( @@ -295,210 +138,8 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix ).forEach((element) => (element.negative = this.negative)); } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - } - - private _componentSetup(): void { - if (!isBrowser()) { - 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 { - if (this._state !== 'opened') { + protected openedPanelKeyboardInteraction(event: KeyboardEvent): void { + if (this.state !== 'opened') { return; } @@ -509,13 +150,13 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix break; case 'Enter': - this._selectByKeyboard(event); + this.selectByKeyboard(event); break; // FIXME case 'ArrowDown': case 'ArrowUp': - this._setNextActiveOption(event); + this.setNextActiveOption(event); break; // FIXME @@ -527,7 +168,7 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix } // TODO - private _selectByKeyboard(event: KeyboardEvent): void { + protected selectByKeyboard(event: KeyboardEvent): void { if (this._activeColumnIndex !== 0) { ( this._row[this._activeItemIndex].querySelectorAll( @@ -535,16 +176,16 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix )[this._activeColumnIndex] as SbbAutocompleteGridButtonElement ).dispatchClick(event); } else { - const activeOption = this._options[this._activeItemIndex]; + const activeOption = this.options[this._activeItemIndex]; if (activeOption) { activeOption.setSelectedViaUserInteraction(true); } } } - private _setNextActiveOption(event: KeyboardEvent): void { - const filteredOptions = this._options.filter( - (opt) => !opt.disabled && !isValidAttribute(opt, 'data-group-disabled'), + 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 @@ -583,7 +224,7 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix this._row[this._activeItemIndex].querySelectorAll< SbbAutocompleteGridOptionElement | SbbAutocompleteGridButtonElement >('sbb-autocomplete-grid-option, sbb-autocomplete-grid-button'), - ).filter((el) => !el.disabled && !isValidAttribute(el, 'data-group-disabled')); + ).filter((el) => !el.disabled && !el.hasAttribute('data-group-disabled')); const next: number = getNextElementIndex(event, this._activeColumnIndex, elementsInRow.length); if (isNaN(next)) { return; @@ -609,8 +250,8 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix } // FIXME - private _resetActiveElement(): void { - const activeElement = this._options[this._activeItemIndex]; + protected resetActiveElement(): void { + const activeElement = this.options[this._activeItemIndex]; if (activeElement) { activeElement.active = false; @@ -625,51 +266,17 @@ export class SbbAutocompleteGridElement extends SbbNegativeMixin(SbbHydrationMix this.triggerElement?.removeAttribute('aria-activedescendant'); } - /** 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 _setTriggerAttributes(element: HTMLInputElement): void { - setAriaComboBoxAttributes(element, this.id || this._overlayId, false, 'grid'); + protected setTriggerAttributes(element: HTMLInputElement): void { + setAriaComboBoxAttributes(element, this.id || this.overlayId, false, 'grid'); } - private _removeTriggerAttributes(element?: HTMLInputElement): void { - removeAriaComboBoxAttributes(element); + protected setRoleOnInnerPanel(): string | typeof nothing { + return !ariaRoleOnHost ? 'grid' : nothing; } // FIXME protected override render(): TemplateResult { - setAttribute(this, 'data-state', this._state); - setAttribute(this, 'role', this._ariaRoleOnHost ? 'grid' : null); - this._ariaRoleOnHost && assignId(() => this._overlayId)(this); - - return html` -
-
-
${overlayGapFixCorners()}
-
(this._overlay = overlayRef as HTMLElement))} - > -
-
(this._optionContainer = containerRef as HTMLElement))} - > - -
-
-
-
- `; + return super.render(); } } diff --git a/src/components/autocomplete-grid/autocomplete-grid/readme.md b/src/components/autocomplete-grid/autocomplete-grid/readme.md index 38c7e41af9..1787e5fb8f 100644 --- a/src/components/autocomplete-grid/autocomplete-grid/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid/readme.md @@ -47,20 +47,20 @@ The `sbb-autocomplete-grid` is a component . . . | Name | Attribute | Privacy | Type | Default | Description | | ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | +| `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. | | `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. | | `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is disabled. | | `preserveIconSpace` | `preserve-icon-space` | public | `boolean \| undefined` | | Whether the icon space is preserved when no icon is set. | | `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | | `triggerElement` | - | public | `HTMLInputElement \| undefined` | | Returns the trigger element. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ------------------------ | ---------- | ------ | -------------- | -| `open` | public | Opens the autocomplete. | | `void` | | -| `close` | public | Closes the autocomplete. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------ | ---------- | ------ | -------------------------- | +| `open` | public | Opens the autocomplete. | | `void` | SbbAutocompleteBaseElement | +| `close` | public | Closes the autocomplete. | | `void` | SbbAutocompleteBaseElement | ## Events diff --git a/src/components/autocomplete/autocomplete.ts b/src/components/autocomplete/autocomplete.ts index e84836e9b8..22470c58be 100644 --- a/src/components/autocomplete/autocomplete.ts +++ b/src/components/autocomplete/autocomplete.ts @@ -1,26 +1,14 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; +import { nothing, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; import { getNextElementIndex } from '../core/a11y.js'; -import { SbbConnectedAbortController } from '../core/controllers.js'; +import { SbbAutocompleteBaseElement } from '../core/base-elements/autocomplete-base-element.js'; import { hostAttributes } from '../core/decorators.js'; -import { findReferencedElement, getDocumentWritingMode, isBrowser, isSafari } from '../core/dom.js'; +import { getDocumentWritingMode, isSafari } from '../core/dom.js'; import { EventEmitter } from '../core/eventing.js'; -import type { SbbOpenedClosedState } from '../core/interfaces.js'; -import { SbbHydrationMixin, SbbNegativeMixin } from '../core/mixins.js'; -import { - isEventOnElement, - overlayGapFixCorners, - removeAriaComboBoxAttributes, - setAriaComboBoxAttributes, - setOverlayPosition, -} from '../core/overlay.js'; +import { setAriaComboBoxAttributes } from '../core/overlay.js'; import type { SbbOptGroupElement, SbbOptionElement } from '../option.js'; -import style from './autocomplete.scss?lit&inline'; - let nextId = 0; /** @@ -46,8 +34,7 @@ const ariaRoleOnHost = isSafari(); dir: getDocumentWritingMode(), role: ariaRoleOnHost ? 'listbox' : null, }) -export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { - public static override styles: CSSResultGroup = style; +export class SbbAutocompleteElement extends SbbAutocompleteBaseElement { public static readonly events = { willOpen: 'willOpen', didOpen: 'didOpen', @@ -55,145 +42,37 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L didClose: 'didClose', } as const; - /** - * 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 animation is disabled. */ - @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) - public disableAnimation = false; - - /** Whether the icon space is preserved when no icon is set. */ - @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) - public preserveIconSpace?: boolean; - - /* The state of the autocomplete. */ - private set _state(state: SbbOpenedClosedState) { - this.setAttribute('data-state', state); - } - private get _state(): SbbOpenedClosedState { - return this.getAttribute('data-state') as SbbOpenedClosedState; - } - /** Emits whenever the `sbb-autocomplete` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.willOpen); + protected willOpen: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.willOpen); /** Emits whenever the `sbb-autocomplete` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.didOpen); + protected didOpen: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.didOpen); /** Emits whenever the `sbb-autocomplete` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter( + protected willClose: EventEmitter = new EventEmitter( this, SbbAutocompleteElement.events.willClose, ); /** Emits whenever the `sbb-autocomplete` is closed. */ - private _didClose: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.didClose); - - 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; + protected didClose: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.didClose); - /** 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}`; + protected overlayId = `sbb-autocomplete-${++nextId}`; 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 { + protected onOptionSelected(event: CustomEvent): void { const target = event.target as SbbOptionElement; if (!target.selected) { return; } // Deselect the previous options - this._options + this.options .filter((option) => option.id !== target.id && option.selected) .forEach((option) => (option.selected = false)); @@ -209,7 +88,7 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L this.close(); } - private _onOptionClick(event: MouseEvent): void { + protected onOptionClick(event: MouseEvent): void { if ( (event.target as Element).tagName !== 'SBB-OPTION' || (event.target as SbbOptionElement).disabled @@ -222,50 +101,17 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L public override connectedCallback(): void { super.connectedCallback(); if (ariaRoleOnHost) { - this.id ||= this._overlayId; - } - - this._state ||= 'closed'; - const signal = this._abort.signal; - const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); - - if (formField) { - this.negative = formField.hasAttribute('negative'); + this.id ||= this.overlayId; } - - 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?.( @@ -273,210 +119,8 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L ).forEach((element) => element.toggleAttribute('data-negative', this.negative)); } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._triggerEventsController?.abort(); - this._openPanelEventsController?.abort(); - } - - private _componentSetup(): void { - if (!isBrowser()) { - 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 { - if (this._state !== 'opened') { + protected openedPanelKeyboardInteraction(event: KeyboardEvent): void { + if (this.state !== 'opened') { return; } @@ -487,26 +131,26 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L 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'), ); @@ -526,8 +170,8 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L 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; @@ -536,49 +180,16 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L this.triggerElement?.removeAttribute('aria-activedescendant'); } - /** 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 _setTriggerAttributes(element: HTMLInputElement): void { - setAriaComboBoxAttributes(element, this.id || this._overlayId, false); - } - - private _removeTriggerAttributes(element?: HTMLInputElement): void { - removeAriaComboBoxAttributes(element); + protected setTriggerAttributes(element: HTMLInputElement): void { + setAriaComboBoxAttributes(element, this.id || this.overlayId, false); } - private _handleSlotchange(): void { - this._highlightOptions(this.triggerElement?.value); + protected setRoleOnInnerPanel(): string | typeof nothing { + return !ariaRoleOnHost ? 'listbox' : nothing; } protected override render(): TemplateResult { - return html` -
-
-
${overlayGapFixCorners()}
-
(this._overlay = overlayRef as HTMLElement))} - > -
-
(this._optionContainer = containerRef as HTMLElement))} - > - -
-
-
-
- `; + return super.render(); } } diff --git a/src/components/autocomplete/readme.md b/src/components/autocomplete/readme.md index 83901da3ee..9099a7896f 100644 --- a/src/components/autocomplete/readme.md +++ b/src/components/autocomplete/readme.md @@ -99,20 +99,20 @@ using `aria-activedescendant` to support navigation though the autocomplete opti | Name | Attribute | Privacy | Type | Default | Description | | ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | +| `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. | | `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. | | `disableAnimation` | `disable-animation` | public | `boolean` | `false` | Whether the animation is disabled. | | `preserveIconSpace` | `preserve-icon-space` | public | `boolean \| undefined` | | Whether the icon space is preserved when no icon is set. | | `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | | `triggerElement` | - | public | `HTMLInputElement \| undefined` | | Returns the trigger element. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ------------------------ | ---------- | ------ | -------------- | -| `open` | public | Opens the autocomplete. | | `void` | | -| `close` | public | Closes the autocomplete. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------ | ---------- | ------ | -------------------------- | +| `open` | public | Opens the autocomplete. | | `void` | SbbAutocompleteBaseElement | +| `close` | public | Closes the autocomplete. | | `void` | SbbAutocompleteBaseElement | ## Events diff --git a/src/components/autocomplete/autocomplete.scss b/src/components/core/base-elements/autocomplete-base-element.scss similarity index 99% rename from src/components/autocomplete/autocomplete.scss rename to src/components/core/base-elements/autocomplete-base-element.scss index 219254c648..dba51568e7 100644 --- a/src/components/autocomplete/autocomplete.scss +++ b/src/components/core/base-elements/autocomplete-base-element.scss @@ -1,4 +1,4 @@ -@use '../core/styles' as sbb; +@use '../styles/index' 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; diff --git a/src/components/core/base-elements/autocomplete-base-element.ts b/src/components/core/base-elements/autocomplete-base-element.ts new file mode 100644 index 0000000000..ef27312320 --- /dev/null +++ b/src/components/core/base-elements/autocomplete-base-element.ts @@ -0,0 +1,450 @@ +import { + type CSSResultGroup, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { SbbConnectedAbortController } from '../controllers'; +import { findReferencedElement, isBrowser, isSafari } from '../dom'; +import type { EventEmitter } from '../eventing'; +import type { SbbOpenedClosedState } from '../interfaces'; +import { SbbHydrationMixin } from '../mixins'; +import { SbbNegativeMixin } from '../mixins/negative-mixin'; +import { + isEventOnElement, + overlayGapFixCorners, + removeAriaComboBoxAttributes, + setOverlayPosition, +} from '../overlay'; + +import style from './autocomplete-base-element.scss?lit&inline'; +import type { SbbOptionBaseElement } from './option-base-element'; + +/** + * 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(LitElement), +) { + 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 animation is disabled. */ + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; + + /** Whether the icon space is preserved when no icon is set. */ + @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) + public preserveIconSpace?: boolean; + + /* The state of the autocomplete. */ + protected set state(state: SbbOpenedClosedState) { + this.setAttribute('data-state', state); + } + protected get state(): SbbOpenedClosedState { + return this.getAttribute('data-state') as SbbOpenedClosedState; + } + + /** Emits whenever the `sbb-autocomplete` starts the opening transition. */ + protected abstract willOpen: EventEmitter; + + /** Emits whenever the `sbb-autocomplete` is opened. */ + protected abstract didOpen: EventEmitter; + + /** Emits whenever the `sbb-autocomplete` begins the closing transition. */ + protected abstract willClose: EventEmitter; + + /** Emits whenever the `sbb-autocomplete` is closed. */ + protected abstract didClose: EventEmitter; + + /** 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 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 setRoleOnInnerPanel(): string | typeof nothing; + protected abstract openedPanelKeyboardInteraction(event: KeyboardEvent): void; + protected abstract selectByKeyboard(event: KeyboardEvent): void; + protected abstract setNextActiveOption(event: KeyboardEvent): void; + protected abstract resetActiveElement(): void; + /** When an option is selected, update the input value and close the autocomplete. */ + protected abstract onOptionSelected(event: CustomEvent): 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(); + + this.state ||= 'closed'; + 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; + } + + private _handleSlotchange(): void { + this._highlightOptions(this.triggerElement?.value); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._triggerEventsController?.abort(); + this._openPanelEventsController?.abort(); + } + + /** 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 (!isBrowser()) { + 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); + } + + // FIXME + protected override render(): TemplateResult { + setAttribute(this, 'data-state', this.state); + + return html` +
+
+
${overlayGapFixCorners()}
+
(this._overlay = overlayRef as HTMLElement))} + > +
+
(this._optionContainer = containerRef as HTMLElement))} + > + +
+
+
+
+ `; + } +} diff --git a/src/components/option/optgroup/optgroup.scss b/src/components/core/base-elements/optgroup-base-element.scss similarity index 98% rename from src/components/option/optgroup/optgroup.scss rename to src/components/core/base-elements/optgroup-base-element.scss index f322973695..868b168be2 100644 --- a/src/components/option/optgroup/optgroup.scss +++ b/src/components/core/base-elements/optgroup-base-element.scss @@ -1,4 +1,4 @@ -@use '../../core/styles' as sbb; +@use '../styles/index' 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; diff --git a/src/components/core/base-elements/optgroup-base-element.ts b/src/components/core/base-elements/optgroup-base-element.ts new file mode 100644 index 0000000000..d109c7abfb --- /dev/null +++ b/src/components/core/base-elements/optgroup-base-element.ts @@ -0,0 +1,131 @@ +import { + type CSSResultGroup, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { SbbAutocompleteElement } from '../../autocomplete'; +import type { SbbAutocompleteGridElement } from '../../autocomplete-grid'; +import { isSafari, setOrRemoveAttribute } from '../dom'; +import { SbbDisabledMixin, SbbHydrationMixin } from '../mixins'; +import { AgnosticMutationObserver } from '../observers'; + +import style from './optgroup-base-element.scss?lit&inline'; +import type { SbbOptionBaseElement } from './option-base-element'; + +/** + * 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(); + +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; + + private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); + + protected abstract get options(): SbbOptionBaseElement[]; + protected abstract setAttributeFromParent(): void; + protected abstract getAutocompleteParent(): SbbAutocompleteGridElement | SbbAutocompleteElement; // fixme autocomplete base element + + 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 (!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 (!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/components/core/base-elements/option-base-element.ts b/src/components/core/base-elements/option-base-element.ts index 84962413ec..6f0b45eba2 100644 --- a/src/components/core/base-elements/option-base-element.ts +++ b/src/components/core/base-elements/option-base-element.ts @@ -74,6 +74,7 @@ export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameM private _abort = new SbbConnectedAbortController(this); protected abstract selectByClick(event: MouseEvent): void; protected abstract setupHighlightHandler(event: Event): void; + protected abstract setAttributeFromParent(): void; protected updateDisableHighlight(disabled: boolean): void { this.disableLabelHighlight = disabled; @@ -112,23 +113,10 @@ export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameM public override connectedCallback(): void { super.connectedCallback(); - this.id ||= `${this.optionId}-${nextId++}`; - - const signal = this._abort.signal; - const parentGroup = this.closest?.('sbb-autocomplete-grid-optgroup'); - if (parentGroup) { - this.disabledFromGroup = parentGroup.disabled; - this._updateAriaDisabled(); - } + this.setAttributeFromParent(); this._optionAttributeObserver.observe(this, optionObserverConfig); - - this.negative = !!this.closest?.( - // :is() selector not possible due to test environment - `sbb-autocomplete-grid[negative],sbb-form-field[negative]`, - ); - this.toggleAttribute('data-group-negative', this.negative); - + const signal = this._abort.signal; this.addEventListener('click', (e: MouseEvent) => this.selectByClick(e), { signal, passive: true, @@ -140,7 +128,7 @@ export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameM if (changedProperties.has('disabled')) { setOrRemoveAttribute(this, 'tabindex', isAndroid() && !this.disabled && 0); - this._updateAriaDisabled(); + this.updateAriaDisabled(); } } @@ -156,12 +144,13 @@ export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameM this._optionAttributeObserver.disconnect(); } - private _updateAriaDisabled(): void { + protected updateAriaDisabled(): void { setOrRemoveAttribute( this, 'aria-disabled', this.disabled || this.disabledFromGroup ? 'true' : null, - ); } + ); + } private _updateAriaSelected(): void { this.setAttribute('aria-selected', `${this.selected}`); @@ -172,7 +161,7 @@ export abstract class SbbOptionBaseElement extends SbbDisabledMixin(SbbIconNameM for (const mutation of mutationsList) { if (mutation.attributeName === 'data-group-disabled') { this.disabledFromGroup = this.hasAttribute('data-group-disabled'); - this._updateAriaDisabled(); + this.updateAriaDisabled(); } else if (mutation.attributeName === 'data-negative') { this.negative = this.hasAttribute('data-negative'); } diff --git a/src/components/option/optgroup/optgroup.ts b/src/components/option/optgroup/optgroup.ts index 4742f4fd6d..ffabbd6018 100644 --- a/src/components/option/optgroup/optgroup.ts +++ b/src/components/option/optgroup/optgroup.ts @@ -1,15 +1,12 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import type { SbbAutocompleteElement } from '../../autocomplete.js'; +import { SbbOptgroupBaseElement } from '../../core/base-elements/optgroup-base-element.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 { isSafari } from '../../core/dom.js'; import type { SbbOptionElement } from '../option.js'; -import style from './optgroup.scss?lit&inline'; - import '../../divider.js'; /** @@ -26,56 +23,25 @@ const inertAriaGroups = isSafari(); */ @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; - - private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); +export class SbbOptGroupElement extends SbbOptgroupBaseElement { - private get _options(): SbbOptionElement[] { + protected get options(): SbbOptionElement[] { return Array.from(this.querySelectorAll?.('sbb-option') ?? []) as SbbOptionElement[]; } - 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 getAutocompleteParent(): SbbAutocompleteElement { + return this.closest('sbb-autocomplete')!; // fixme } - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - if (changedProperties.has('disabled')) { - if (!inertAriaGroups) { - this.setAttribute('aria-disabled', this.disabled.toString()); - } - - this._proxyDisabledToOptions(); - } - if (changedProperties.has('label')) { - this._proxyGroupLabelToOptions(); - } + protected setAttributeFromParent(): void { + this.negative = !!this.closest?.(`:is(sbb-autocomplete, sbb-select, sbb-form-field)[negative]`); + this.toggleAttribute('data-negative', this.negative); } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._negativeObserver?.disconnect(); + public override connectedCallback(): void { + super.connectedCallback(); + this.toggleAttribute('data-multiple', !!this.closest('sbb-select[multiple]')); + this._setVariantByContext(); } private _setVariantByContext(): void { @@ -86,63 +52,8 @@ export class SbbOptGroupElement extends SbbDisabledMixin(SbbHydrationMixin(LitEl } } - private _handleSlotchange(): void { - this._proxyDisabledToOptions(); - this._proxyGroupLabelToOptions(); - this._highlightOptions(); - } - - private _proxyGroupLabelToOptions(): void { - if (!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` -
- -
- - - `; + return super.render(); } } diff --git a/src/components/option/optgroup/readme.md b/src/components/option/optgroup/readme.md index 4a6530833b..bfaf2c4683 100644 --- a/src/components/option/optgroup/readme.md +++ b/src/components/option/optgroup/readme.md @@ -35,8 +35,8 @@ The component has a `disabled` property which sets all the `sbb-option` in the g | Name | Attribute | Privacy | Type | Default | Description | | ---------- | ---------- | ------- | --------- | ------- | ---------------------------------- | -| `label` | `label` | public | `string` | | Option group label. | | `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `label` | `label` | public | `string` | | Option group label. | ## Slots diff --git a/src/components/option/option/option.ts b/src/components/option/option/option.ts index 20bc710c57..9bf8e280fd 100644 --- a/src/components/option/option/option.ts +++ b/src/components/option/option/option.ts @@ -65,6 +65,22 @@ export class SbbOptionElement extends SbbOptionBaseElement { return !!this.closest?.('sbb-select[multiple]'); } + protected setAttributeFromParent(): void { + const parentGroup = this.closest?.('sbb-optgroup'); + if (parentGroup) { + this.disabledFromGroup = parentGroup.disabled; + this.updateAriaDisabled(); + } + + this.negative = !!this.closest?.( + // :is() selector not possible due to test environment + `sbb-autocomplete[negative],sbb-form-field[negative]`, + ); + this.toggleAttribute('data-group-negative', this.negative); + + this.toggleAttribute('data-multiple', this._isMultiple); + } + protected selectByClick(event: MouseEvent): void { if (this.disabled || this.disabledFromGroup) { event.stopPropagation(); From 6e5469dd8e3197b1856b1ac6c865aecaed789aaa Mon Sep 17 00:00:00 2001 From: Davide Mininni Date: Mon, 18 Mar 2024 18:30:53 +0100 Subject: [PATCH 09/67] docs: add docs --- .../autocomplete-grid-actions/readme.md | 57 ++++---- .../autocomplete-grid-button/readme.md | 86 +++++++++--- .../autocomplete-grid-optgroup/readme.md | 83 +++++++----- .../autocomplete-grid-option/readme.md | 81 ++++++++--- .../autocomplete-grid-row/readme.md | 56 ++++---- .../autocomplete-grid/readme.md | 126 +++++++++++++++--- src/components/option/option/readme.md | 2 +- 7 files changed, 332 insertions(+), 159 deletions(-) diff --git a/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md index 9ce2d05e88..e10ceb32da 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-actions/readme.md @@ -1,45 +1,36 @@ -> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
-> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
-> For the examples, use triple backticks with file extension (` ```html ``` `).
-> The following list of paragraphs is only suggested; remove, create and adapt as needed. - -The `sbb-autocomplete-grid-actions` is a component . . . +The `sbb-autocomplete-grid-actions` component wraps one of more [sbb-autocomplete-grid-button](/docs/components-sbb-autocomplete-grid-sbb-autocomplete-grid-button--docs) +inside a [sbb-autocomplete-grid](/docs/components-sbb-autocomplete-grid-sbb-autocomplete-grid--docs) ```html - + + + + + Option 1 + + + + + + Option 2 + + + + + + ``` ## Slots -> Describe slot naming and usage and provide an example of slotted content. - -## States - -> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. - -## Style - -> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. - -## Interactions - -> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. - -## Events - -> Describe events triggered by the component and possibly how to get information from the payload. - -## Keyboard interaction - -> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. - -| Keyboard | Action | -| -------------- | ------------- | -| Key | What it does. | +The component has an unnamed slot which is used to project the `sbb-autocomplete-grid-buttons`. ## Accessibility -> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. +The `sbb-autocomplete-grid` follows the combobox `grid` pattern; +this means that the `sbb-autocomplete-grid-actions` has a `gridcell` role and its child would receive an `id` +based on the `sbb-autocomplete-grid-actions`'s `id`, +which is needed to correctly set the `aria-activedescendant` on the related `input`. diff --git a/src/components/autocomplete-grid/autocomplete-grid-button/readme.md b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md index 42730034dd..86771577c6 100644 --- a/src/components/autocomplete-grid/autocomplete-grid-button/readme.md +++ b/src/components/autocomplete-grid/autocomplete-grid-button/readme.md @@ -1,45 +1,89 @@ -> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
-> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
-> For the examples, use triple backticks with file extension (` ```html ``` `).
-> The following list of paragraphs is only suggested; remove, create and adapt as needed. - -The `sbb-autocomplete-grid-button` is a component . . . +The `sbb-autocomplete-grid-button` component provides the same functionality as a native icon-only `