diff --git a/src/elements/autocomplete/autocomplete.ts b/src/elements/autocomplete/autocomplete.ts index edcea652c4..025b1b4d45 100644 --- a/src/elements/autocomplete/autocomplete.ts +++ b/src/elements/autocomplete/autocomplete.ts @@ -1,21 +1,13 @@ -import { - type CSSResultGroup, - html, - isServer, - LitElement, - nothing, - type PropertyValues, - type TemplateResult, -} from 'lit'; +import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; +import { html, isServer, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; import { getNextElementIndex } from '../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; import { SbbConnectedAbortController } from '../core/controllers.js'; import { hostAttributes } from '../core/decorators.js'; import { findReferencedElement, getDocumentWritingMode, isSafari } from '../core/dom.js'; -import { EventEmitter } from '../core/eventing.js'; -import type { SbbOpenedClosedState } from '../core/interfaces.js'; import { SbbHydrationMixin, SbbNegativeMixin } from '../core/mixins.js'; import { isEventOnElement, @@ -53,14 +45,10 @@ const ariaRoleOnHost = isSafari; dir: getDocumentWritingMode(), role: ariaRoleOnHost ? 'listbox' : null, }) -export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { +export class SbbAutocompleteElement extends SbbNegativeMixin( + SbbHydrationMixin(SbbOpenCloseBaseElement), +) { 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. @@ -79,29 +67,6 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L @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); - - /** Emits whenever the `sbb-autocomplete` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbAutocompleteElement.events.didOpen); - - /** Emits whenever the `sbb-autocomplete` begins the closing transition. */ - private _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; @@ -139,32 +104,27 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L /** Opens the autocomplete. */ public open(): void { - if ( - this._state !== 'closed' || - !this._overlay || - this._options.length === 0 || - this._readonly - ) { + if (this.state !== 'closed' || !this._overlay || this._options.length === 0 || this._readonly) { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; this._setOverlayPosition(); } /** Closes the autocomplete. */ public close(): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } - if (!this._willClose.emit()) { + if (!this.willClose.emit()) { return; } - this._state = 'closing'; + this.state = 'closing'; this._openPanelEventsController.abort(); } @@ -228,7 +188,6 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L this.id ||= this._overlayId; } - this._state ||= 'closed'; const signal = this._abort.signal; const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); @@ -396,26 +355,26 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L * To avoid entering a corrupt state, exit when state is not expected. */ private _onAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { + if (event.animationName === 'open' && this.state === 'opening') { this._onOpenAnimationEnd(); - } else if (event.animationName === 'close' && this._state === 'closing') { + } else if (event.animationName === 'close' && this.state === 'closing') { this._onCloseAnimationEnd(); } } private _onOpenAnimationEnd(): void { - this._state = 'opened'; + this.state = 'opened'; this._attachOpenPanelEvents(); this.triggerElement?.setAttribute('aria-expanded', 'true'); - this._didOpen.emit(); + this.didOpen.emit(); } private _onCloseAnimationEnd(): void { - this._state = 'closed'; + this.state = 'closed'; this.triggerElement?.setAttribute('aria-expanded', 'false'); this._resetActiveElement(); this._optionContainer.scrollTop = 0; - this._didClose.emit(); + this.didClose.emit(); } private _attachOpenPanelEvents(): void { @@ -466,7 +425,7 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L }; private _closedPanelKeyboardInteraction(event: KeyboardEvent): void { - if (this._state !== 'closed') { + if (this.state !== 'closed') { return; } @@ -480,7 +439,7 @@ export class SbbAutocompleteElement extends SbbNegativeMixin(SbbHydrationMixin(L } private _openedPanelKeyboardInteraction(event: KeyboardEvent): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } diff --git a/src/elements/autocomplete/readme.md b/src/elements/autocomplete/readme.md index 859ae98ceb..4e54c54a6f 100644 --- a/src/elements/autocomplete/readme.md +++ b/src/elements/autocomplete/readme.md @@ -108,19 +108,19 @@ using `aria-activedescendant` to support navigation though the autocomplete opti ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ------------------------ | ---------- | ------ | -------------- | -| `close` | public | Closes the autocomplete. | | `void` | | -| `open` | public | Opens the autocomplete. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------ | ---------- | ------ | ----------------------- | +| `close` | public | Closes the autocomplete. | | `void` | SbbOpenCloseBaseElement | +| `open` | public | Opens the autocomplete. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------- | ------------------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ------------------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-autocomplete` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-autocomplete` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/core/base-elements.ts b/src/elements/core/base-elements.ts index b369db0b79..ec31fb7adb 100644 --- a/src/elements/core/base-elements.ts +++ b/src/elements/core/base-elements.ts @@ -1,3 +1,4 @@ export * from './base-elements/action-base-element.js'; export * from './base-elements/button-base-element.js'; export * from './base-elements/link-base-element.js'; +export * from './base-elements/open-close-base-element.js'; diff --git a/src/elements/core/base-elements/open-close-base-element.ts b/src/elements/core/base-elements/open-close-base-element.ts new file mode 100644 index 0000000000..23aca7730d --- /dev/null +++ b/src/elements/core/base-elements/open-close-base-element.ts @@ -0,0 +1,60 @@ +import { LitElement } from 'lit'; + +import { EventEmitter } from '../eventing.js'; +import type { SbbOpenedClosedState } from '../interfaces.js'; + +/** + * Base class for overlay components. + * + * @event willOpen - Emits whenever the component starts the opening transition. Can be canceled. + * @event didOpen - Emits whenever the component is opened. + * @event willClose - Emits whenever the component begins the closing transition. Can be canceled. + * @event didClose - Emits whenever the component is closed. + */ +export abstract class SbbOpenCloseBaseElement extends LitElement { + public static readonly events = { + willOpen: 'willOpen', + didOpen: 'didOpen', + willClose: 'willClose', + didClose: 'didClose', + } as const; + + /** The state of the component. */ + protected set state(state: SbbOpenedClosedState) { + this.setAttribute('data-state', state); + } + protected get state(): SbbOpenedClosedState { + return this.getAttribute('data-state') as SbbOpenedClosedState; + } + + /** Emits whenever the component starts the opening transition. */ + protected willOpen: EventEmitter = new EventEmitter( + this, + SbbOpenCloseBaseElement.events.willOpen, + ); + + /** Emits whenever the component is opened. */ + protected didOpen: EventEmitter = new EventEmitter(this, SbbOpenCloseBaseElement.events.didOpen); + + /** Emits whenever the component begins the closing transition. */ + protected willClose: EventEmitter = new EventEmitter( + this, + SbbOpenCloseBaseElement.events.willClose, + ); + + /** Emits whenever the component is closed. */ + protected didClose: EventEmitter = new EventEmitter( + this, + SbbOpenCloseBaseElement.events.didClose, + ); + + /** Opens the component. */ + public abstract open(): void; + /** Closes the component. */ + public abstract close(): void; + + public override connectedCallback(): void { + super.connectedCallback(); + this.state ||= 'closed'; + } +} diff --git a/src/elements/core/interfaces.ts b/src/elements/core/interfaces.ts index d6d8a35523..4a7673d271 100644 --- a/src/elements/core/interfaces.ts +++ b/src/elements/core/interfaces.ts @@ -1,2 +1,3 @@ +export * from './interfaces/overlay-close-details.js'; export * from './interfaces/types.js'; export * from './interfaces/validation-change.js'; diff --git a/src/elements/core/interfaces/overlay-close-details.ts b/src/elements/core/interfaces/overlay-close-details.ts new file mode 100644 index 0000000000..08191fb08b --- /dev/null +++ b/src/elements/core/interfaces/overlay-close-details.ts @@ -0,0 +1,4 @@ +export type SbbOverlayCloseEventDetails = { + returnValue?: any; + closeTarget?: HTMLElement; +}; diff --git a/src/elements/core/mixins/update-scheduler-mixin.ts b/src/elements/core/mixins/update-scheduler-mixin.ts index 37d7aa7b59..a973409fd9 100644 --- a/src/elements/core/mixins/update-scheduler-mixin.ts +++ b/src/elements/core/mixins/update-scheduler-mixin.ts @@ -1,6 +1,6 @@ import type { LitElement } from 'lit'; -import type { Constructor } from './constructor.js'; +import type { AbstractConstructor } from './constructor.js'; // Define the interface for the mixin export declare class SbbUpdateSchedulerMixinType { @@ -14,10 +14,13 @@ export declare class SbbUpdateSchedulerMixinType { * @returns A class extended with the slot child observer functionality. */ // eslint-disable-next-line @typescript-eslint/naming-convention -export const SbbUpdateSchedulerMixin = >( +export const SbbUpdateSchedulerMixin = >( base: T, -): Constructor & T => { - class SbbUpdateSchedulerElement extends base implements Partial { +): AbstractConstructor & T => { + abstract class SbbUpdateSchedulerElement + extends base + implements Partial + { private _updatePromise = Promise.resolve(); private _updateResolve = (): void => {}; @@ -35,5 +38,6 @@ export const SbbUpdateSchedulerMixin = >( return result; } } - return SbbUpdateSchedulerElement as unknown as Constructor & T; + return SbbUpdateSchedulerElement as unknown as AbstractConstructor & + T; }; diff --git a/src/elements/dialog/dialog/dialog.ts b/src/elements/dialog/dialog/dialog.ts index c90bd08716..cd982dae58 100644 --- a/src/elements/dialog/dialog/dialog.ts +++ b/src/elements/dialog/dialog/dialog.ts @@ -1,22 +1,12 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { html } from 'lit/static-html.js'; -import { - SbbFocusHandler, - getFirstFocusableElement, - setModalityOnNextFocus, -} from '../../core/a11y.js'; -import { SbbLanguageController } from '../../core/controllers.js'; -import { SbbScrollHandler, hostContext, isBreakpoint } from '../../core/dom.js'; -import { EventEmitter } from '../../core/eventing.js'; -import { i18nDialog } from '../../core/i18n.js'; -import type { SbbOpenedClosedState } from '../../core/interfaces.js'; -import { SbbNegativeMixin } from '../../core/mixins.js'; +import { getFirstFocusableElement, setModalityOnNextFocus } from '../../core/a11y.js'; +import { isBreakpoint } from '../../core/dom.js'; import { AgnosticResizeObserver } from '../../core/observers.js'; import { applyInertMechanism, removeInertMechanism } from '../../core/overlay.js'; -import type { SbbScreenReaderOnlyElement } from '../../screen-reader-only.js'; +import { overlayRefs, SbbOverlayBaseElement } from '../../overlay.js'; import type { SbbDialogActionsElement } from '../dialog-actions.js'; import type { SbbDialogTitleElement } from '../dialog-title.js'; @@ -24,15 +14,8 @@ import style from './dialog.scss?lit&inline'; import '../../screen-reader-only.js'; -// A global collection of existing dialogs -const dialogRefs: SbbDialogElement[] = []; let nextId = 0; -export type SbbDialogCloseEventDetails = { - returnValue?: any; - closeTarget?: HTMLElement; -}; - /** * It displays an interactive overlay element. * @@ -40,40 +23,17 @@ export type SbbDialogCloseEventDetails = { * @event {CustomEvent} willOpen - Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. * @event {CustomEvent} didOpen - Emits whenever the `sbb-dialog` is opened. * @event {CustomEvent} willClose - Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. - * @event {CustomEvent} didClose - Emits whenever the `sbb-dialog` is closed. + * @event {CustomEvent} didClose - Emits whenever the `sbb-dialog` is closed. * @cssprop [--sbb-dialog-z-index=var(--sbb-overlay-default-z-index)] - To specify a custom stack order, * the `z-index` can be overridden by defining this CSS variable. The default `z-index` of the * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. */ @customElement('sbb-dialog') -export class SbbDialogElement extends SbbNegativeMixin(LitElement) { +export class SbbDialogElement extends SbbOverlayBaseElement { public static override styles: CSSResultGroup = style; - public static readonly events = { - willOpen: 'willOpen', - didOpen: 'didOpen', - willClose: 'willClose', - didClose: 'didClose', - } as const; - - /** - * Backdrop click action. - */ - @property({ attribute: 'backdrop-action' }) public backdropAction: 'close' | 'none' = 'close'; - - /** - * This will be forwarded as aria-label to the relevant nested element. - */ - @property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined; - /* - * The state of the dialog. - */ - private set _state(state: SbbOpenedClosedState) { - this.setAttribute('data-state', state); - } - private get _state(): SbbOpenedClosedState { - return this.getAttribute('data-state') as SbbOpenedClosedState; - } + /** Backdrop click action. */ + @property({ attribute: 'backdrop-action' }) public backdropAction: 'close' | 'none' = 'close'; // We use a timeout as a workaround to the "ResizeObserver loop completed with undelivered notifications" error. // For more details: @@ -82,52 +42,23 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { private _dialogContentResizeObserver = new AgnosticResizeObserver(() => setTimeout(() => this._onContentResize()), ); - private _ariaLiveRef!: SbbScreenReaderOnlyElement; - private _ariaLiveRefToggle = false; - - /** Emits whenever the `sbb-dialog` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbDialogElement.events.willOpen); - - /** Emits whenever the `sbb-dialog` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbDialogElement.events.didOpen); - - /** Emits whenever the `sbb-dialog` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter(this, SbbDialogElement.events.willClose); - - /** Emits whenever the `sbb-dialog` is closed. */ - private _didClose: EventEmitter = new EventEmitter( - this, - SbbDialogElement.events.didClose, - ); private _dialogTitleElement: SbbDialogTitleElement | null = null; private _dialogTitleHeight?: number; private _dialogContentElement: HTMLElement | null = null; private _dialogActionsElement: SbbDialogActionsElement | null = null; - private _dialogCloseElement?: HTMLElement; - private _dialogController!: AbortController; - private _openDialogController!: AbortController; - private _focusHandler = new SbbFocusHandler(); - private _scrollHandler = new SbbScrollHandler(); - private _returnValue: any; private _isPointerDownEventOnDialog: boolean = false; private _overflows: boolean = false; private _lastScroll = 0; private _dialogId = `sbb-dialog-${nextId++}`; + protected closeAttribute: string = 'sbb-dialog-close'; - // Last element which had focus before the dialog was opened. - private _lastFocusedElement?: HTMLElement; - - private _language = new SbbLanguageController(this); - - /** - * Opens the dialog element. - */ + /** Opens the component. */ public open(): void { - if (this._state !== 'closed') { + if (this.state !== 'closed') { return; } - this._lastFocusedElement = document.activeElement as HTMLElement; + this.lastFocusedElement = document.activeElement as HTMLElement; // Initialize dialog elements this._dialogTitleElement = this.querySelector('sbb-dialog-title'); @@ -137,50 +68,118 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { this._syncNegative(); - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; // Add this dialog to the global collection - dialogRefs.push(this as SbbDialogElement); + overlayRefs.push(this as SbbDialogElement); this._dialogContentResizeObserver.observe(this._dialogContentElement); // Disable scrolling for content below the dialog - this._scrollHandler.disableScroll(); + this.scrollHandler.disableScroll(); } - /** - * Closes the dialog element. - */ - public close(result?: any, target?: HTMLElement): any { - if (this._state !== 'opened') { - return; + public override connectedCallback(): void { + super.connectedCallback(); + + // Close dialog on backdrop click + this.addEventListener('pointerdown', this._pointerDownListener, { + signal: this.overlayController.signal, + }); + this.addEventListener('pointerup', this._closeOnBackdropClick, { + signal: this.overlayController.signal, + }); + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + // Synchronize the negative state before the first opening to avoid a possible color flash if it is negative. + this._dialogTitleElement = this.querySelector('sbb-dialog-title')!; + this._syncNegative(); + super.firstUpdated(changedProperties); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has('negative')) { + this._syncNegative(); } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._dialogContentResizeObserver.disconnect(); + } - this._returnValue = result; - this._dialogCloseElement = target; - const eventData = { - returnValue: this._returnValue, - closeTarget: this._dialogCloseElement, - }; + protected override attachOpenOverlayEvents(): void { + super.attachOpenOverlayEvents(); - if (!this._willClose.emit(eventData)) { - return; + // If the content overflows, show/hide the dialog header on scroll. + this._dialogContentElement?.addEventListener('scroll', () => this._onContentScroll(), { + passive: true, + signal: this.openOverlayController.signal, + }); + window.addEventListener('resize', () => this._setHideHeaderDataAttribute(false), { + signal: this.openOverlayController.signal, + }); + } + + // Wait for dialog transition to complete. + // 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. + protected onOverlayAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); + applyInertMechanism(this); + this.attachOpenOverlayEvents(); + this.setOverlayFocus(); + // Use timeout to read label after focused element + setTimeout(() => + this.setAriaLiveRefContent( + this.accessibilityLabel || this._dialogTitleElement?.innerText.trim(), + ), + ); + this.focusHandler.trap(this); + } else if (event.animationName === 'close' && this.state === 'closing') { + this._setHideHeaderDataAttribute(false); + this._dialogContentElement?.scrollTo(0, 0); + this.state = 'closed'; + removeInertMechanism(); + setModalityOnNextFocus(this.lastFocusedElement); + // Manually focus last focused element + this.lastFocusedElement?.focus(); + this.openOverlayController?.abort(); + this.focusHandler.disconnect(); + this._dialogContentResizeObserver.disconnect(); + this.removeInstanceFromGlobalCollection(); + // Enable scrolling for content below the dialog if no dialog is open + !overlayRefs.length && this.scrollHandler.enableScroll(); + this.didClose.emit({ + returnValue: this.returnValue, + closeTarget: this.overlayCloseElement, + }); } - this._state = 'closing'; - this._removeAriaLiveRefContent(); } - // Closes the dialog on "Esc" key pressed. - private _onKeydownEvent(event: KeyboardEvent): void { - if (this._state !== 'opened') { - return; + // Set focus on the first focusable element. + protected setOverlayFocus(): void { + const firstFocusable = getFirstFocusableElement( + Array.from(this.children).filter((e): e is HTMLElement => e instanceof window.HTMLElement), + ); + setModalityOnNextFocus(firstFocusable); + firstFocusable?.focus(); + } + + private _syncNegative(): void { + if (this._dialogTitleElement) { + this._dialogTitleElement.negative = this.negative; } - if (event.key === 'Escape') { - dialogRefs[dialogRefs.length - 1].close(); - return; + if (this._dialogActionsElement) { + this._dialogActionsElement.toggleAttribute('data-negative', this.negative); } } @@ -220,93 +219,6 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { this._lastScroll = currentScroll <= 0 ? 0 : currentScroll; } - public override connectedCallback(): void { - super.connectedCallback(); - this._state ||= 'closed'; - this._dialogController?.abort(); - this._dialogController = new AbortController(); - - // Close dialog on backdrop click - this.addEventListener('pointerdown', this._pointerDownListener, { - signal: this._dialogController.signal, - }); - this.addEventListener('pointerup', this._closeOnBackdropClick, { - signal: this._dialogController.signal, - }); - - if (this._state === 'opened') { - applyInertMechanism(this); - } - } - - protected override firstUpdated(_changedProperties: PropertyValues): void { - this._ariaLiveRef = - this.shadowRoot!.querySelector('sbb-screen-reader-only')!; - - // Synchronize the negative state before the first opening to avoid a possible color flash if it is negative. - this._dialogTitleElement = this.querySelector('sbb-dialog-title')!; - this._syncNegative(); - super.firstUpdated(_changedProperties); - } - - protected override willUpdate(changedProperties: PropertyValues): void { - super.willUpdate(changedProperties); - - if (changedProperties.has('negative')) { - this._syncNegative(); - } - } - - private _syncNegative(): void { - if (this._dialogTitleElement) { - this._dialogTitleElement.negative = this.negative; - } - - if (this._dialogActionsElement) { - this._dialogActionsElement.toggleAttribute('data-negative', this.negative); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._dialogController?.abort(); - this._openDialogController?.abort(); - this._focusHandler.disconnect(); - this._dialogContentResizeObserver.disconnect(); - this._removeInstanceFromGlobalCollection(); - removeInertMechanism(); - } - - private _removeInstanceFromGlobalCollection(): void { - dialogRefs.splice(dialogRefs.indexOf(this as SbbDialogElement), 1); - } - - private _attachOpenDialogEvents(): void { - this._openDialogController = new AbortController(); - // Remove dialog label as soon as it is not needed anymore to prevent accessing it with browse mode. - window.addEventListener( - 'keydown', - async (event: KeyboardEvent) => { - this._removeAriaLiveRefContent(); - await this._onKeydownEvent(event); - }, - { - signal: this._openDialogController.signal, - }, - ); - window.addEventListener('click', () => this._removeAriaLiveRefContent(), { - signal: this._openDialogController.signal, - }); - // If the content overflows, show/hide the dialog header on scroll. - this._dialogContentElement?.addEventListener('scroll', () => this._onContentScroll(), { - passive: true, - signal: this._openDialogController.signal, - }); - window.addEventListener('resize', () => this._setHideHeaderDataAttribute(false), { - signal: this._openDialogController.signal, - }); - } - // Check if the pointerdown event target is triggered on the dialog. private _pointerDownListener = (event: PointerEvent): void => { if (this.backdropAction !== 'close') { @@ -336,87 +248,6 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { } }; - // Close the dialog on click of any element that has the 'sbb-dialog-close' attribute. - private _closeOnSbbDialogCloseClick(event: Event): void { - const dialogCloseElement = event - .composedPath() - .filter((e): e is HTMLElement => e instanceof window.HTMLElement) - .find( - (target) => target.hasAttribute('sbb-dialog-close') && !target.hasAttribute('disabled'), - ); - - if (!dialogCloseElement) { - return; - } - - // Check if the target is a submission element within a form and return the form, if present - const closestForm = - dialogCloseElement.getAttribute('type') === 'submit' - ? (hostContext('form', dialogCloseElement) as HTMLFormElement) - : undefined; - dialogRefs[dialogRefs.length - 1].close(closestForm, dialogCloseElement); - } - - // Wait for dialog transition to complete. - // 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 _onDialogAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); - applyInertMechanism(this); - this._attachOpenDialogEvents(); - this._setDialogFocus(); - // Use timeout to read label after focused element - setTimeout(() => this._setAriaLiveRefContent()); - this._focusHandler.trap(this); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._setHideHeaderDataAttribute(false); - this._dialogContentElement?.scrollTo(0, 0); - this._state = 'closed'; - removeInertMechanism(); - setModalityOnNextFocus(this._lastFocusedElement); - // Manually focus last focused element - this._lastFocusedElement?.focus(); - this._openDialogController?.abort(); - this._focusHandler.disconnect(); - this._dialogContentResizeObserver.disconnect(); - this._removeInstanceFromGlobalCollection(); - // Enable scrolling for content below the dialog if no dialog is open - !dialogRefs.length && this._scrollHandler.enableScroll(); - this._didClose.emit({ - returnValue: this._returnValue, - closeTarget: this._dialogCloseElement, - }); - } - } - - private _setAriaLiveRefContent(): void { - this._ariaLiveRefToggle = !this._ariaLiveRefToggle; - - // Take accessibility label or current string in title section - const label = this.accessibilityLabel || this._dialogTitleElement?.innerText.trim(); - - // If the text content remains the same, on VoiceOver the aria-live region is not announced a second time. - // In order to support reading on every opening, we toggle an invisible space. - this._ariaLiveRef.textContent = `${i18nDialog[this._language.current]}${ - label ? `, ${label}` : '' - }${this._ariaLiveRefToggle ? ' ' : ''}`; - } - - private _removeAriaLiveRefContent(): void { - this._ariaLiveRef.textContent = ''; - } - - // Set focus on the first focusable element. - private _setDialogFocus(): void { - const firstFocusable = getFirstFocusableElement( - Array.from(this.children).filter((e): e is HTMLElement => e instanceof window.HTMLElement), - ); - setModalityOnNextFocus(firstFocusable); - firstFocusable?.focus(); - } - private _setDialogHeaderHeight(): void { this._dialogTitleHeight = this._dialogTitleElement?.shadowRoot!.firstElementChild!.clientHeight; this.style.setProperty('--sbb-dialog-header-height', `${this._dialogTitleHeight}px`); @@ -452,12 +283,12 @@ export class SbbDialogElement extends SbbNegativeMixin(LitElement) { return html`
this._onDialogAnimationEnd(event)} + @animationend=${(event: AnimationEvent) => this.onOverlayAnimationEnd(event)} class="sbb-dialog" id=${this._dialogId} >
this._closeOnSbbDialogCloseClick(event)} + @click=${(event: Event) => this.closeOnSbbOverlayCloseClick(event)} class="sbb-dialog__wrapper" > diff --git a/src/elements/dialog/dialog/readme.md b/src/elements/dialog/dialog/readme.md index d7714597af..6cd597e2b1 100644 --- a/src/elements/dialog/dialog/readme.md +++ b/src/elements/dialog/dialog/readme.md @@ -90,27 +90,27 @@ The `sbb-dialog` component may visually hide the title thanks to the `hideOnScro ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| -------------------- | --------------------- | ------- | --------------------- | --------- | -------------------------------------------------------------------- | -| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the relevant nested element. | -| `backdropAction` | `backdrop-action` | public | `'close' \| 'none'` | `'close'` | Backdrop click action. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | --------------------- | ------- | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | +| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the relevant nested element to describe the purpose of the overlay. | +| `backdropAction` | `backdrop-action` | public | `'close' \| 'none'` | `'close'` | Backdrop click action. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | -------------------------- | ---------------------------------- | ------ | -------------- | -| `close` | public | Closes the dialog element. | `result: any, target: HTMLElement` | `any` | | -| `open` | public | Opens the dialog element. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | --------------------- | ---------------------------------- | ------ | ----------------------- | +| `close` | public | Closes the component. | `result: any, target: HTMLElement` | `any` | SbbOpenCloseBaseElement | +| `open` | public | Opens the component. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ----------------------------------------- | ------------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-dialog` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-dialog` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-dialog` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-dialog` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/menu/menu/menu.ts b/src/elements/menu/menu/menu.ts index b997a37414..28261989b8 100644 --- a/src/elements/menu/menu/menu.ts +++ b/src/elements/menu/menu/menu.ts @@ -1,4 +1,4 @@ -import { type CSSResultGroup, html, LitElement, type TemplateResult } from 'lit'; +import { type CSSResultGroup, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; @@ -10,10 +10,9 @@ import { SbbFocusHandler, setModalityOnNextFocus, } from '../../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../../core/base-elements.js'; import { SbbConnectedAbortController } from '../../core/controllers.js'; import { findReferencedElement, isBreakpoint, SbbScrollHandler } from '../../core/dom.js'; -import { EventEmitter } from '../../core/eventing.js'; -import type { SbbOpenedClosedState } from '../../core/interfaces.js'; import { SbbNamedSlotListMixin } from '../../core/mixins.js'; import { applyInertMechanism, @@ -57,15 +56,9 @@ let nextId = 0; @customElement('sbb-menu') export class SbbMenuElement extends SbbNamedSlotListMixin< SbbMenuButtonElement | SbbMenuLinkElement, - typeof LitElement ->(LitElement) { + typeof SbbOpenCloseBaseElement +>(SbbOpenCloseBaseElement) { public static override styles: CSSResultGroup = style; - public static readonly events = { - willOpen: 'willOpen', - didOpen: 'didOpen', - willClose: 'willClose', - didClose: 'didClose', - } as const; protected override readonly listChildLocalNames = ['sbb-menu-button', 'sbb-menu-link']; /** @@ -89,28 +82,6 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< */ @property({ attribute: 'list-accessibility-label' }) public listAccessibilityLabel?: string; - /** - * The state of the menu. - */ - 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-menu` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbMenuElement.events.willOpen); - - /** Emits whenever the `sbb-menu` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbMenuElement.events.didOpen); - - /** Emits whenever the `sbb-menu` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter(this, SbbMenuElement.events.willClose); - - /** Emits whenever the `sbb-menu` is closed. */ - private _didClose: EventEmitter = new EventEmitter(this, SbbMenuElement.events.didClose); - private _menu!: HTMLDivElement; private _triggerElement: HTMLElement | null = null; private _isPointerDownEventOnMenu: boolean = false; @@ -124,15 +95,15 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< * Opens the menu on trigger click. */ public open(): void { - if (this._state === 'closing' || !this._menu) { + if (this.state === 'closing' || !this._menu) { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; this._setMenuPosition(); this._triggerElement?.setAttribute('aria-expanded', 'true'); @@ -146,15 +117,15 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< * Closes the menu. */ public close(): void { - if (this._state === 'opening') { + if (this.state === 'opening') { return; } - if (!this._willClose.emit()) { + if (!this.willClose.emit()) { return; } - this._state = 'closing'; + this.state = 'closing'; this._triggerElement?.setAttribute('aria-expanded', 'false'); } @@ -191,7 +162,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< // Closes the menu on "Esc" key pressed and traps focus within the menu. private async _onKeydownEvent(event: KeyboardEvent): Promise { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } @@ -215,7 +186,6 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< public override connectedCallback(): void { super.connectedCallback(); - this._state ||= 'closed'; const signal = this._abort.signal; this.addEventListener('click', (e) => this._onClick(e), { signal }); this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal }); @@ -229,7 +199,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< // Validate trigger element and attach event listeners this._configure(this.trigger); - if (this._state === 'opened') { + if (this.state === 'opened') { applyInertMechanism(this); } } @@ -275,7 +245,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< } this.id = this.id || `sbb-menu-${nextId++}`; - setAriaOverlayTriggerAttributes(this._triggerElement, 'menu', this.id, this._state); + setAriaOverlayTriggerAttributes(this._triggerElement, 'menu', this.id, this.state); this._menuController?.abort(); this._menuController = new AbortController(); this._triggerElement.addEventListener('click', () => this.open(), { @@ -331,15 +301,15 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< // 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 _onMenuAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); applyInertMechanism(this); this._setMenuFocus(); this._focusHandler.trap(this); this._attachWindowEvents(); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; + } else if (event.animationName === 'close' && this.state === 'closing') { + this.state = 'closed'; this._menu?.firstElementChild?.scrollTo(0, 0); removeInertMechanism(); setModalityOnNextFocus(this._triggerElement); @@ -350,7 +320,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< this._triggerElement.localName === 'sbb-header-button' || this._triggerElement.localName === 'sbb-header-link', }); - this._didClose.emit(); + this.didClose.emit(); this._windowEventsController?.abort(); this._focusHandler.disconnect(); @@ -373,7 +343,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< !isBreakpoint('medium') || !this._menu || !this._triggerElement || - this._state === 'closing' + this.state === 'closing' ) { return; } diff --git a/src/elements/menu/menu/readme.md b/src/elements/menu/menu/readme.md index a4a2f8e65a..87346ee139 100644 --- a/src/elements/menu/menu/readme.md +++ b/src/elements/menu/menu/readme.md @@ -68,19 +68,19 @@ to identify which actions are active and which are not. ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | -------------------------------- | ---------- | ------ | -------------- | -| `close` | public | Closes the menu. | | `void` | | -| `open` | public | Opens the menu on trigger click. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | -------------------------------- | ---------- | ------ | ----------------------- | +| `close` | public | Closes the menu. | | `void` | SbbOpenCloseBaseElement | +| `open` | public | Opens the menu on trigger click. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------- | ----------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-menu` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-menu` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-menu` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-menu` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ----------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-menu` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-menu` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-menu` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-menu` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/navigation/navigation/navigation.ts b/src/elements/navigation/navigation/navigation.ts index 81bfaa50f8..c0301b980f 100644 --- a/src/elements/navigation/navigation/navigation.ts +++ b/src/elements/navigation/navigation/navigation.ts @@ -1,15 +1,14 @@ import type { CSSResultGroup, TemplateResult } from 'lit'; -import { html, LitElement } from 'lit'; +import { html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; import { SbbFocusHandler, setModalityOnNextFocus } from '../../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../../core/base-elements.js'; import { SbbConnectedAbortController, SbbLanguageController } from '../../core/controllers.js'; import { hostAttributes } from '../../core/decorators.js'; import { findReferencedElement, SbbScrollHandler } from '../../core/dom.js'; -import { EventEmitter } from '../../core/eventing.js'; import { i18nCloseNavigation } from '../../core/i18n.js'; -import type { SbbOpenedClosedState } from '../../core/interfaces.js'; import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; import { AgnosticMutationObserver, AgnosticResizeObserver } from '../../core/observers.js'; import { @@ -51,14 +50,8 @@ const DEBOUNCE_TIME = 150; @hostAttributes({ role: 'navigation', }) -export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { +export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBaseElement) { public static override styles: CSSResultGroup = style; - public static readonly events = { - willOpen: 'willOpen', - didOpen: 'didOpen', - willClose: 'willClose', - didClose: 'didClose', - } as const; /** * The element that will trigger the navigation. @@ -82,16 +75,6 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { | string | undefined; - /** - * The state of the navigation. - */ - private set _state(state: SbbOpenedClosedState) { - this.setAttribute('data-state', state); - } - private get _state(): SbbOpenedClosedState { - return this.getAttribute('data-state') as SbbOpenedClosedState; - } - /** * Whether a navigation section is displayed. */ @@ -101,30 +84,6 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { return this._activeNavigationSection; } - /** Emits whenever the `sbb-navigation` begins the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter( - this, - SbbNavigationElement.events.willOpen, - ); - - /** Emits whenever the `sbb-navigation` is opened. */ - private _didOpen: EventEmitter = new EventEmitter( - this, - SbbNavigationElement.events.didOpen, - ); - - /** Emits whenever the `sbb-navigation` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter( - this, - SbbNavigationElement.events.willClose, - ); - - /** Emits whenever the `sbb-navigation` is closed. */ - private _didClose: EventEmitter = new EventEmitter( - this, - SbbNavigationElement.events.didClose, - ); - private _navigation!: HTMLDivElement; private _navigationContentElement!: HTMLElement; private _triggerElement: HTMLElement | null = null; @@ -145,14 +104,14 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { * Opens the navigation. */ public open(): void { - if (this._state !== 'closed' || !this._navigation) { + if (this.state !== 'closed' || !this._navigation) { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; this._checkActiveActions(); this._checkActiveSection(); this.startUpdate(); @@ -180,14 +139,14 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { * Closes the navigation. */ public close(): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } - if (!this._willClose.emit()) { + if (!this.willClose.emit()) { return; } - this._state = 'closing'; + this.state = 'closing'; this.startUpdate(); this._triggerElement?.setAttribute('aria-expanded', 'false'); } @@ -218,7 +177,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { return; } - setAriaOverlayTriggerAttributes(this._triggerElement, 'menu', this.id, this._state); + setAriaOverlayTriggerAttributes(this._triggerElement, 'menu', this.id, this.state); this._navigationController?.abort(); this._navigationController = new AbortController(); this._triggerElement.addEventListener('click', () => this.open(), { @@ -233,22 +192,22 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { // 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._state = 'opened'; - this._didOpen.emit(); + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); this._navigationResizeObserver.observe(this); applyInertMechanism(this); this._focusHandler.trap(this, { filter: this._trapFocusFilter }); this._attachWindowEvents(); this._setNavigationFocus(); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; + } else if (event.animationName === 'close' && this.state === 'closing') { + this.state = 'closed'; this._navigationContentElement.scrollTo(0, 0); setModalityOnNextFocus(this._triggerElement); removeInertMechanism(); // To enable focusing other element than the trigger, we need to call focus() a second time. this._triggerElement?.focus(); - this._didClose.emit(); + this.didClose.emit(); this._navigationResizeObserver.unobserve(this); this._resetMarkers(); this._windowEventsController?.abort(); @@ -294,7 +253,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { // Closes the navigation on "Esc" key pressed. private _onKeydownEvent(event: KeyboardEvent): void { - if (this._state === 'opened' && event.key === 'Escape') { + if (this.state === 'opened' && event.key === 'Escape') { this.close(); } } @@ -340,7 +299,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { } private _onNavigationResize(): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } @@ -360,7 +319,6 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { public override connectedCallback(): void { super.connectedCallback(); this.id ||= `sbb-navigation-${nextId++}`; - this._state ||= 'closed'; const signal = this._abort.signal; this.addEventListener('click', (e) => this._handleNavigationClose(e), { signal }); // Validate trigger element and attach event listeners @@ -369,7 +327,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(LitElement) { this.addEventListener('pointerup', (event) => this._closeOnBackdropClick(event), { signal }); this.addEventListener('pointerdown', (event) => this._pointerDownListener(event), { signal }); - if (this._state === 'opened') { + if (this.state === 'opened') { applyInertMechanism(this); } } diff --git a/src/elements/navigation/navigation/readme.md b/src/elements/navigation/navigation/readme.md index 189746db5d..6e2f14d971 100644 --- a/src/elements/navigation/navigation/readme.md +++ b/src/elements/navigation/navigation/readme.md @@ -66,19 +66,19 @@ Similarly, if a navigation action is marked to indicate a selected option (e.g., ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ---------------------- | ---------- | ------ | -------------- | -| `close` | public | Closes the navigation. | | `void` | | -| `open` | public | Opens the navigation. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ---------------------- | ---------- | ------ | ----------------------- | +| `close` | public | Closes the navigation. | | `void` | SbbOpenCloseBaseElement | +| `open` | public | Opens the navigation. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------- | ----------------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-navigation` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-navigation` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ----------------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-navigation` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-navigation` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-navigation` begins the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/notification/notification.ts b/src/elements/notification/notification.ts index 692651b449..90156f2091 100644 --- a/src/elements/notification/notification.ts +++ b/src/elements/notification/notification.ts @@ -38,6 +38,7 @@ const DEBOUNCE_TIME = 150; */ @customElement('sbb-notification') export class SbbNotificationElement extends LitElement { + // FIXME inheriting from SbbOpenCloseBaseElement requires: https://github.com/open-wc/custom-elements-manifest/issues/253 public static override styles: CSSResultGroup = style; public static readonly events = { willOpen: 'willOpen', diff --git a/src/elements/overlay.ts b/src/elements/overlay.ts index 507a410c82..61d328b624 100644 --- a/src/elements/overlay.ts +++ b/src/elements/overlay.ts @@ -1 +1,2 @@ export * from './overlay/overlay.js'; +export * from './overlay/overlay-base-element.js'; diff --git a/src/elements/overlay/overlay-base-element.ts b/src/elements/overlay/overlay-base-element.ts new file mode 100644 index 0000000000..f135ada3c2 --- /dev/null +++ b/src/elements/overlay/overlay-base-element.ts @@ -0,0 +1,157 @@ +import { type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SbbFocusHandler } from '../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; +import { SbbLanguageController } from '../core/controllers.js'; +import { hostContext, SbbScrollHandler } from '../core/dom.js'; +import { EventEmitter } from '../core/eventing.js'; +import { i18nDialog } from '../core/i18n.js'; +import type { SbbOverlayCloseEventDetails } from '../core/interfaces.js'; +import { SbbNegativeMixin } from '../core/mixins.js'; +import { applyInertMechanism, removeInertMechanism } from '../core/overlay.js'; +import type { SbbScreenReaderOnlyElement } from '../screen-reader-only.js'; + +// A global collection of existing overlays. +export const overlayRefs: SbbOverlayBaseElement[] = []; + +export abstract class SbbOverlayBaseElement extends SbbNegativeMixin(SbbOpenCloseBaseElement) { + /** This will be forwarded as aria-label to the relevant nested element to describe the purpose of the overlay. */ + @property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined; + + /** Emits whenever the component is closed. */ + protected override didClose: EventEmitter = new EventEmitter( + this, + SbbOverlayBaseElement.events.didClose, + ); + + // The last element which had focus before the component was opened. + protected lastFocusedElement?: HTMLElement; + protected overlayCloseElement?: HTMLElement; + protected overlayController!: AbortController; + protected openOverlayController!: AbortController; + protected focusHandler = new SbbFocusHandler(); + protected scrollHandler = new SbbScrollHandler(); + protected returnValue: any; + protected ariaLiveRefToggle = false; + protected ariaLiveRef!: SbbScreenReaderOnlyElement; + protected language = new SbbLanguageController(this); + + protected abstract onOverlayAnimationEnd(event: AnimationEvent): void; + protected abstract setOverlayFocus(): void; + protected abstract closeAttribute: string; + + /** Closes the component. */ + public close(result?: any, target?: HTMLElement): any { + if (this.state !== 'opened') { + return; + } + + this.returnValue = result; + this.overlayCloseElement = target; + const eventData: SbbOverlayCloseEventDetails = { + returnValue: this.returnValue, + closeTarget: this.overlayCloseElement, + }; + + if (!this.willClose.emit(eventData)) { + return; + } + this.state = 'closing'; + this.removeAriaLiveRefContent(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.overlayController?.abort(); + this.overlayController = new AbortController(); + + if (this.state === 'opened') { + applyInertMechanism(this); + } + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + this.ariaLiveRef = + this.shadowRoot!.querySelector('sbb-screen-reader-only')!; + + super.firstUpdated(changedProperties); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.overlayController?.abort(); + this.openOverlayController?.abort(); + this.focusHandler.disconnect(); + this.removeInstanceFromGlobalCollection(); + removeInertMechanism(); + } + + protected attachOpenOverlayEvents(): void { + this.openOverlayController = new AbortController(); + // Remove overlay label as soon as it is not needed any more to prevent accessing it with browse mode. + window.addEventListener( + 'keydown', + (event: KeyboardEvent) => { + this.removeAriaLiveRefContent(); + this.onKeydownEvent(event); + }, + { + signal: this.openOverlayController.signal, + }, + ); + window.addEventListener('click', () => this.removeAriaLiveRefContent(), { + signal: this.openOverlayController.signal, + }); + } + + protected onKeydownEvent(event: KeyboardEvent): void { + if (this.state !== 'opened') { + return; + } + + if (event.key === 'Escape') { + overlayRefs[overlayRefs.length - 1].close(); + return; + } + } + + protected removeInstanceFromGlobalCollection(): void { + overlayRefs.splice(overlayRefs.indexOf(this as SbbOverlayBaseElement), 1); + } + + // Close the component on click of any element that has the `closeAttribute` attribute. + protected closeOnSbbOverlayCloseClick(event: Event): void { + const overlayCloseElement = event + .composedPath() + .filter((e): e is HTMLElement => e instanceof window.HTMLElement) + .find( + (target) => target.hasAttribute(this.closeAttribute) && !target.hasAttribute('disabled'), + ); + + if (!overlayCloseElement) { + return; + } + + // Check if the target is a submission element within a form and return the form, if present + const closestForm = + overlayCloseElement.getAttribute('type') === 'submit' + ? (hostContext('form', overlayCloseElement) as HTMLFormElement) + : undefined; + overlayRefs[overlayRefs.length - 1].close(closestForm, overlayCloseElement); + } + + protected removeAriaLiveRefContent(): void { + this.ariaLiveRef.textContent = ''; + } + + protected setAriaLiveRefContent(label?: string): void { + this.ariaLiveRefToggle = !this.ariaLiveRefToggle; + + // If the text content remains the same, on VoiceOver the aria-live region is not announced a second time. + // In order to support reading on every opening, we toggle an invisible space. + this.ariaLiveRef.textContent = `${i18nDialog[this.language.current]}${ + label ? `, ${label}` : '' + }${this.ariaLiveRefToggle ? ' ' : ''}`; + } +} diff --git a/src/elements/overlay/overlay.ts b/src/elements/overlay/overlay.ts index b941da4c3f..740d01e577 100644 --- a/src/elements/overlay/overlay.ts +++ b/src/elements/overlay/overlay.ts @@ -1,33 +1,20 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { LitElement, nothing } from 'lit'; +import type { CSSResultGroup, TemplateResult } from 'lit'; +import { nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { html, unsafeStatic } from 'lit/static-html.js'; -import { getFirstFocusableElement, SbbFocusHandler, setModalityOnNextFocus } from '../core/a11y.js'; -import { SbbLanguageController } from '../core/controllers.js'; -import { hostContext, SbbScrollHandler } from '../core/dom.js'; +import { getFirstFocusableElement, setModalityOnNextFocus } from '../core/a11y.js'; import { EventEmitter } from '../core/eventing.js'; -import { i18nCloseDialog, i18nDialog, i18nGoBack } from '../core/i18n.js'; -import type { SbbOpenedClosedState } from '../core/interfaces.js'; -import { SbbNegativeMixin } from '../core/mixins.js'; +import { i18nCloseDialog, i18nGoBack } from '../core/i18n.js'; import { applyInertMechanism, removeInertMechanism } from '../core/overlay.js'; -import type { SbbScreenReaderOnlyElement } from '../screen-reader-only.js'; +import { overlayRefs, SbbOverlayBaseElement } from './overlay-base-element.js'; import style from './overlay.scss?lit&inline'; - import '../button/secondary-button.js'; import '../button/transparent-button.js'; import '../container.js'; import '../screen-reader-only.js'; -// A global collection of existing overlays -const overlayRefs: SbbOverlayElement[] = []; - -export type SbbOverlayCloseEventDetails = { - returnValue?: any; - closeTarget?: HTMLElement; -}; - /** * It displays an interactive overlay element. * @@ -42,9 +29,11 @@ export type SbbOverlayCloseEventDetails = { * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. */ @customElement('sbb-overlay') -export class SbbOverlayElement extends SbbNegativeMixin(LitElement) { +export class SbbOverlayElement extends SbbOverlayBaseElement { public static override styles: CSSResultGroup = style; - public static readonly events = { + + // FIXME using ...super.events requires: https://github.com/sbb-design-systems/lyne-components/issues/2600 + public static override readonly events = { willOpen: 'willOpen', didOpen: 'didOpen', willClose: 'willClose', @@ -58,253 +47,85 @@ export class SbbOverlayElement extends SbbNegativeMixin(LitElement) { */ @property({ reflect: true, type: Boolean }) public expanded = false; - /** - * Whether a back button is displayed next to the title. - */ + /** Whether a back button is displayed next to the title. */ @property({ attribute: 'back-button', type: Boolean }) public backButton = false; - /** - * This will be forwarded as aria-label to the close button element. - */ + /** This will be forwarded as aria-label to the close button element. */ @property({ attribute: 'accessibility-close-label' }) public accessibilityCloseLabel: | string | undefined; - /** - * This will be forwarded as aria-label to the back button element. - */ + /** This will be forwarded as aria-label to the back button element. */ @property({ attribute: 'accessibility-back-label' }) public accessibilityBackLabel: | string | undefined; - /** - * This will be forwarded as aria-label adn will describe the purpose of the dialog. - */ - @property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined; - - /* - * The state of the overlay. - */ - private set _state(state: SbbOpenedClosedState) { - this.setAttribute('data-state', state); - } - private get _state(): SbbOpenedClosedState { - return this.getAttribute('data-state') as SbbOpenedClosedState; - } - - private _ariaLiveRef!: SbbScreenReaderOnlyElement; - private _ariaLiveRefToggle = false; - - /** Emits whenever the `sbb-overlay` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbOverlayElement.events.willOpen); - - /** Emits whenever the `sbb-overlay` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbOverlayElement.events.didOpen); - - /** Emits whenever the `sbb-overlay` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter(this, SbbOverlayElement.events.willClose); - - /** Emits whenever the `sbb-overlay` is closed. */ - private _didClose: EventEmitter = new EventEmitter( - this, - SbbOverlayElement.events.didClose, - ); + protected closeAttribute: string = 'sbb-overlay-close'; /** Emits whenever the back button is clicked. */ private _backClick: EventEmitter = new EventEmitter( this, SbbOverlayElement.events.backClick, ); - private _overlayContentElement: HTMLElement | null = null; - private _overlayCloseElement?: HTMLElement; - private _overlayController!: AbortController; - private _openOverlayController!: AbortController; - private _focusHandler = new SbbFocusHandler(); - private _scrollHandler = new SbbScrollHandler(); - private _returnValue: any; - - // Last element which had focus before the overlay was opened. - private _lastFocusedElement?: HTMLElement; - private _language = new SbbLanguageController(this); - - /** - * Opens the overlay element. - */ + /** Opens the component. */ public open(): void { - if (this._state !== 'closed') { + if (this.state !== 'closed') { return; } - this._lastFocusedElement = document.activeElement as HTMLElement; + this.lastFocusedElement = document.activeElement as HTMLElement; this._overlayContentElement = this.shadowRoot?.querySelector( '.sbb-overlay__content', ) as HTMLElement; - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; // Add this overlay to the global collection overlayRefs.push(this as SbbOverlayElement); // Disable scrolling for content below the overlay - this._scrollHandler.disableScroll(); - } - - /** - * Closes the overlay element. - */ - public close(result?: any, target?: HTMLElement): any { - if (this._state !== 'opened') { - return; - } - - this._returnValue = result; - this._overlayCloseElement = target; - const eventData = { - returnValue: this._returnValue, - closeTarget: this._overlayCloseElement, - }; - - if (!this._willClose.emit(eventData)) { - return; - } - this._state = 'closing'; - this._removeAriaLiveRefContent(); - } - - // Closes the overlay on "Esc" key pressed. - private _onKeydownEvent(event: KeyboardEvent): void { - if (this._state !== 'opened') { - return; - } - - if (event.key === 'Escape') { - overlayRefs[overlayRefs.length - 1].close(); - return; - } - } - - public override connectedCallback(): void { - super.connectedCallback(); - this._state ||= 'closed'; - this._overlayController?.abort(); - this._overlayController = new AbortController(); - - if (this._state === 'opened') { - applyInertMechanism(this); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._overlayController?.abort(); - this._openOverlayController?.abort(); - this._focusHandler.disconnect(); - this._removeInstanceFromGlobalCollection(); - removeInertMechanism(); - } - - protected override firstUpdated(changedProperties: PropertyValues): void { - this._ariaLiveRef = - this.shadowRoot!.querySelector('sbb-screen-reader-only')!; - super.firstUpdated(changedProperties); - } - - private _removeInstanceFromGlobalCollection(): void { - overlayRefs.splice(overlayRefs.indexOf(this), 1); - } - - private _attachOpenOverlayEvents(): void { - this._openOverlayController = new AbortController(); - // Remove overlay label as soon as it is not needed anymore to prevent accessing it with browse mode. - window.addEventListener( - 'keydown', - (event: KeyboardEvent) => { - this._removeAriaLiveRefContent(); - this._onKeydownEvent(event); - }, - { - signal: this._openOverlayController.signal, - }, - ); - window.addEventListener('click', () => this._removeAriaLiveRefContent(), { - signal: this._openOverlayController.signal, - }); - } - - // Close the overlay on click of any element that has the 'sbb-overlay-close' attribute. - private _closeOnSbbOverlayCloseClick(event: Event): void { - const overlayCloseElement = event - .composedPath() - .filter((e): e is HTMLElement => e instanceof window.HTMLElement) - .find( - (target) => target.hasAttribute('sbb-overlay-close') && !target.hasAttribute('disabled'), - ); - - if (!overlayCloseElement) { - return; - } - - // Check if the target is a submission element within a form and return the form, if present - const closestForm = - overlayCloseElement.getAttribute('type') === 'submit' - ? (hostContext('form', overlayCloseElement) as HTMLFormElement) - : undefined; - overlayRefs[overlayRefs.length - 1].close(closestForm, overlayCloseElement); + this.scrollHandler.disableScroll(); } // Wait for overlay transition to complete. - // In rare cases it can be that the animationEnd event is triggered twice. + // 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 _onOverlayAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); + protected onOverlayAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); applyInertMechanism(this); - this._attachOpenOverlayEvents(); - this._setOverlayFocus(); + this.attachOpenOverlayEvents(); + this.setOverlayFocus(); // Use timeout to read label after focused element - setTimeout(() => this._setAriaLiveRefContent()); - this._focusHandler.trap(this); - } else if (event.animationName === 'close' && this._state === 'closing') { + setTimeout(() => this.setAriaLiveRefContent(this.accessibilityLabel)); + this.focusHandler.trap(this); + } else if (event.animationName === 'close' && this.state === 'closing') { this._overlayContentElement?.scrollTo(0, 0); - this._state = 'closed'; + this.state = 'closed'; removeInertMechanism(); - setModalityOnNextFocus(this._lastFocusedElement); + setModalityOnNextFocus(this.lastFocusedElement); // Manually focus last focused element - this._lastFocusedElement?.focus(); - this._openOverlayController?.abort(); - this._focusHandler.disconnect(); - this._removeInstanceFromGlobalCollection(); + this.lastFocusedElement?.focus(); + this.openOverlayController?.abort(); + this.focusHandler.disconnect(); + this.removeInstanceFromGlobalCollection(); // Enable scrolling for content below the overlay if no overlay is open - !overlayRefs.length && this._scrollHandler.enableScroll(); - this._didClose.emit({ - returnValue: this._returnValue, - closeTarget: this._overlayCloseElement, + !overlayRefs.length && this.scrollHandler.enableScroll(); + this.didClose.emit({ + returnValue: this.returnValue, + closeTarget: this.overlayCloseElement, }); } } - private _setAriaLiveRefContent(): void { - this._ariaLiveRefToggle = !this._ariaLiveRefToggle; - - // If the text content remains the same, on VoiceOver the aria-live region is not announced a second time. - // In order to support reading on every opening, we toggle an invisible space. - this._ariaLiveRef.textContent = `${i18nDialog[this._language.current]}${ - this.accessibilityLabel ? `, ${this.accessibilityLabel}` : '' - }${this._ariaLiveRefToggle ? ' ' : ''}`; - } - - private _removeAriaLiveRefContent(): void { - this._ariaLiveRef.textContent = ''; - } - // Set focus on the first focusable element. - private _setOverlayFocus(): void { + protected setOverlayFocus(): void { const firstFocusable = getFirstFocusableElement( Array.from(this.shadowRoot!.children).filter( (e): e is HTMLElement => e instanceof window.HTMLElement, @@ -321,7 +142,7 @@ export class SbbOverlayElement extends SbbNegativeMixin(LitElement) { const closeButton = html` <${unsafeStatic(TAG_NAME)} class="sbb-overlay__close" - aria-label=${this.accessibilityCloseLabel || i18nCloseDialog[this._language.current]} + aria-label=${this.accessibilityCloseLabel || i18nCloseDialog[this.language.current]} ?negative=${this.negative} size="m" type="button" @@ -333,7 +154,7 @@ export class SbbOverlayElement extends SbbNegativeMixin(LitElement) { const backButton = html` <${unsafeStatic(TAG_NAME)} class="sbb-overlay__back" - aria-label=${this.accessibilityBackLabel || i18nGoBack[this._language.current]} + aria-label=${this.accessibilityBackLabel || i18nGoBack[this.language.current]} ?negative=${this.negative} size="m" type="button" @@ -346,11 +167,11 @@ export class SbbOverlayElement extends SbbNegativeMixin(LitElement) { return html`
this._onOverlayAnimationEnd(event)} + @animationend=${(event: AnimationEvent) => this.onOverlayAnimationEnd(event)} class="sbb-overlay" >
this._closeOnSbbOverlayCloseClick(event)} + @click=${(event: Event) => this.closeOnSbbOverlayCloseClick(event)} class="sbb-overlay__wrapper" >
diff --git a/src/elements/overlay/readme.md b/src/elements/overlay/readme.md index 9ad281968a..c450a8a593 100644 --- a/src/elements/overlay/readme.md +++ b/src/elements/overlay/readme.md @@ -78,27 +78,27 @@ When using a button to trigger the overlay, ensure to manage the appropriate ARI | ------------------------- | --------------------------- | ------- | ---------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | | `accessibilityBackLabel` | `accessibility-back-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the back button element. | | `accessibilityCloseLabel` | `accessibility-close-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the close button element. | -| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label adn will describe the purpose of the dialog. | +| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the relevant nested element to describe the purpose of the overlay. | | `backButton` | `back-button` | public | `boolean` | `false` | Whether a back button is displayed next to the title. | | `expanded` | `expanded` | public | `boolean` | `false` | Whether to allow the overlay content to stretch to full width. By default, the content has the appropriate page size. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | --------------------------- | ---------------------------------- | ------ | -------------- | -| `close` | public | Closes the overlay element. | `result: any, target: HTMLElement` | `any` | | -| `open` | public | Opens the overlay element. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | --------------------- | ---------------------------------- | ------ | ----------------------- | +| `close` | public | Closes the component. | `result: any, target: HTMLElement` | `any` | SbbOpenCloseBaseElement | +| `open` | public | Opens the component. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-overlay` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-overlay` is opened. | | -| `requestBackAction` | `CustomEvent` | Emits whenever the back button is clicked. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-overlay` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-overlay` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-overlay` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-overlay` is opened. | SbbOpenCloseBaseElement | +| `requestBackAction` | `CustomEvent` | Emits whenever the back button is clicked. | | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-overlay` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-overlay` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/popover/popover/popover.ts b/src/elements/popover/popover/popover.ts index 254f4405ad..241f2cbed9 100644 --- a/src/elements/popover/popover/popover.ts +++ b/src/elements/popover/popover/popover.ts @@ -1,5 +1,5 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; +import { html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; @@ -9,6 +9,7 @@ import { SbbFocusHandler, setModalityOnNextFocus, } from '../../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../../core/base-elements.js'; import { SbbLanguageController } from '../../core/controllers.js'; import { findReferencedElement } from '../../core/dom.js'; import { composedPathHasAttribute, EventEmitter } from '../../core/eventing.js'; @@ -46,14 +47,8 @@ const popoversRef = new Set(); * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. */ @customElement('sbb-popover') -export class SbbPopoverElement extends LitElement { +export class SbbPopoverElement extends SbbOpenCloseBaseElement { public static override styles: CSSResultGroup = style; - public static readonly events = { - willOpen: 'willOpen', - didOpen: 'didOpen', - willClose: 'willClose', - didClose: 'didClose', - } as const; /** * The element that will trigger the popover overlay. @@ -79,28 +74,14 @@ export class SbbPopoverElement extends LitElement { | string | undefined; - /** The state of the popover. */ - 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-popover` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbPopoverElement.events.willOpen); - - /** Emits whenever the `sbb-popover` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbPopoverElement.events.didOpen); - /** Emits whenever the `sbb-popover` begins the closing transition. */ - private _willClose: EventEmitter<{ closeTarget?: HTMLElement }> = new EventEmitter( + protected override willClose: EventEmitter<{ closeTarget?: HTMLElement }> = new EventEmitter( this, SbbPopoverElement.events.willClose, ); /** Emits whenever the `sbb-popover` is closed. */ - private _didClose: EventEmitter<{ closeTarget?: HTMLElement }> = new EventEmitter( + protected override didClose: EventEmitter<{ closeTarget?: HTMLElement }> = new EventEmitter( this, SbbPopoverElement.events.didClose, ); @@ -122,11 +103,11 @@ export class SbbPopoverElement extends LitElement { /** Opens the popover on trigger click. */ public open(): void { - if ((this._state !== 'closed' && this._state !== 'closing') || !this._overlay) { + if ((this.state !== 'closed' && this.state !== 'closing') || !this._overlay) { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } @@ -138,7 +119,7 @@ export class SbbPopoverElement extends LitElement { } } - this._state = 'opening'; + this.state = 'opening'; this.inert = true; this._setPopoverPosition(); this._triggerElement?.setAttribute('aria-expanded', 'true'); @@ -148,23 +129,23 @@ export class SbbPopoverElement extends LitElement { /** Closes the popover. */ public close(target?: HTMLElement): void { - if (this._state !== 'opened' && this._state !== 'opening') { + if (this.state !== 'opened' && this.state !== 'opening') { return; } this._popoverCloseElement = target; - if (!this._willClose.emit({ closeTarget: target })) { + if (!this.willClose.emit({ closeTarget: target })) { return; } - this._state = 'closing'; + this.state = 'closing'; this.inert = true; this._triggerElement?.setAttribute('aria-expanded', 'false'); } // Closes the popover on "Esc" key pressed and traps focus within the popover. private _onKeydownEvent(event: KeyboardEvent): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } @@ -194,7 +175,7 @@ export class SbbPopoverElement extends LitElement { // Validate trigger element and attach event listeners this._configure(); - this._state = 'closed'; + this.state = 'closed'; popoversRef.add(this as SbbPopoverElement); } @@ -241,7 +222,7 @@ export class SbbPopoverElement extends LitElement { return; } - setAriaOverlayTriggerAttributes(this._triggerElement, 'dialog', this.id, this._state); + setAriaOverlayTriggerAttributes(this._triggerElement, 'dialog', this.id, this.state); // Check whether the trigger can be hovered. Some devices might interpret the media query (hover: hover) differently, // and not respect the fallback mechanism on the click. Therefore, the following is preferred to identify @@ -274,7 +255,7 @@ export class SbbPopoverElement extends LitElement { this._triggerElement.addEventListener( 'click', () => { - this._state === 'closed' && this.open(); + this.state === 'closed' && this.open(); }, { signal: this._popoverController.signal, @@ -334,7 +315,7 @@ export class SbbPopoverElement extends LitElement { }; private _onTriggerMouseEnter = (): void => { - if (this._state === 'closed' || this._state === 'closing') { + if (this.state === 'closed' || this.state === 'closing') { this._openTimeout = setTimeout(() => this.open(), this.openDelay); } else { clearTimeout(this._closeTimeout); @@ -342,7 +323,7 @@ export class SbbPopoverElement extends LitElement { }; private _onTriggerMouseLeave = (): void => { - if (this._state === 'opened' || this._state === 'opening') { + if (this.state === 'opened' || this.state === 'opening') { this._closeTimeout = setTimeout(() => this.close(), this.closeDelay); } else { clearTimeout(this._openTimeout); @@ -350,13 +331,13 @@ export class SbbPopoverElement extends LitElement { }; private _onOverlayMouseEnter = (): void => { - if (this._state !== 'opening') { + if (this.state !== 'opening') { clearTimeout(this._closeTimeout); } }; private _onOverlayMouseLeave = (): void => { - if (this._state !== 'opening') { + if (this.state !== 'opening') { this._closeTimeout = setTimeout(() => this.close(), this.closeDelay); } }; @@ -366,17 +347,17 @@ export class SbbPopoverElement extends LitElement { // 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 _onPopoverAnimationEnd(event: AnimationEvent): void { - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); this.inert = false; this._attachWindowEvents(); this._setPopoverFocus(); this._focusHandler.trap(this, { postFilter: (el) => el !== this._overlay, }); - } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; + } else if (event.animationName === 'close' && this.state === 'closing') { + this.state = 'closed'; this._overlay?.firstElementChild?.scrollTo(0, 0); this._overlay?.removeAttribute('tabindex'); @@ -388,7 +369,7 @@ export class SbbPopoverElement extends LitElement { elementToFocus?.focus(); } - this._didClose.emit({ closeTarget: this._popoverCloseElement }); + this.didClose.emit({ closeTarget: this._popoverCloseElement }); this._openStateController?.abort(); this._focusHandler.disconnect(); } @@ -422,7 +403,7 @@ export class SbbPopoverElement extends LitElement { setTimeout(() => { if (document.visibilityState !== 'hidden') { this._overlay?.removeAttribute('tabindex'); - if (this._state === 'opened' || this._state === 'opening') { + if (this.state === 'opened' || this.state === 'opening') { this._skipCloseFocus = true; } this.close(); diff --git a/src/elements/popover/popover/readme.md b/src/elements/popover/popover/readme.md index 578cac9f3d..0f0023961a 100644 --- a/src/elements/popover/popover/readme.md +++ b/src/elements/popover/popover/readme.md @@ -89,19 +89,19 @@ Overlays should always contain a heading level 2 title. It can be visually hidde ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ----------------------------------- | --------------------- | ------ | -------------- | -| `close` | public | Closes the popover. | `target: HTMLElement` | `void` | | -| `open` | public | Opens the popover on trigger click. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ----------------------------------- | --------------------- | ------ | ----------------------- | +| `close` | public | Closes the popover. | `target: HTMLElement` | `void` | SbbOpenCloseBaseElement | +| `open` | public | Opens the popover on trigger click. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------------------------------- | -------------------------------------------------------------------------------- | -------------- | -| `didClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the `sbb-popover` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-popover` is opened. | | -| `willClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the `sbb-popover` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-popover` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------- | +| `didClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the `sbb-popover` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-popover` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the `sbb-popover` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-popover` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/select/readme.md b/src/elements/select/readme.md index 0265165e48..17bc9967d6 100644 --- a/src/elements/select/readme.md +++ b/src/elements/select/readme.md @@ -116,23 +116,23 @@ Opened panel: ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ----------------- | ------- | --------------------------------- | ---------- | -------- | -------------- | -| `close` | public | Closes the selection panel. | | `void` | | -| `getDisplayValue` | public | Gets the current displayed value. | | `string` | | -| `open` | public | Opens the selection panel. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ----------------- | ------- | --------------------------------- | ---------- | -------- | ----------------------- | +| `close` | public | Closes the selection panel. | | `void` | SbbOpenCloseBaseElement | +| `getDisplayValue` | public | Gets the current displayed value. | | `string` | | +| `open` | public | Opens the selection panel. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------- | -------------------------------------------------------------------------------- | -------------- | -| `change` | `CustomEvent` | Notifies that the component's value has changed. | | -| `didChange` | `CustomEvent` | Deprecated. used for React. Will probably be removed once React 19 is available. | | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-select` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-select` is opened. | | -| `input` | `CustomEvent` | Notifies that an option value has been selected. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-select` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-select` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | -------------------------------------------------------------------------------- | ----------------------- | +| `change` | `CustomEvent` | Notifies that the component's value has changed. | | +| `didChange` | `CustomEvent` | Deprecated. used for React. Will probably be removed once React 19 is available. | | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-select` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-select` is opened. | SbbOpenCloseBaseElement | +| `input` | `CustomEvent` | Notifies that an option value has been selected. | | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-select` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-select` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/select/select.ts b/src/elements/select/select.ts index a97870c440..7bd7a5eed1 100644 --- a/src/elements/select/select.ts +++ b/src/elements/select/select.ts @@ -1,14 +1,14 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; +import { html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; import { getNextElementIndex } from '../core/a11y.js'; +import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; import { SbbConnectedAbortController } from '../core/controllers.js'; import { hostAttributes } from '../core/decorators.js'; import { getDocumentWritingMode, isNextjs, isSafari } from '../core/dom.js'; import { EventEmitter } from '../core/eventing.js'; -import type { SbbOpenedClosedState } from '../core/interfaces.js'; import { SbbDisabledMixin, SbbNegativeMixin, SbbUpdateSchedulerMixin } from '../core/mixins.js'; import { isEventOnElement, overlayGapFixCorners, setOverlayPosition } from '../core/overlay.js'; import type { SbbOptGroupElement, SbbOptionElement } from '../option.js'; @@ -49,10 +49,12 @@ export interface SelectChange { role: ariaRoleOnHost ? 'listbox' : null, }) export class SbbSelectElement extends SbbUpdateSchedulerMixin( - SbbDisabledMixin(SbbNegativeMixin(LitElement)), + SbbDisabledMixin(SbbNegativeMixin(SbbOpenCloseBaseElement)), ) { public static override styles: CSSResultGroup = style; - public static readonly events = { + + // FIXME using ...super.events requires: https://github.com/sbb-design-systems/lyne-components/issues/2600 + public static override readonly events = { didChange: 'didChange', change: 'change', input: 'input', @@ -78,14 +80,6 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( /** Whether the select is readonly. */ @property({ type: Boolean }) public readonly = false; - /** The state of the select. */ - private set _state(state: SbbOpenedClosedState) { - this.setAttribute('data-state', state); - } - private get _state(): SbbOpenedClosedState { - return this.getAttribute('data-state') as SbbOpenedClosedState; - } - /** The value displayed by the component. */ @state() private _displayValue: string | null = null; @@ -109,21 +103,6 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( }, ); - /** Emits whenever the `sbb-select` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbSelectElement.events.willOpen); - - /** Emits whenever the `sbb-select` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbSelectElement.events.didOpen); - - /** Emits whenever the `sbb-select` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter( - this, - SbbSelectElement.events.willClose, - ); - - /** Emits whenever the `sbb-select` is closed. */ - private _didClose: EventEmitter = new EventEmitter(this, SbbSelectElement.events.didClose); - private _overlay!: HTMLElement; private _optionContainer!: HTMLElement; private _originElement!: HTMLElement; @@ -158,27 +137,27 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( /** Opens the selection panel. */ public open(): void { - if (this._state !== 'closed' || !this._overlay || this._options.length === 0) { + if (this.state !== 'closed' || !this._overlay || this._options.length === 0) { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; this._setOverlayPosition(); } /** Closes the selection panel. */ public close(): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } - if (!this._willClose.emit()) { + if (!this.willClose.emit()) { return; } - this._state = 'closing'; + this.state = 'closing'; this._openPanelEventsController.abort(); } @@ -273,8 +252,6 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( this.id ||= this._overlayId; } - this._state ||= 'closed'; - const signal = this._abort.signal; const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); @@ -382,27 +359,27 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( // 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') { + if (event.animationName === 'open' && this.state === 'opening') { this._onOpenAnimationEnd(); - } else if (event.animationName === 'close' && this._state === 'closing') { + } else if (event.animationName === 'close' && this.state === 'closing') { this._onCloseAnimationEnd(); } } private _onOpenAnimationEnd(): void { - this._state = 'opened'; + this.state = 'opened'; this._attachOpenPanelEvents(); this._triggerElement.setAttribute('aria-expanded', 'true'); - this._didOpen.emit(); + this.didOpen.emit(); } private _onCloseAnimationEnd(): void { - this._state = 'closed'; + this.state = 'closed'; this._triggerElement.setAttribute('aria-expanded', 'false'); this._resetActiveElement(); this._optionContainer.scrollTop = 0; - this._didClose.emit(); + this.didClose.emit(); } /** When an option is selected, updates the displayValue; it also closes the select if not `multiple`. */ @@ -465,10 +442,10 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( return; } - if (this._state === 'opened') { + if (this.state === 'opened') { await this._openedPanelKeyboardInteraction(event); } - if (this._state === 'closed') { + if (this.state === 'closed') { await this._closedPanelKeyboardInteraction(event); } } @@ -490,7 +467,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( } private async _openedPanelKeyboardInteraction(event: KeyboardEvent): Promise { - if (this.disabled || this.readonly || this._state !== 'opened') { + if (this.disabled || this.readonly || this.state !== 'opened') { return; } @@ -688,7 +665,7 @@ export class SbbSelectElement extends SbbUpdateSchedulerMixin( } this._triggerElement?.focus(); - switch (this._state) { + switch (this.state) { case 'opened': { this.close(); break; diff --git a/src/elements/selection-panel/selection-panel.ts b/src/elements/selection-panel/selection-panel.ts index 42958f8443..13b4c9e82d 100644 --- a/src/elements/selection-panel/selection-panel.ts +++ b/src/elements/selection-panel/selection-panel.ts @@ -25,6 +25,7 @@ import '../divider.js'; */ @customElement('sbb-selection-panel') export class SbbSelectionPanelElement extends LitElement { + // FIXME inheriting from SbbOpenCloseBaseElement requires: https://github.com/open-wc/custom-elements-manifest/issues/253 public static override styles: CSSResultGroup = style; public static readonly events: Record = { willOpen: 'willOpen', diff --git a/src/elements/toast/readme.md b/src/elements/toast/readme.md index 60ccbc6da4..9ac76c0470 100644 --- a/src/elements/toast/readme.md +++ b/src/elements/toast/readme.md @@ -112,19 +112,19 @@ Unless strictly necessary, we advise you not to wrap it preventively and let the ## Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| ------- | ------- | ------------------------------------------------------------------------------- | ---------- | ------ | -------------- | -| `close` | public | Close the toast. | | `void` | | -| `open` | public | Open the toast. If there are other opened toasts in the page, close them first. | | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ------- | ------- | ------------------------------------------------------------------------------- | ---------- | ------ | ----------------------- | +| `close` | public | Close the toast. | | `void` | SbbOpenCloseBaseElement | +| `open` | public | Open the toast. If there are other opened toasts in the page, close them first. | | `void` | SbbOpenCloseBaseElement | ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------- | ------------------------------------------------------------------------------ | -------------- | -| `didClose` | `CustomEvent` | Emits whenever the `sbb-toast` is closed. | | -| `didOpen` | `CustomEvent` | Emits whenever the `sbb-toast` is opened. | | -| `willClose` | `CustomEvent` | Emits whenever the `sbb-toast` begins the closing transition. Can be canceled. | | -| `willOpen` | `CustomEvent` | Emits whenever the `sbb-toast` starts the opening transition. Can be canceled. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ------------------------------------------------------------------------------ | ----------------------- | +| `didClose` | `CustomEvent` | Emits whenever the `sbb-toast` is closed. | SbbOpenCloseBaseElement | +| `didOpen` | `CustomEvent` | Emits whenever the `sbb-toast` is opened. | SbbOpenCloseBaseElement | +| `willClose` | `CustomEvent` | Emits whenever the `sbb-toast` begins the closing transition. Can be canceled. | SbbOpenCloseBaseElement | +| `willOpen` | `CustomEvent` | Emits whenever the `sbb-toast` starts the opening transition. Can be canceled. | SbbOpenCloseBaseElement | ## CSS Properties diff --git a/src/elements/toast/toast.ts b/src/elements/toast/toast.ts index d8de62e117..cd0d51fb22 100644 --- a/src/elements/toast/toast.ts +++ b/src/elements/toast/toast.ts @@ -1,17 +1,17 @@ import type { CSSResultGroup, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; +import { html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { SbbTransparentButtonElement, SbbTransparentButtonLinkElement } from '../button.js'; +import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; import { SbbConnectedAbortController, SbbLanguageController, SbbSlotStateController, } from '../core/controllers.js'; import { isFirefox } from '../core/dom.js'; -import { composedPathHasAttribute, EventEmitter } from '../core/eventing.js'; +import { composedPathHasAttribute } from '../core/eventing.js'; import { i18nCloseAlert } from '../core/i18n.js'; -import type { SbbOpenedClosedState } from '../core/interfaces.js'; import { SbbIconNameMixin } from '../icon.js'; import type { SbbLinkButtonElement, SbbLinkElement, SbbLinkStaticElement } from '../link.js'; import '../button/transparent-button.js'; @@ -40,14 +40,8 @@ const toastRefs = new Set(); * component is set to `var(--sbb-overlay-default-z-index)` with a value of `1000`. */ @customElement('sbb-toast') -export class SbbToastElement extends SbbIconNameMixin(LitElement) { +export class SbbToastElement extends SbbIconNameMixin(SbbOpenCloseBaseElement) { public static override styles: CSSResultGroup = style; - public static readonly events = { - willOpen: 'willOpen', - didOpen: 'didOpen', - willClose: 'willClose', - didClose: 'didClose', - } as const; /** * The length of time in milliseconds to wait before automatically dismissing the toast. @@ -67,26 +61,6 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { */ @property() public politeness: 'polite' | 'assertive' | 'off' = 'polite'; - /* The state of the toast. */ - 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-toast` starts the opening transition. */ - private _willOpen: EventEmitter = new EventEmitter(this, SbbToastElement.events.willOpen); - - /** Emits whenever the `sbb-toast` is opened. */ - private _didOpen: EventEmitter = new EventEmitter(this, SbbToastElement.events.didOpen); - - /** Emits whenever the `sbb-toast` begins the closing transition. */ - private _willClose: EventEmitter = new EventEmitter(this, SbbToastElement.events.willClose); - - /** Emits whenever the `sbb-toast` is closed. */ - private _didClose: EventEmitter = new EventEmitter(this, SbbToastElement.events.didClose); - private _closeTimeout?: ReturnType; private _abort = new SbbConnectedAbortController(this); private _language = new SbbLanguageController(this); @@ -112,14 +86,14 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { * If there are other opened toasts in the page, close them first. */ public open(): void { - if (this._state !== 'closed') { + if (this.state !== 'closed') { return; } - if (!this._willOpen.emit()) { + if (!this.willOpen.emit()) { return; } - this._state = 'opening'; + this.state = 'opening'; this._closeOtherToasts(); } @@ -127,15 +101,15 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { * Close the toast. */ public close(): void { - if (this._state !== 'opened') { + if (this.state !== 'opened') { return; } - if (!this._willClose.emit()) { + if (!this.willClose.emit()) { return; } clearTimeout(this._closeTimeout); - this._state = 'closing'; + this.state = 'closing'; } // Close the toast on click of any element that has the 'sbb-toast-close' attribute. @@ -154,7 +128,6 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { public override connectedCallback(): void { super.connectedCallback(); - this._state ||= 'closed'; const signal = this._abort.signal; this.addEventListener('click', (e) => this._onClick(e), { signal }); @@ -213,9 +186,9 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { // To avoid entering a corrupt state, exit when state is not expected. private _onToastAnimationEnd(event: AnimationEvent): void { // On toast opened - if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); + if (event.animationName === 'open' && this.state === 'opening') { + this.state = 'opened'; + this.didOpen.emit(); // Start the countdown to close it if (this.timeout) { @@ -224,9 +197,9 @@ export class SbbToastElement extends SbbIconNameMixin(LitElement) { } // On toast closed - if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; - this._didClose.emit(); + if (event.animationName === 'close' && this.state === 'closing') { + this.state = 'closed'; + this.didClose.emit(); } } diff --git a/tools/vite/generate-react-wrappers.ts b/tools/vite/generate-react-wrappers.ts index 38817408a7..23e9696eaa 100644 --- a/tools/vite/generate-react-wrappers.ts +++ b/tools/vite/generate-react-wrappers.ts @@ -156,10 +156,9 @@ function renderTemplate( // If a type or interface needs to be imported, the custom elements analyzer will not // detect/extract these and therefore we need to have a manual list of required // types/interfaces. - const interfaces = new Map().set( - 'SbbValidationChangeEvent', - 'core/interfaces.js', - ); + const interfaces = new Map() + .set('SbbOverlayCloseEventDetails', 'core/interfaces.js') + .set('SbbValidationChangeEvent', 'core/interfaces.js'); for (const customEventType of customEventTypes) { const exportModule = exports.find((e) => e.name === customEventType); if (exportModule) {