diff --git a/src/components/sbb-autocomplete/index.ts b/src/components/sbb-autocomplete/index.ts new file mode 100644 index 0000000000..4cb64fc98a --- /dev/null +++ b/src/components/sbb-autocomplete/index.ts @@ -0,0 +1 @@ +export * from './sbb-autocomplete'; diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts b/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts index 9ca3ec1eb3..43d91b828a 100644 --- a/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts +++ b/src/components/sbb-autocomplete/sbb-autocomplete.e2e.ts @@ -1,17 +1,22 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import events from './sbb-autocomplete.events'; -import optionEvents from '../sbb-option/sbb-option.events'; -import { waitForCondition } from '../../global/testing'; +import { events } from './sbb-autocomplete'; +import { events as optionEvents } from '../sbb-option'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { sendKeys, sendMouse } from '@web/test-runner-commands'; +import { EventSpy } from '../../global/testing/event-spy'; +import { SbbAutocomplete } from './sbb-autocomplete'; +import { SbbFormField } from '../sbb-form-field'; +import '../sbb-option'; describe('sbb-autocomplete', () => { - let element: E2EElement, formField: E2EElement, input: E2EElement, page: E2EPage; + let element: SbbAutocomplete, formField: SbbFormField, input: HTMLInputElement; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` + formField = await fixture(html` - + 1 2 3 @@ -19,172 +24,157 @@ describe('sbb-autocomplete', () => { `); - - formField = await page.find('sbb-form-field'); - input = await page.find('input'); - element = await page.find('sbb-autocomplete'); + input = formField.querySelector('input'); + element = formField.querySelector('sbb-autocomplete'); }); it('renders and sets the correct attributes', () => { - expect(formField).toHaveClass('hydrated'); - expect(element).toHaveClass('hydrated'); - - expect(element).not.toHaveAttribute('autocomplete-origin-borderless'); - - expect(input).toEqualAttribute('autocomplete', 'off'); - expect(input).toEqualAttribute('role', 'combobox'); - expect(input).toEqualAttribute('aria-autocomplete', 'list'); - expect(input).toEqualAttribute('aria-haspopup', 'listbox'); - expect(input).toEqualAttribute('aria-controls', 'myAutocomplete'); - expect(input).toEqualAttribute('aria-owns', 'myAutocomplete'); - expect(input).toEqualAttribute('aria-expanded', 'false'); + assert.instanceOf(formField, SbbFormField); + assert.instanceOf(element, SbbAutocomplete); + + expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + + expect(input).to.have.attribute('autocomplete', 'off'); + expect(input).to.have.attribute('role', 'combobox'); + expect(input).to.have.attribute('aria-autocomplete', 'list'); + expect(input).to.have.attribute('aria-haspopup', 'listbox'); + expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); + expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); + expect(input).to.have.attribute('aria-expanded', 'false'); }); it('opens and closes with mouse and keyboard', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.willClose); + const didCloseEventSpy = new EventSpy(events.didClose); - await input.focus(); - await page.waitForChanges(); + input.click(); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('true'); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'true'); - await element.press('Escape'); - await page.waitForChanges(); + await sendKeys({ press: 'Escape' }); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + expect(didCloseEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'false'); - await element.press('ArrowDown'); - await page.waitForChanges(); + await sendKeys({ press: 'ArrowDown' }); await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(2); await waitForCondition(() => didOpenEventSpy.events.length === 2); - expect(didOpenEventSpy).toHaveReceivedEventTimes(2); - expect(input.getAttribute('aria-expanded')).toEqual('true'); + expect(didOpenEventSpy.count).to.be.equal(2); + expect(input).to.have.attribute('aria-expanded', 'true'); - await element.press('Tab'); - await page.waitForChanges(); + await sendKeys({ press: 'Tab' }); await waitForCondition(() => willCloseEventSpy.events.length === 2); - expect(willCloseEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(2); await waitForCondition(() => didCloseEventSpy.events.length === 2); - expect(didCloseEventSpy).toHaveReceivedEventTimes(2); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + expect(didCloseEventSpy.count).to.be.equal(2); + expect(input).to.have.attribute('aria-expanded', 'false'); - await input.click(); - await page.waitForChanges(); + input.click(); await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(3); await waitForCondition(() => didOpenEventSpy.events.length === 3); - expect(didOpenEventSpy).toHaveReceivedEventTimes(3); - expect(input.getAttribute('aria-expanded')).toEqual('true'); + expect(didOpenEventSpy.count).to.be.equal(3); + expect(input).to.have.attribute('aria-expanded', 'true'); + + // Simulate backdrop click + sendMouse({ type: 'click', position: [formField.offsetWidth + 25, 25] }); - const button = await page.find('button'); - await button.click(); await waitForCondition(() => willCloseEventSpy.events.length === 3); - expect(willCloseEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(3); await waitForCondition(() => didCloseEventSpy.events.length === 3); - expect(didCloseEventSpy).toHaveReceivedEventTimes(3); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + expect(didCloseEventSpy.count).to.be.equal(3); + expect(input).to.have.attribute('aria-expanded', 'false'); }); it('select by mouse', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const optionSelectedEventSpy = await page.spyOnEvent(optionEvents.optionSelected); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const optionSelectedEventSpy = new EventSpy(optionEvents.optionSelected); - await input.focus(); - await page.waitForChanges(); + input.focus(); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); + expect(didOpenEventSpy.count).to.be.equal(1); - await element.press('ArrowDown'); - await element.press('ArrowDown'); - await page.waitForChanges(); - await element.press('Enter'); - await page.waitForChanges(); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + await waitForLitRender(element); - expect(optionSelectedEventSpy).toHaveReceivedEventTimes(1); - expect(optionSelectedEventSpy.firstEvent.target.id).toBe('option-2'); + expect(optionSelectedEventSpy.count).to.be.equal(1); + expect(optionSelectedEventSpy.firstEvent.target).to.have.property('id', 'option-2'); }); it('opens and select with keyboard', async () => { - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const optionSelectedEventSpy = await page.spyOnEvent(optionEvents.optionSelected); - await input.focus(); - await page.waitForChanges(); + const didOpenEventSpy = new EventSpy(events.didOpen); + const didCloseEventSpy = new EventSpy(events.didClose); + const optionSelectedEventSpy = new EventSpy(optionEvents.optionSelected); + const optOne = element.querySelector('#option-1'); + const optTwo = element.querySelector('#option-2'); + input.focus(); + await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - - await element.press('ArrowDown'); - await page.waitForChanges(); - await element.press('ArrowDown'); - await page.waitForChanges(); - const optOne = await page.find('sbb-autocomplete > sbb-option#option-1'); - expect(await optOne.getProperty('active')).toEqual(false); - expect(await optOne.getProperty('selected')).toEqual(false); - const optTwo = await page.find('sbb-autocomplete > sbb-option#option-2'); - expect(await optTwo.getProperty('active')).toEqual(true); - expect(await optTwo.getProperty('selected')).toEqual(false); - expect(input.getAttribute('aria-activedescendant')).toEqual('option-2'); - - await element.press('Enter'); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(optOne).not.to.have.attribute('active'); + expect(optOne).not.to.have.attribute('selected'); + expect(optTwo).to.have.attribute('active'); + expect(optTwo).not.to.have.attribute('selected'); + expect(input).to.have.attribute('aria-activedescendant', 'option-2'); + + await sendKeys({ press: 'Enter' }); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(await optTwo.getProperty('active')).toEqual(false); - expect(await optTwo.getProperty('selected')).toEqual(true); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - expect(optionSelectedEventSpy).toHaveReceivedEventTimes(1); - expect(input.getAttribute('aria-expanded')).toEqual('false'); - expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(didCloseEventSpy.count).to.be.equal(1); + + expect(optTwo).not.to.have.attribute('active'); + expect(optTwo).to.have.attribute('selected'); + expect(optionSelectedEventSpy.count).to.be.equal(1); + expect(input).to.have.attribute('aria-expanded', 'false'); + expect(input).not.to.have.attribute('aria-activedescendant'); }); it('should stay closed when disabled', async () => { - await page.$eval('input', (e) => e.setAttribute('disabled', 'true')); + input.setAttribute('disabled', ''); - await input.focus(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + input.focus(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); - await input.click(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + input.click(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); - await element.press('ArrowDown'); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); }); it('should stay closed when readonly', async () => { - await page.$eval('input', (e) => e.setAttribute('readonly', 'true')); + input.setAttribute('readonly', ''); - await input.focus(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + input.focus(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); - await input.click(); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + input.click(); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); - await element.press('ArrowDown'); - await page.waitForChanges(); - expect(input.getAttribute('aria-expanded')).toEqual('false'); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(element); + expect(input).to.have.attribute('aria-expanded', 'false'); }); }); diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.events.ts b/src/components/sbb-autocomplete/sbb-autocomplete.events.ts deleted file mode 100644 index cf7d67d8ae..0000000000 --- a/src/components/sbb-autocomplete/sbb-autocomplete.events.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didClose: 'did-close', - didOpen: 'did-open', - willClose: 'will-close', - willOpen: 'will-open', -}; diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts b/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts index 5aa731ec78..206ee57cfa 100644 --- a/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts +++ b/src/components/sbb-autocomplete/sbb-autocomplete.spec.ts @@ -1,109 +1,111 @@ -import { SbbAutocomplete } from './sbb-autocomplete'; -import { newSpecPage } from '@stencil/core/testing'; -import { SbbFormField } from '../sbb-form-field/sbb-form-field'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '../sbb-form-field'; +import '../sbb-option'; +import './sbb-autocomplete'; +import { isSafari } from '../../global/dom'; describe('sbb-autocomplete', () => { it('renders standalone', async () => { - const { root } = await newSpecPage({ - components: [SbbAutocomplete], - html: ` -
- - - 1 - 2 - - `, - }); - - expect(root).toEqualHtml(` - - -
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
+ await fixture(html` +
+ + 1 2 `); - }); + const elem = document.querySelector('sbb-autocomplete'); + const listboxAttr = 'id="sbb-autocomplete-1" role="listbox"'; - it('renders in form field', async () => { - const { root } = await newSpecPage({ - components: [SbbAutocomplete, SbbFormField], - html: ` - - - - 1 - 2 - - - `, - }); - - expect(root).toEqualHtml(` - - -
-
- -
-
- -
-
- -
-
- -
+ expect(elem).dom.to.be.equal(` + + 1 + 2 + + `); + expect(elem).shadowDom.to.be.equal(` +
+
+
+
+
- - - - -
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
+
+ +
+
+
+
+
+
- +
+
+
+ `); + }); + + it('renders in form field', async () => { + const root = await fixture(html` + + + 1 2 `); + const elem = root.querySelector('sbb-autocomplete'); + const listboxAttr = 'id="sbb-autocomplete-4" role="listbox"'; + + expect(root).dom.to.be.equal(` + + + + 1 + 2 + + + `); + expect(root).shadowDom.to.equal(` +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ `); + expect(elem).shadowDom.to.equal(` +
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ `); }); }); diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx b/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx index 01a073360a..9a8ff572e3 100644 --- a/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx +++ b/src/components/sbb-autocomplete/sbb-autocomplete.stories.tsx @@ -1,15 +1,24 @@ /** @jsx h */ import { h, JSX } from 'jsx-dom'; -import events from './sbb-autocomplete.events'; -import optionEvents from '../sbb-option/sbb-option.events'; +import { events } from './sbb-autocomplete'; +import { events as optionEvents } from '../sbb-option'; import readme from './readme.md?raw'; import { userEvent, within } from '@storybook/testing-library'; import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; import isChromatic from 'chromatic'; import { waitForStablePosition } from '../../global/testing/wait-for-stable-position'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator, StoryContext } from '@storybook/html'; +import type { + Meta, + StoryObj, + ArgTypes, + Args, + Decorator, + StoryContext, +} from '@storybook/web-components'; import type { InputType } from '@storybook/types'; import { withActions } from '@storybook/addon-actions/decorator'; +import '../sbb-form-field'; +import './sbb-autocomplete'; const wrapperStyle = (context: StoryContext): Record => ({ 'background-color': context.args.negative diff --git a/src/components/sbb-autocomplete/sbb-autocomplete.tsx b/src/components/sbb-autocomplete/sbb-autocomplete.tsx index 6fe9788230..8b40a8178d 100644 --- a/src/components/sbb-autocomplete/sbb-autocomplete.tsx +++ b/src/components/sbb-autocomplete/sbb-autocomplete.tsx @@ -1,18 +1,3 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Listen, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; import { isEventOnElement, overlayGapFixCorners, @@ -29,76 +14,68 @@ import { toggleDatasetEntry, } from '../../global/dom'; import { assignId, getNextElementIndex } from '../../global/a11y'; +import { CSSResult, html, LitElement, nothing, TemplateResult, PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { setAttribute } from '../../global/dom'; +import { ref } from 'lit/directives/ref.js'; +import { ConnectedAbortController, EventEmitter } from '../../global/eventing'; +import { SbbOption } from '../sbb-option'; +import Style from './sbb-autocomplete.scss?lit&inline'; let nextId = 0; +export const events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', +}; + /** * @slot unnamed - Use this slot to project options. */ -@Component({ - shadow: true, - styleUrl: 'sbb-autocomplete.scss', - tag: 'sbb-autocomplete', -}) -export class SbbAutocomplete implements ComponentInterface { +@customElement('sbb-autocomplete') +export class SbbAutocomplete extends LitElement { + public static override styles: CSSResult = Style; + /** * The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. * If not set, will search for the first 'sbb-form-field' ancestor. */ - @Prop() public origin: string | HTMLElement; + @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. */ - @Prop() public trigger: string | HTMLInputElement; + @property() public trigger: string | HTMLInputElement; /** Whether the animation is disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; /** Whether the icon space is preserved when no icon is set. */ - @Prop({ reflect: true }) public preserveIconSpace: boolean; + @property({ attribute: 'preserve-icon-space', reflect: true, type: Boolean }) + public preserveIconSpace: boolean; /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; + @property({ reflect: true, type: Boolean }) public negative = false; /** The state of the autocomplete. */ - @State() private _state: SbbOverlayState = 'closed'; + @state() private _state: SbbOverlayState = 'closed'; /** Emits whenever the autocomplete starts the opening transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; + private _willOpen: EventEmitter = new EventEmitter(this, events.willOpen); /** Emits whenever the autocomplete is opened. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; + private _didOpen: EventEmitter = new EventEmitter(this, events.didOpen); /** Emits whenever the autocomplete begins the closing transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter; + private _willClose: EventEmitter = new EventEmitter(this, events.willClose); /** Emits whenever the autocomplete is closed. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter; - - @Element() private _element!: HTMLElement; + private _didClose: EventEmitter = new EventEmitter(this, events.didClose); private _overlay: HTMLElement; private _optionContainer: HTMLElement; @@ -110,6 +87,7 @@ export class SbbAutocomplete implements ComponentInterface { private _activeItemIndex = -1; private _didLoad = false; private _isPointerDownEventOnMenu: boolean; + 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. @@ -122,13 +100,12 @@ export class SbbAutocomplete implements ComponentInterface { return this._triggerElement && isValidAttribute(this._triggerElement, 'readonly'); } - private get _options(): HTMLSbbOptionElement[] { - return Array.from(this._element.querySelectorAll('sbb-option')) as HTMLSbbOptionElement[]; + private get _options(): SbbOption[] { + return Array.from(this.querySelectorAll('sbb-option')); } /** Opens the autocomplete. */ - @Method() - public async open(): Promise { + public open(): void { if ( this._state !== 'closed' || !this._overlay || @@ -139,25 +116,23 @@ export class SbbAutocomplete implements ComponentInterface { } this._state = 'opening'; - this.willOpen.emit(); + this._willOpen.emit(); this._setOverlayPosition(); } /** Closes the autocomplete. */ - @Method() - public async close(): Promise { + public close(): void { if (this._state !== 'opened') { return; } this._state = 'closing'; - this.willClose.emit(); + this._willClose.emit(); this._openPanelEventsController.abort(); } /** Removes trigger click listener on trigger change. */ - @Watch('origin') - public resetOriginClickListener( + private _resetOriginClickListener( newValue: string | HTMLElement, oldValue: string | HTMLElement, ): void { @@ -167,8 +142,7 @@ export class SbbAutocomplete implements ComponentInterface { } /** Removes trigger click listener on trigger change. */ - @Watch('trigger') - public resetTriggerClickListener( + private _resetTriggerClickListener( newValue: string | HTMLElement, oldValue: string | HTMLElement, ): void { @@ -178,9 +152,8 @@ export class SbbAutocomplete implements ComponentInterface { } /** When an option is selected, update the input value and close the autocomplete. */ - @Listen('option-selection-change') - public async onOptionSelected(event: CustomEvent): Promise { - const target: HTMLSbbOptionElement = event.target as HTMLSbbOptionElement; + private _onOptionSelected(event: CustomEvent): void { + const target = event.target as SbbOption; if (!target.selected) { return; } @@ -197,50 +170,67 @@ export class SbbAutocomplete implements ComponentInterface { this._triggerElement.dispatchEvent(new window.Event('change', { bubbles: true })); this._triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - await this.close(); + this.close(); } - @Listen('click') - public async onOptionClick(event): Promise { + private _onOptionClick(event): void { if (event.target?.tagName !== 'SBB-OPTION' || event.target.disabled) { return; } - await this.close(); - } - - public componentDidLoad(): void { - this._componentSetup(); - this._didLoad = true; + this.close(); } - public connectedCallback(): void { - const formField = - this._element.closest('sbb-form-field') ?? this._element.closest('[data-form-field]'); + 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'); } - this._syncNegative(); if (this._didLoad) { this._componentSetup(); } + this._syncNegative(); + + this.addEventListener( + 'option-selection-change', + (e: CustomEvent) => this._onOptionSelected(e), + { signal }, + ); + this.addEventListener('click', (e) => this._onOptionClick(e), { signal }); + } + + public override willUpdate(changedProperties: PropertyValues): void { + 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(): void { + this._componentSetup(); + this._didLoad = true; } - @Watch('negative') private _syncNegative(): void { - this._element - .querySelectorAll('sbb-divider') - .forEach((element) => - this.negative ? element.setAttribute('negative', '') : element.removeAttribute('negative'), - ); + this.querySelectorAll('sbb-divider').forEach((element) => + setAttribute(element, 'negative', this.negative), + ); - this._element - .querySelectorAll('sbb-option, sbb-optgroup') - .forEach((element: HTMLElement) => toggleDatasetEntry(element, 'negative', this.negative)); + this.querySelectorAll('sbb-option, sbb-optgroup').forEach((element: HTMLElement) => + toggleDatasetEntry(element, 'negative', this.negative), + ); } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._triggerEventsController?.abort(); this._openPanelEventsController?.abort(); } @@ -261,7 +251,7 @@ export class SbbAutocomplete implements ComponentInterface { let result: HTMLElement; if (!this.origin) { - result = this._element.closest('sbb-form-field')?.shadowRoot.querySelector('#overlay-anchor'); + result = this.closest('sbb-form-field')?.shadowRoot.querySelector('#overlay-anchor'); } else { result = findReferencedElement(this.origin); } @@ -281,7 +271,7 @@ export class SbbAutocomplete implements ComponentInterface { */ private _getTriggerElement(): HTMLInputElement { if (!this.trigger) { - return this._element.closest('sbb-form-field')?.querySelector('input') as HTMLInputElement; + return this.closest('sbb-form-field')?.querySelector('input') as HTMLInputElement; } const result = findReferencedElement(this.trigger); @@ -303,9 +293,9 @@ export class SbbAutocomplete implements ComponentInterface { this._originElement = anchorElem; toggleDatasetEntry( - this._element, + this, 'optionPanelOriginBorderless', - this._element.closest('sbb-form-field')?.hasAttribute('borderless'), + this.closest('sbb-form-field')?.hasAttribute('borderless'), ); } @@ -335,8 +325,8 @@ export class SbbAutocomplete implements ComponentInterface { }); this._triggerElement.addEventListener( 'input', - async (event) => { - await this.open(); + (event) => { + this.open(); this._highlightOptions((event.target as HTMLInputElement).value); }, { signal: this._triggerEventsController.signal }, @@ -350,7 +340,7 @@ export class SbbAutocomplete implements ComponentInterface { // Set overlay position, width and max height private _setOverlayPosition(): void { - setOverlayPosition(this._overlay, this._originElement, this._optionContainer, this._element); + setOverlayPosition(this._overlay, this._originElement, this._optionContainer, this); } /** On open/close animation end. @@ -369,7 +359,7 @@ export class SbbAutocomplete implements ComponentInterface { this._state = 'opened'; this._attachOpenPanelEvents(); this._triggerElement?.setAttribute('aria-expanded', 'true'); - this.didOpen.emit(); + this._didOpen.emit(); } private _onCloseAnimationEnd(): void { @@ -377,7 +367,7 @@ export class SbbAutocomplete implements ComponentInterface { this._triggerElement?.setAttribute('aria-expanded', 'false'); this._resetActiveElement(); this._optionContainer.scrollTop = 0; - this.didClose.emit(); + this._didClose.emit(); } private _attachOpenPanelEvents(): void { @@ -417,17 +407,17 @@ export class SbbAutocomplete implements ComponentInterface { }; // If the click is outside the autocomplete, closes the panel. - private _closeOnBackdropClick = async (event: PointerEvent): Promise => { + private _closeOnBackdropClick = (event: PointerEvent): void => { if ( !this._isPointerDownEventOnMenu && !isEventOnElement(this._overlay, event) && !isEventOnElement(this._originElement, event) ) { - await this.close(); + this.close(); } }; - private async _closedPanelKeyboardInteraction(event: KeyboardEvent): Promise { + private _closedPanelKeyboardInteraction(event: KeyboardEvent): void { if (this._state !== 'closed') { return; } @@ -436,12 +426,12 @@ export class SbbAutocomplete implements ComponentInterface { case 'Enter': case 'ArrowDown': case 'ArrowUp': - await this.open(); + this.open(); break; } } - private async _openedPanelKeyboardInteraction(event: KeyboardEvent): Promise { + private _openedPanelKeyboardInteraction(event: KeyboardEvent): void { if (this._state !== 'opened') { return; } @@ -449,11 +439,11 @@ export class SbbAutocomplete implements ComponentInterface { switch (event.key) { case 'Escape': case 'Tab': - await this.close(); + this.close(); break; case 'Enter': - await this._selectByKeyboard(); + this._selectByKeyboard(); break; case 'ArrowDown': @@ -463,11 +453,11 @@ export class SbbAutocomplete implements ComponentInterface { } } - private async _selectByKeyboard(): Promise { + private _selectByKeyboard(): void { const activeOption = this._options[this._activeItemIndex]; if (activeOption) { - await activeOption.setSelectedViaUserInteraction(true); + activeOption.setSelectedViaUserInteraction(true); } } @@ -508,43 +498,48 @@ export class SbbAutocomplete implements ComponentInterface { } private _setTriggerAttributes(element: HTMLInputElement): void { - setAriaComboBoxAttributes(element, this._element.id || this._overlayId, false); + setAriaComboBoxAttributes(element, this.id || this._overlayId, false); } private _removeTriggerAttributes(element: HTMLInputElement): void { removeAriaComboBoxAttributes(element); } - public render(): JSX.Element { - return ( - this._overlayId)} - dir={getDocumentWritingMode()} - > -
-
-
{overlayGapFixCorners()}
-
this._onAnimationEnd(event)} - class="sbb-autocomplete__panel" - data-open={this._state === 'opened' || this._state === 'opening'} - ref={(overlayRef) => (this._overlay = overlayRef)} - > -
-
(this._optionContainer = containerRef)} - role={!this._ariaRoleOnHost ? 'listbox' : null} - id={!this._ariaRoleOnHost ? this._overlayId : null} - > - -
+ protected override render(): TemplateResult { + setAttribute(this, 'data-state', this._state); + setAttribute(this, 'role', this._ariaRoleOnHost ? 'listbox' : null); + setAttribute(this, 'dir', getDocumentWritingMode()); + this._ariaRoleOnHost && assignId(() => this._overlayId)(this); + + return html` +
+
+
${overlayGapFixCorners()}
+
(this._overlay = overlayRef as HTMLElement))} + > +
+
(this._optionContainer = containerRef as HTMLElement))} + role=${!this._ariaRoleOnHost ? 'listbox' : nothing} + id=${!this._ariaRoleOnHost ? this._overlayId : nothing} + > +
- - ); +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-autocomplete': SbbAutocomplete; } } diff --git a/src/components/sbb-button/sbb-button.spec.ts b/src/components/sbb-button/sbb-button.spec.ts index c3f97f8d23..8a0df21ad1 100644 --- a/src/components/sbb-button/sbb-button.spec.ts +++ b/src/components/sbb-button/sbb-button.spec.ts @@ -1,5 +1,6 @@ import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; +import '../sbb-form-field'; import './sbb-button'; describe('sbb-button', () => { @@ -172,7 +173,7 @@ describe('sbb-button', () => { `, ); - - expect(root.querySelector('sbb-button')).to.have.attribute('data-icon-small'); + const button = root.querySelector('sbb-button'); + expect(button).to.have.attribute('data-icon-small'); }); }); diff --git a/src/components/sbb-form-field/sbb-form-field.e2e.ts b/src/components/sbb-form-field/sbb-form-field.e2e.ts index 0b9945a04b..694dd7aa6c 100644 --- a/src/components/sbb-form-field/sbb-form-field.e2e.ts +++ b/src/components/sbb-form-field/sbb-form-field.e2e.ts @@ -1,8 +1,10 @@ -import { aTimeout, assert, expect, fixture, nextFrame } from '@open-wc/testing'; +import { assert, expect, fixture, nextFrame } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; import { sendKeys } from '@web/test-runner-commands'; -import { waitForLitRender } from '../../global/testing'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; import { SbbFormField } from './sbb-form-field'; +import { SbbSelect } from '../sbb-select'; +import { SbbOption } from '../sbb-option'; describe('sbb-form-field', () => { describe('with input', () => { @@ -131,10 +133,9 @@ describe('sbb-form-field', () => { }); }); - // TODO-Migr: Unskip this when sbb-select is migrated - describe.skip('with sbb-select', () => { + describe('with sbb-select', () => { let element: SbbFormField; - let select: any; // TODO-Migr: Change to SbbSelect + let select: SbbSelect; beforeEach(async () => { element = await fixture(html` @@ -145,6 +146,12 @@ describe('sbb-form-field', () => { select = document.querySelector('sbb-select'); }); + it('renders', async () => { + const option = select.querySelector('sbb-option'); + assert.instanceOf(select, SbbSelect); + assert.instanceOf(option, SbbOption); + }); + it('should react to focus state', async () => { expect(element).not.to.have.attribute('data-input-focused'); @@ -165,7 +172,7 @@ describe('sbb-form-field', () => { label.click(); await waitForLitRender(element); - expect(select).to.have.attribute('data-state', 'opened'); + expect(select).to.have.attribute('data-state', 'opening'); }); it('should focus select on form field click readonly', async () => { @@ -225,8 +232,7 @@ describe('sbb-form-field', () => { expect(element).not.to.have.attribute('data-input-empty'); }); - // TODO-Migr: Unskip this when sbb-select is migrated - it.skip('should read sbb-select empty state', async () => { + it('should read sbb-select empty state', async () => { const element: SbbFormField = await fixture(html` @@ -239,9 +245,8 @@ describe('sbb-form-field', () => { expect(element).to.have.attribute('data-input-empty'); }); - // TODO-Migr: Unskip this when sbb-select is migrated - it.skip('should not read sbb-select empty state', async () => { - const element: SbbFormField = await fixture(html` + it('should not read sbb-select empty state', async () => { + const element = await fixture(html` Empty Value @@ -253,8 +258,7 @@ describe('sbb-form-field', () => { expect(element).not.to.have.attribute('data-input-empty'); }); - // TODO-Migr: Unskip this when sbb-select is migrated - it.skip('should update floating label after clearing', async () => { + it('should update floating label after clearing', async () => { const element: SbbFormField = await fixture( html` @@ -263,35 +267,30 @@ describe('sbb-form-field', () => { `, ); - // TODO-Migr: Remove as any - (document.querySelector('sbb-select') as any).value = ''; + document.querySelector('sbb-select').value = ''; await waitForLitRender(element); expect(element).to.have.attribute('data-input-empty'); }); it('should update floating label when resetting form', async () => { - await fixture(html` + const form = (await fixture(html`
- `); - const element = document.querySelector('sbb-form-field'); - document.querySelector('input').focus(); + `)) as HTMLFormElement; + const element = form.querySelector('sbb-form-field'); + form.querySelector('input').focus(); await sendKeys({ type: 'test' }); await waitForLitRender(element); expect(element).not.to.have.attribute('data-input-empty'); - document.querySelector('form').reset(); - await waitForLitRender(element); + form.reset(); // This is necessary to await for the reset event to be propagated - // In general, 'element.updateComplete' should suffice. Unless the changes - // do not trigger a rendering of the component - await aTimeout(0); - + await waitForCondition(() => element.hasAttribute('data-input-empty')); expect(element).to.have.attribute('data-input-empty'); }); diff --git a/src/components/sbb-form-field/sbb-form-field.spec.ts b/src/components/sbb-form-field/sbb-form-field.spec.ts index 048fa85d81..e236f89fe2 100644 --- a/src/components/sbb-form-field/sbb-form-field.spec.ts +++ b/src/components/sbb-form-field/sbb-form-field.spec.ts @@ -123,8 +123,7 @@ describe('sbb-form-field', () => { `); }); - // TODO-Migr: Unskip when the 'sbb-form-error' is migrated - it.skip('renders readonly input with error', async () => { + it('renders readonly input with error', async () => { const root = await fixture(html` { `); expect(root).dom.to.be.equal(` - + - + You can't change this value. diff --git a/src/components/sbb-form-field/sbb-form-field.tsx b/src/components/sbb-form-field/sbb-form-field.tsx index 1edc5ffd2c..e35d9c394a 100644 --- a/src/components/sbb-form-field/sbb-form-field.tsx +++ b/src/components/sbb-form-field/sbb-form-field.tsx @@ -13,7 +13,7 @@ import { SbbInputModality, sbbInputModalityDetector } from '../../global/a11y'; import { CSSResult, html, LitElement, nothing, TemplateResult, PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import Style from './sbb-form-field.scss?lit&inline'; -//import { SbbSelect } from '../sbb-select'; TODO-Migr: Uncomment when the sbb-select has been migrated +import { SbbSelect } from '../sbb-select'; import '../sbb-icon'; let nextId = 0; @@ -371,8 +371,7 @@ export class SbbFormField extends LitElement { } else if (this._input instanceof HTMLSelectElement) { return this._input.selectedOptions?.item(0)?.label?.trim() === ''; } else if (this._input.tagName === 'SBB-SELECT') { - // TODO-Migr: change the 'any' to SbbSelect - return (this._input as any).getDisplayValue()?.trim() === ''; + return (this._input as SbbSelect).getDisplayValue()?.trim() === ''; } else { return this._isInputValueEmpty(); } diff --git a/src/components/sbb-navigation-section/sbb-navigation-section.tsx b/src/components/sbb-navigation-section/sbb-navigation-section.tsx index 18a071fdf9..ed932116a1 100644 --- a/src/components/sbb-navigation-section/sbb-navigation-section.tsx +++ b/src/components/sbb-navigation-section/sbb-navigation-section.tsx @@ -190,7 +190,7 @@ export class SbbNavigationSection extends LitElement { this._state = 'opened'; this._attachWindowEvents(); this._setNavigationInert(); - this._timeout = setTimeout(() => this._setNavigationSectionFocus()); + this._timeoutController = setTimeout(() => this._setNavigationSectionFocus()); } else if (event.animationName === 'close' && this._state === 'closing') { this._state = 'closed'; this._navigationSectionContainerElement.scrollTo(0, 0); diff --git a/src/components/sbb-navigation/sbb-navigation.e2e.ts b/src/components/sbb-navigation/sbb-navigation.e2e.ts index f22f060427..1dc12ce293 100644 --- a/src/components/sbb-navigation/sbb-navigation.e2e.ts +++ b/src/components/sbb-navigation/sbb-navigation.e2e.ts @@ -227,12 +227,11 @@ describe('sbb-navigation', () => { expect(secondSectionDialog).not.to.have.attribute('open'); secondAction.click(); - console.log('second click'); await waitForCondition(() => secondSection.getAttribute('data-state') === 'opened'); expect(firstSection.getAttribute('data-state')).not.to.be.equal('opened'); expect(secondSectionDialog).to.have.attribute('open'); - await aTimeout(1000); + await aTimeout(250); }); it('closes the navigation and the section on close button click', async () => { diff --git a/src/components/sbb-optgroup/index.ts b/src/components/sbb-optgroup/index.ts new file mode 100644 index 0000000000..171f423b88 --- /dev/null +++ b/src/components/sbb-optgroup/index.ts @@ -0,0 +1 @@ +export * from './sbb-optgroup'; diff --git a/src/components/sbb-optgroup/sbb-optgroup.e2e.ts b/src/components/sbb-optgroup/sbb-optgroup.e2e.ts index 4f85042f0e..d5c032ce10 100644 --- a/src/components/sbb-optgroup/sbb-optgroup.e2e.ts +++ b/src/components/sbb-optgroup/sbb-optgroup.e2e.ts @@ -1,61 +1,74 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { SbbOptGroup } from './sbb-optgroup'; +import { SbbOption } from '../sbb-option'; +import { waitForLitRender } from '../../global/testing'; +import '../sbb-option'; describe('sbb-optgroup', () => { - let element: E2EElement, page: E2EPage; + let element: SbbOptGroup; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` + element = await fixture(html` Label 1 Label 2 Label 3 `); - element = await page.find('sbb-optgroup'); }); it('renders', async () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbOptGroup); }); it('disabled status is inherited', async () => { - element.setAttribute('disabled', 'true'); - await page.waitForChanges(); - expect(element).toEqualAttribute('disabled', 'true'); - const optionOne = await page.find('sbb-optgroup > sbb-option#option-1'); - expect(optionOne.getAttribute('data-group-disabled')).not.toBeNull(); - const optionTwo = await page.find('sbb-optgroup > sbb-option#option-2'); - expect(optionTwo.getAttribute('data-group-disabled')).not.toBeNull(); - expect(optionTwo.getAttribute('disabled')).not.toBeNull(); - const optionThree = await page.find('sbb-optgroup > sbb-option#option-3'); - expect(optionThree.getAttribute('data-group-disabled')).not.toBeNull(); + const optionOne = document.querySelector('sbb-optgroup > sbb-option#option-1'); + const optionTwo = document.querySelector('sbb-optgroup > sbb-option#option-2'); + const optionThree = document.querySelector('sbb-optgroup > sbb-option#option-3'); + element.setAttribute('disabled', ''); + await waitForLitRender(element); + + expect(element).to.have.attribute('disabled'); + expect(optionOne).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); + expect(optionThree).to.have.attribute('data-group-disabled'); + element.removeAttribute('disabled'); - await page.waitForChanges(); - expect(optionTwo.getAttribute('data-group-disabled')).toBeNull(); - expect(optionTwo.getAttribute('disabled')).not.toBeNull(); + await waitForLitRender(element); + expect(optionTwo).not.to.have.attribute('data-group-disabled'); + expect(optionTwo).to.have.attribute('disabled'); }); it('disabled status prevents changes', async () => { - const optionOne = await page.find('sbb-optgroup > sbb-option#option-1'); - const optionTwo = await page.find('sbb-optgroup > sbb-option#option-2'); - const optionThree = await page.find('sbb-optgroup > sbb-option#option-3'); + const optionOne: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-1'); + const optionTwo: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-2'); + const optionThree: SbbOption = document.querySelector('sbb-optgroup > sbb-option#option-3'); const options = [optionOne, optionTwo, optionThree]; - options.forEach((opt: E2EElement) => expect(opt).toEqualAttribute('selected', null)); - element.setAttribute('disabled', 'true'); - await page.waitForChanges(); - expect(element).toEqualAttribute('disabled', 'true'); - for (const check of options) { - await check.click(); - expect(check).toEqualAttribute('selected', null); + + options.forEach((opt) => expect(opt).not.to.have.attribute('selected')); + + element.setAttribute('disabled', ''); + await waitForLitRender(element); + expect(element).to.have.attribute('disabled'); + + // clicks should have no effect since the group is disabled + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); + expect(opt).not.to.have.attribute('selected'); } + element.removeAttribute('disabled'); - await page.waitForChanges(); - for (const check of options) { - await check.click(); + await waitForLitRender(element); + for (const opt of options) { + opt.click(); + await waitForLitRender(opt); } - expect(optionOne).toEqualAttribute('selected', ''); - expect(optionTwo).toEqualAttribute('selected', null); - expect(optionThree).toEqualAttribute('selected', ''); + + expect(optionOne).to.have.attribute('selected'); + expect(optionTwo).not.to.have.attribute('selected'); + expect(optionThree).to.have.attribute('selected'); }); }); diff --git a/src/components/sbb-optgroup/sbb-optgroup.spec.ts b/src/components/sbb-optgroup/sbb-optgroup.spec.ts index fb150f1608..0a5ddbaf53 100644 --- a/src/components/sbb-optgroup/sbb-optgroup.spec.ts +++ b/src/components/sbb-optgroup/sbb-optgroup.spec.ts @@ -1,68 +1,77 @@ -import { SbbOptGroup } from './sbb-optgroup'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '../sbb-autocomplete'; +import '../sbb-option'; +import './sbb-optgroup'; +import { isSafari } from '../../global/dom'; describe('sbb-optgroup', () => { describe('autocomplete', function () { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbOptGroup], - html: ` - + const root = ( + await fixture(html` + 1 2 - `, - }); +
+ `) + ).querySelector('sbb-optgroup'); + const groupRoleAttr = 'aria-disabled="false" aria-label="Label" role="group"'; - expect(root).toEqualHtml(` - - -
- -
- - -
- 1 - 2 + expect(root).dom.to.be.equal(` + + 1 + 2 `); + expect(root).shadowDom.to.be.equal(` +
+ +
+ + + `); }); it('renders disabled', async () => { - const { root } = await newSpecPage({ - components: [SbbOptGroup], - html: ` - + const root = ( + await fixture(html` + 1 2 - `, - }); +
+ `) + ).querySelector('sbb-optgroup'); + const groupRoleAttr = 'aria-disabled="true" aria-label="Label" role="group"'; - expect(root).toEqualHtml(` - - -
- -
- - -
- 1 - 2 + expect(root).dom.to.be.equal(` + + 1 + 2 `); + + expect(root).shadowDom.to.be.equal(` +
+ +
+ + + `); }); }); }); diff --git a/src/components/sbb-optgroup/sbb-optgroup.stories.tsx b/src/components/sbb-optgroup/sbb-optgroup.stories.tsx index eb8068581a..8a6d638fe9 100644 --- a/src/components/sbb-optgroup/sbb-optgroup.stories.tsx +++ b/src/components/sbb-optgroup/sbb-optgroup.stories.tsx @@ -1,9 +1,14 @@ /** @jsx h */ import { Fragment, h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; import type { InputType } from '@storybook/types'; -import { StoryContext } from '@storybook/html'; +import { StoryContext } from '@storybook/web-components'; +import '../sbb-form-field'; +import '../sbb-autocomplete'; +import '../sbb-select'; +import '../sbb-option'; +import './sbb-optgroup'; const wrapperStyle = (context: StoryContext): Record => ({ 'background-color': context.args.negative diff --git a/src/components/sbb-optgroup/sbb-optgroup.tsx b/src/components/sbb-optgroup/sbb-optgroup.tsx index 9cb3c8684d..a825b76c91 100644 --- a/src/components/sbb-optgroup/sbb-optgroup.tsx +++ b/src/components/sbb-optgroup/sbb-optgroup.tsx @@ -1,36 +1,26 @@ -import { - Component, - ComponentInterface, - Element, - h, - Host, - JSX, - Prop, - State, - Watch, -} from '@stencil/core'; -import { SbbOptionVariant } from '../sbb-option/sbb-option.custom'; import { isSafari, isValidAttribute, toggleDatasetEntry } from '../../global/dom'; import { AgnosticMutationObserver } from '../../global/observers'; +import { CSSResult, html, LitElement, TemplateResult, PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { setAttribute } from '../../global/dom'; +import { SbbOption, SbbOptionVariant } from '../sbb-option'; +import Style from './sbb-optgroup.scss?lit&inline'; +import '../sbb-divider'; /** * @slot unnamed - Used to display options. */ -@Component({ - shadow: true, - styleUrl: 'sbb-optgroup.scss', - tag: 'sbb-optgroup', -}) -export class SbbOptGroup implements ComponentInterface { +@customElement('sbb-optgroup') +export class SbbOptGroup extends LitElement { + public static override styles: CSSResult = Style; + /** Option group label. */ - @Prop() public label: string; + @property() public label: string; /** Whether the group is disabled. */ - @Prop() public disabled = false; - - @Element() private _element: HTMLElement; + @property({ type: Boolean }) public disabled = false; - @State() private _negative = false; + @state() private _negative = false; private _negativeObserver = new AgnosticMutationObserver(() => this._onNegativeChange()); @@ -44,60 +34,60 @@ export class SbbOptGroup implements ComponentInterface { private _inertAriaGroups = isSafari(); private get _isMultiple(): boolean { - return ( - this._variant === 'select' && this._element.closest('sbb-select')?.hasAttribute('multiple') - ); + return this._variant === 'select' && this.closest('sbb-select')?.hasAttribute('multiple'); } - @Watch('disabled') - public updateDisabled(): void { - this._proxyDisabledToOptions(); + private get _options(): SbbOption[] { + return Array.from(this.querySelectorAll('sbb-option')) as SbbOption[]; } - @Watch('label') - public proxyGroupLabelToOptions(): void { - if (!this._inertAriaGroups) { - return; - } - - for (const option of this._options) { - option.setGroupLabel(this.label); - } - } - - public connectedCallback(): void { + public override connectedCallback(): void { + super.connectedCallback(); this._negativeObserver?.disconnect(); - this._negative = !!this._element.closest( - // :is() selector not possible due to test environment - `sbb-autocomplete[negative]:not([negative='false']),sbb-select[negative]:not([negative='false']),sbb-form-field[negative]:not([negative='false'])`, + this._negative = !!this.closest( + `:is(sbb-autocomplete, sbb-select, sbb-form-field)[negative]:not([negative='false']`, ); - toggleDatasetEntry(this._element, 'negative', this._negative); + toggleDatasetEntry(this, 'negative', this._negative); - this._negativeObserver.observe(this._element, { + this._negativeObserver.observe(this, { attributes: true, attributeFilter: ['data-negative'], }); this._setVariantByContext(); - this.proxyGroupLabelToOptions(); + this._proxyGroupLabelToOptions(); } - public disconnectedCallback(): void { - this._negativeObserver?.disconnect(); + public override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('disabled')) { + this._proxyDisabledToOptions(); + } + if (changedProperties.has('label')) { + this._proxyGroupLabelToOptions(); + } } - private get _options(): HTMLSbbOptionElement[] { - return Array.from(this._element.querySelectorAll('sbb-option')) as HTMLSbbOptionElement[]; + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._negativeObserver?.disconnect(); } private _setVariantByContext(): void { - if (this._element.closest('sbb-autocomplete')) { + if (this.closest('sbb-autocomplete')) { this._variant = 'autocomplete'; - } else if (this._element.closest('sbb-select')) { + } else if (this.closest('sbb-select')) { this._variant = 'select'; } } + 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); @@ -105,27 +95,32 @@ export class SbbOptGroup implements ComponentInterface { } private _onNegativeChange(): void { - this._negative = isValidAttribute(this._element, 'data-negative'); + this._negative = isValidAttribute(this, 'data-negative'); } - public render(): JSX.Element { - return ( - -
- -
-