diff --git a/src/elements/alert/alert/readme.md b/src/elements/alert/alert/readme.md index 7e38f31594..467620b726 100644 --- a/src/elements/alert/alert/readme.md +++ b/src/elements/alert/alert/readme.md @@ -88,6 +88,7 @@ As a base rule, opening animations should be active if an alert arrives after th | `animation` | `animation` | public | `'open' \| 'close' \| 'all' \| 'none'` | `'all'` | The enabled animations. | | `href` | `href` | public | `string \| undefined` | | The href value you want to link to. | | `iconName` | `icon-name` | public | `string \| undefined` | `'info'` | Name of the icon which will be forward to the nested `sbb-icon`. Choose the icons from https://icons.app.sbb.ch. Styling is optimized for icons of type HIM-CUS. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `linkContent` | `link-content` | public | `string \| undefined` | | Content of the link. | | `readonly` | `readonly` | public | `boolean` | `false` | Whether the alert is readonly. In readonly mode, there is no dismiss button offered to the user. | | `rel` | `rel` | public | `string \| undefined` | | The relationship of the linked URL as space-separated link types. | diff --git a/src/elements/autocomplete-grid/autocomplete-grid/readme.md b/src/elements/autocomplete-grid/autocomplete-grid/readme.md index d5996c85c2..a529bcefe4 100644 --- a/src/elements/autocomplete-grid/autocomplete-grid/readme.md +++ b/src/elements/autocomplete-grid/autocomplete-grid/readme.md @@ -144,6 +144,7 @@ using `aria-activedescendant` to support navigation though the autocomplete opti | Name | Attribute | Privacy | Type | Default | Description | | ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | | `origin` | `origin` | public | `string \| HTMLElement \| undefined` | | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, it will search for the first 'sbb-form-field' ancestor. | | `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | diff --git a/src/elements/autocomplete/readme.md b/src/elements/autocomplete/readme.md index 50d73055c7..c2e8661ce0 100644 --- a/src/elements/autocomplete/readme.md +++ b/src/elements/autocomplete/readme.md @@ -101,6 +101,7 @@ using `aria-activedescendant` to support navigation though the autocomplete opti | Name | Attribute | Privacy | Type | Default | Description | | ------------------- | --------------------- | ------- | ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | | `origin` | `origin` | public | `string \| HTMLElement \| undefined` | | The element where the autocomplete will attach; accepts both an element's id or an HTMLElement. If not set, it will search for the first 'sbb-form-field' ancestor. | | `originElement` | - | public | `HTMLElement` | | Returns the element where autocomplete overlay is attached to. | diff --git a/src/elements/core/base-elements/open-close-base-element.ts b/src/elements/core/base-elements/open-close-base-element.ts index 23aca7730d..19f2efcab3 100644 --- a/src/elements/core/base-elements/open-close-base-element.ts +++ b/src/elements/core/base-elements/open-close-base-element.ts @@ -27,6 +27,11 @@ export abstract class SbbOpenCloseBaseElement extends LitElement { return this.getAttribute('data-state') as SbbOpenedClosedState; } + /** Whether the element is open. */ + public get isOpen(): boolean { + return this.state === 'opened'; + } + /** Emits whenever the component starts the opening transition. */ protected willOpen: EventEmitter = new EventEmitter( this, @@ -58,3 +63,12 @@ export abstract class SbbOpenCloseBaseElement extends LitElement { this.state ||= 'closed'; } } + +declare global { + interface GlobalEventHandlersEventMap { + willOpen: CustomEvent; + willClose: CustomEvent; + didOpen: CustomEvent; + didClose: CustomEvent; + } +} diff --git a/src/elements/core/controllers.ts b/src/elements/core/controllers.ts index c768396594..378101653a 100644 --- a/src/elements/core/controllers.ts +++ b/src/elements/core/controllers.ts @@ -1,3 +1,4 @@ export * from './controllers/connected-abort-controller.js'; +export * from './controllers/inert-controller.js'; export * from './controllers/language-controller.js'; export * from './controllers/slot-state-controller.js'; diff --git a/src/elements/core/controllers/__snapshots__/inert-controller.spec.snap.js b/src/elements/core/controllers/__snapshots__/inert-controller.spec.snap.js new file mode 100644 index 0000000000..8a78805d85 --- /dev/null +++ b/src/elements/core/controllers/__snapshots__/inert-controller.spec.snap.js @@ -0,0 +1,267 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["inert light DOM should mark inert"] = +`
+ + + +
+
+ +
+`; +/* end snapshot inert light DOM should mark inert */ + +snapshots["inert light DOM should remove inert"] = +`
+
+
+
+
+ +
+
+
+
+
+
+
+`; +/* end snapshot inert light DOM should remove inert */ + +snapshots["inert light DOM stacked should mark inert"] = +`
+ + + + +
+
+
+
+
+`; +/* end snapshot inert light DOM stacked should mark inert */ + +snapshots["inert light DOM stacked should remove inert level 2"] = +`
+ + + +
+
+ +
+`; +/* end snapshot inert light DOM stacked should remove inert level 2 */ + +snapshots["inert light DOM stacked should remove inert level 1"] = +`
+
+
+
+
+ +
+
+
+
+
+
+
+`; +/* end snapshot inert light DOM stacked should remove inert level 1 */ + +snapshots["inert light DOM stacked should handle level skip removal"] = +`
+
+
+
+
+ +
+
+
+
+
+
+
+`; +/* end snapshot inert light DOM stacked should handle level skip removal */ + +snapshots["inert with shadow DOM should mark inert DOM"] = +`
+ + + + + + +
+`; +/* end snapshot inert with shadow DOM should mark inert DOM */ + +snapshots["inert with shadow DOM should mark inert Shadow DOM"] = +` +
+ +
+
+ +
+`; +/* end snapshot inert with shadow DOM should mark inert Shadow DOM */ + +snapshots["inert with shadow DOM should remove inert DOM"] = +`
+
+
+
+
+ + + +
+
+
+
+
+`; +/* end snapshot inert with shadow DOM should remove inert DOM */ + +snapshots["inert with shadow DOM should remove inert Shadow DOM"] = +`
+
+
+
+ Sibling +
+
+
+ + Another sibling + +
+`; +/* end snapshot inert with shadow DOM should remove inert Shadow DOM */ + diff --git a/src/elements/core/controllers/inert-controller.spec.ts b/src/elements/core/controllers/inert-controller.spec.ts new file mode 100644 index 0000000000..f31ef07914 --- /dev/null +++ b/src/elements/core/controllers/inert-controller.spec.ts @@ -0,0 +1,171 @@ +import { expect } from '@open-wc/testing'; +import { html, LitElement, type ReactiveControllerHost, type TemplateResult } from 'lit'; + +import type { SbbOpenCloseBaseElement } from '../base-elements.js'; +import { fixture } from '../testing/private.js'; + +import { SbbInertController } from './inert-controller.js'; + +class ShadowElement extends LitElement { + protected override render(): TemplateResult { + return html`
+
+
Sibling
+
+ Another sibling +
`; + } +} + +customElements.define('shadow-element', ShadowElement); + +describe('inert', () => { + let element: HTMLElement; + let inertElements: Set; + let inertOverlays: Set; + let inertControllerOverlay: SbbInertController; + let inertControllerOverlay2: SbbInertController; + + const createInertController = (overlay: HTMLElement): SbbInertController => + new SbbInertController( + overlay as unknown as ReactiveControllerHost & SbbOpenCloseBaseElement, + inertElements, + inertOverlays, + ); + + // Reset state for each test + beforeEach(() => { + inertElements = new Set(); + inertOverlays = new Set(); + }); + + describe('light DOM', () => { + beforeEach(async () => { + element = await fixture( + html`
+
+
+ +
+
+
+
+
`, + ); + + inertControllerOverlay = createInertController( + element.querySelector('#overlay')!, + ); + inertControllerOverlay2 = createInertController( + element.querySelector('#overlay2')!, + ); + }); + + it('should mark inert', async () => { + inertControllerOverlay.activate(); + + await expect(element).dom.to.equalSnapshot(); + }); + + it('should remove inert', async () => { + inertControllerOverlay.activate(); + inertControllerOverlay.deactivate(); + + await expect(element).dom.to.equalSnapshot(); + }); + + describe('stacked', () => { + it('should mark inert', async () => { + inertControllerOverlay.activate(); + inertControllerOverlay2.activate(); + + await expect(element).dom.to.equalSnapshot(); + }); + + it('should remove inert level 2', async () => { + inertControllerOverlay.activate(); + inertControllerOverlay2.activate(); + inertControllerOverlay2.deactivate(); + + await expect(element).dom.to.equalSnapshot(); + }); + + it('should remove inert level 1', async () => { + inertControllerOverlay.activate(); + inertControllerOverlay2.activate(); + inertControllerOverlay2.deactivate(); + inertControllerOverlay.deactivate(); + + await expect(element).dom.to.equalSnapshot(); + }); + + it('should handle level skip removal', async () => { + inertControllerOverlay.activate(); + inertControllerOverlay2.activate(); + inertControllerOverlay.deactivate(); + inertControllerOverlay2.deactivate(); + + await expect(element).dom.to.equalSnapshot(); + }); + }); + }); + + describe('with shadow DOM', () => { + let shadowElement: ShadowElement; + + beforeEach(async () => { + element = await fixture( + html`
+
+
+ + +
+
+
+
`, + ); + + shadowElement = element.querySelector('shadow-element')!; + inertControllerOverlay = createInertController( + shadowElement.shadowRoot!.querySelector('#overlay')!, + ); + }); + + describe('should mark inert', () => { + beforeEach(async () => { + inertControllerOverlay.activate(); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(shadowElement).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('should remove inert', () => { + beforeEach(async () => { + inertControllerOverlay.activate(); + inertControllerOverlay.deactivate(); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(shadowElement).shadowDom.to.be.equalSnapshot(); + }); + }); + }); +}); + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'shadow-element': ShadowElement; + } +} diff --git a/src/elements/core/controllers/inert-controller.ts b/src/elements/core/controllers/inert-controller.ts new file mode 100644 index 0000000000..540e8f7c1d --- /dev/null +++ b/src/elements/core/controllers/inert-controller.ts @@ -0,0 +1,119 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import type { SbbOpenCloseBaseElement } from '../base-elements/open-close-base-element.js'; + +const IGNORED_ELEMENTS = ['script', 'head', 'template', 'style']; +const inertElements = new Set(); +const inertOverlays = new Set(); + +export class SbbInertController implements ReactiveController { + public constructor( + private _host: ReactiveControllerHost & SbbOpenCloseBaseElement, + private _inertElements = inertElements, + private _inertOverlays = inertOverlays, + ) { + this._host.addController?.(this); + } + + public hostConnected(): void { + if (this._host.isOpen) { + this.activate(); + } + } + + public hostDisconnected(): void { + if (this._inertOverlays.has(this._host)) { + this.deactivate(); + } + } + + /** Applies inert state to every other element on the page except the overlay. */ + public activate(): void { + // Remove inert state from previous opened overlay + if (this._inertOverlays.size) { + this._removeInertAttributes(); + } + + this._inertOverlays.add(this._host); + this._addInertAttributes(); + } + + /** Removes inert state. */ + public deactivate(): void { + if (this._currentOverlay() !== this._host) { + // If e.g. a component gets disconnected, it could be that it is not the top most. + // In this case, we can directly remove it, as there is currently no inert state applied. + if (this._inertOverlays.has(this._host)) { + this._inertOverlays.delete(this._host); + } else if (import.meta.env.DEV) { + console.warn( + 'Trying to remove inert state of an overlay which never had an applied inert state.', + this._host, + ); + } + + return; + } + + this._removeInertAttributes(); + this._inertOverlays.delete(this._host); + + // If there is as previous opened overlay, set its inert state again. + if (this._inertOverlays.size) { + this._addInertAttributes(); + } + } + + private _currentOverlay(): HTMLElement | null { + return [...this._inertOverlays].pop() ?? null; + } + + private _removeInertAttributes(): void { + this._inertElements.forEach((element: HTMLElement): void => { + if (!element) { + return; + } + + if (element.hasAttribute('data-sbb-inert')) { + element.inert = false; + element.removeAttribute('data-sbb-inert'); + } + + if (element.hasAttribute('data-sbb-aria-hidden')) { + element.removeAttribute('aria-hidden'); + element.removeAttribute('data-sbb-aria-hidden'); + } + }); + this._inertElements.clear(); + } + + private _addInertAttributes(): void { + let element: Element | null = this._currentOverlay(); + + while (element !== document.documentElement && element !== null) { + Array.from((element?.parentElement ?? element?.getRootNode())?.childNodes ?? []) + .filter( + (child): child is HTMLElement => + child !== element && + child instanceof window.HTMLElement && + !IGNORED_ELEMENTS.includes(child.localName), + ) + .forEach((element) => { + this._inertElements.add(element); + + if (!element.inert) { + element.inert = true; + element.toggleAttribute('data-sbb-inert', true); + } + + if (!element.hasAttribute('aria-hidden')) { + element.setAttribute('aria-hidden', 'true'); + element.toggleAttribute('data-sbb-aria-hidden', true); + } + }); + + // We need to pierce through Shadow DOM boundary + element = element?.parentElement ?? (element?.getRootNode() as ShadowRoot)?.host ?? null; + } + } +} diff --git a/src/elements/core/overlay/overlay.ts b/src/elements/core/overlay/overlay.ts index 5fca0ef13a..46f64c5a8c 100644 --- a/src/elements/core/overlay/overlay.ts +++ b/src/elements/core/overlay/overlay.ts @@ -1,8 +1,6 @@ import type { TemplateResult } from 'lit'; import { html } from 'lit'; -const IS_OPEN_OVERLAY_QUERY = `:is(sbb-dialog, sbb-navigation, sbb-menu, sbb-overlay)[data-state='opened']`; - /** * Used to create the "wrapping" effect around the anchor for overlays (es. autocomplete) * Works in conjunction with the 'overlayGapFixCorners()' function in 'overlay.ts' @@ -17,122 +15,3 @@ export function overlayGapFixCorners(): TemplateResult { `; } - -/** - * Returns all the ancestors of the overlay. - */ -const getAncestors = (overlay: HTMLElement, root: HTMLElement): HTMLElement[] => { - let el = overlay.parentElement; - const ancestors: HTMLElement[] = []; - while (el) { - ancestors.push(el); - if (el !== root) { - el = el.parentElement; - } else { - break; - } - } - return ancestors; -}; - -const setDataSbbInert = (el: HTMLElement): void => { - el.setAttribute( - 'data-sbb-inert', - `${+(el.getAttribute('data-sbb-inert') ?? undefined)! + 1 || 0}`, - ); -}; - -/** - * Set the inert and the data-sbb-inert attributes. - */ -const setSbbInert = (el: HTMLElement): void => { - if (!el.inert) { - el.inert = true; - if (el.matches(IS_OPEN_OVERLAY_QUERY)) { - setDataSbbInert(el); - } else { - el.toggleAttribute('data-sbb-inert', true); - } - } - - if (!el.hasAttribute('aria-hidden')) { - el.setAttribute('aria-hidden', 'true'); - el.toggleAttribute('data-sbb-aria-hidden', true); - } -}; - -/** - * Removes the inert and the data-sbb-inert attributes. - */ -const removeSbbInert = (el: HTMLElement): void => { - if (el.hasAttribute('data-sbb-inert')) { - el.inert = false; - el.removeAttribute('data-sbb-inert'); - } - - if (el.hasAttribute('data-sbb-aria-hidden')) { - el.removeAttribute('aria-hidden'); - el.removeAttribute('data-sbb-aria-hidden'); - } -}; - -/** - * Applies inert to every other element on the page except the overlay. - */ -export function applyInertMechanism(overlay: HTMLElement): void { - removeSbbInert(overlay); - - const overlayRoot = overlay.closest('body > *') as HTMLElement; - const documentChildren = Array.from(document.querySelectorAll('body > *')).filter( - (el) => el !== overlayRoot, - ) as HTMLElement[]; - - documentChildren.forEach((el) => setSbbInert(el)); - - let children: HTMLElement[] = []; - const ancestors = getAncestors(overlay, overlayRoot); - - for (const el of ancestors) { - children = children.concat( - Array.from(el.children).filter( - (el: Element) => el !== overlay && !ancestors.includes(el as HTMLElement), - ) as HTMLElement[], - ); - if (el.matches(IS_OPEN_OVERLAY_QUERY)) { - setDataSbbInert(el); - } - } - - children.forEach((el) => setSbbInert(el)); -} - -export function removeInertMechanism(): void { - const openOverlays = Array.from( - document.documentElement.querySelectorAll(IS_OPEN_OVERLAY_QUERY), - ) as HTMLElement[]; - - if (openOverlays.length) { - openOverlays.forEach((el) => { - const newValue = +el.getAttribute('data-sbb-inert')! - 1; - el.setAttribute('data-sbb-inert', `${newValue}`); - - if (newValue && newValue < 0) { - removeSbbInert(el); - Array.from(el.children).forEach((el: Element) => removeSbbInert(el as HTMLElement)); - } - }); - return; - } - - Array.from(document.documentElement.querySelectorAll('[data-sbb-inert]')).forEach((el: Element) => - removeSbbInert(el as HTMLElement), - ); -} -declare global { - interface GlobalEventHandlersEventMap { - willOpen: CustomEvent; - willClose: CustomEvent; - didOpen: CustomEvent; - didClose: CustomEvent; - } -} diff --git a/src/elements/dialog/dialog/dialog.ts b/src/elements/dialog/dialog/dialog.ts index fe188b3d43..b99c60d759 100644 --- a/src/elements/dialog/dialog/dialog.ts +++ b/src/elements/dialog/dialog/dialog.ts @@ -5,7 +5,6 @@ import { html } from 'lit/static-html.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 { overlayRefs, SbbOverlayBaseElement } from '../../overlay.js'; import type { SbbDialogActionsElement } from '../dialog-actions.js'; import type { SbbDialogTitleElement } from '../dialog-title.js'; @@ -133,7 +132,7 @@ export class SbbDialogElement extends SbbOverlayBaseElement { if (event.animationName === 'open' && this.state === 'opening') { this.state = 'opened'; this.didOpen.emit(); - applyInertMechanism(this); + this.inertController.activate(); this.attachOpenOverlayEvents(); this.setOverlayFocus(); // Use timeout to read label after focused element @@ -147,7 +146,7 @@ export class SbbDialogElement extends SbbOverlayBaseElement { this._setHideHeaderDataAttribute(false); this._dialogContentElement?.scrollTo(0, 0); this.state = 'closed'; - removeInertMechanism(); + this.inertController.deactivate(); setModalityOnNextFocus(this.lastFocusedElement); // Manually focus last focused element this.lastFocusedElement?.focus(); diff --git a/src/elements/dialog/dialog/readme.md b/src/elements/dialog/dialog/readme.md index 6cd597e2b1..20712652e3 100644 --- a/src/elements/dialog/dialog/readme.md +++ b/src/elements/dialog/dialog/readme.md @@ -94,6 +94,7 @@ The `sbb-dialog` component may visually hide the title thanks to the `hideOnScro | -------------------- | --------------------- | ------- | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | | `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. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods diff --git a/src/elements/menu/menu/menu.ts b/src/elements/menu/menu/menu.ts index 2a4db75642..5bd09d01e4 100644 --- a/src/elements/menu/menu/menu.ts +++ b/src/elements/menu/menu/menu.ts @@ -11,15 +11,13 @@ import { setModalityOnNextFocus, } from '../../core/a11y.js'; import { SbbOpenCloseBaseElement } from '../../core/base-elements.js'; -import { SbbConnectedAbortController } from '../../core/controllers.js'; +import { SbbConnectedAbortController, SbbInertController } from '../../core/controllers.js'; import { findReferencedElement, isBreakpoint, SbbScrollHandler } from '../../core/dom.js'; import { SbbNamedSlotListMixin } from '../../core/mixins.js'; import { - applyInertMechanism, getElementPosition, isEventOnElement, removeAriaOverlayTriggerAttributes, - removeInertMechanism, setAriaOverlayTriggerAttributes, } from '../../core/overlay.js'; import type { SbbMenuButtonElement } from '../menu-button.js'; @@ -90,6 +88,7 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< private _abort = new SbbConnectedAbortController(this); private _focusHandler = new SbbFocusHandler(); private _scrollHandler = new SbbScrollHandler(); + private _inertController = new SbbInertController(this); /** * Opens the menu on trigger click. @@ -198,10 +197,6 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< }); // Validate trigger element and attach event listeners this._configure(this.trigger); - - if (this.state === 'opened') { - applyInertMechanism(this); - } } public override disconnectedCallback(): void { @@ -209,7 +204,6 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< this._menuController?.abort(); this._windowEventsController?.abort(); this._focusHandler.disconnect(); - removeInertMechanism(); this._scrollHandler.enableScroll(); } @@ -305,14 +299,14 @@ export class SbbMenuElement extends SbbNamedSlotListMixin< if (event.animationName === 'open' && this.state === 'opening') { this.state = 'opened'; this.didOpen.emit(); - applyInertMechanism(this); + this._inertController.activate(); this._setMenuFocus(); this._focusHandler.trap(this); this._attachWindowEvents(); } else if (event.animationName === 'close' && this.state === 'closing') { this.state = 'closed'; this._menu?.firstElementChild?.scrollTo(0, 0); - removeInertMechanism(); + this._inertController.deactivate(); setModalityOnNextFocus(this._triggerElement); // Manually focus last focused element this._triggerElement?.focus({ diff --git a/src/elements/menu/menu/readme.md b/src/elements/menu/menu/readme.md index 87346ee139..775ae83d48 100644 --- a/src/elements/menu/menu/readme.md +++ b/src/elements/menu/menu/readme.md @@ -63,6 +63,7 @@ to identify which actions are active and which are not. | Name | Attribute | Privacy | Type | Default | Description | | ------------------------ | -------------------------- | ------- | ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `listAccessibilityLabel` | `list-accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the inner list. Used only if the menu automatically renders the actions inside as a list. | | `trigger` | `trigger` | public | `string \| HTMLElement \| null` | `null` | The element that will trigger the menu overlay. Accepts both a string (id of an element) or an HTML element. | diff --git a/src/elements/navigation/navigation/navigation.ts b/src/elements/navigation/navigation/navigation.ts index b9cd095201..668e78c818 100644 --- a/src/elements/navigation/navigation/navigation.ts +++ b/src/elements/navigation/navigation/navigation.ts @@ -5,17 +5,19 @@ 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 { + SbbConnectedAbortController, + SbbInertController, + SbbLanguageController, +} from '../../core/controllers.js'; import { hostAttributes } from '../../core/decorators.js'; import { findReferencedElement, SbbScrollHandler } from '../../core/dom.js'; import { i18nCloseNavigation } from '../../core/i18n.js'; import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; import { AgnosticMutationObserver, AgnosticResizeObserver } from '../../core/observers.js'; import { - applyInertMechanism, isEventOnElement, removeAriaOverlayTriggerAttributes, - removeInertMechanism, setAriaOverlayTriggerAttributes, } from '../../core/overlay.js'; import type { SbbNavigationButtonElement } from '../navigation-button.js'; @@ -91,6 +93,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBa private _windowEventsController!: AbortController; private _abort = new SbbConnectedAbortController(this); private _language = new SbbLanguageController(this); + private _inertController = new SbbInertController(this); private _focusHandler = new SbbFocusHandler(); private _scrollHandler = new SbbScrollHandler(); private _isPointerDownEventOnNavigation: boolean = false; @@ -196,7 +199,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBa this.state = 'opened'; this.didOpen.emit(); this._navigationResizeObserver.observe(this); - applyInertMechanism(this); + this._inertController.activate(); this._focusHandler.trap(this, { filter: this._trapFocusFilter }); this._attachWindowEvents(); this._setNavigationFocus(); @@ -204,7 +207,7 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBa this.state = 'closed'; this._navigationContentElement.scrollTo(0, 0); setModalityOnNextFocus(this._triggerElement); - removeInertMechanism(); + this._inertController.deactivate(); // To enable focusing other element than the trigger, we need to call focus() a second time. this._triggerElement?.focus(); this.didClose.emit(); @@ -326,10 +329,6 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBa this._navigationObserver.observe(this, navigationObserverConfig); this.addEventListener('pointerup', (event) => this._closeOnBackdropClick(event), { signal }); this.addEventListener('pointerdown', (event) => this._pointerDownListener(event), { signal }); - - if (this.state === 'opened') { - applyInertMechanism(this); - } } public override disconnectedCallback(): void { @@ -339,7 +338,6 @@ export class SbbNavigationElement extends SbbUpdateSchedulerMixin(SbbOpenCloseBa this._focusHandler.disconnect(); this._navigationObserver.disconnect(); this._navigationResizeObserver.disconnect(); - removeInertMechanism(); this._scrollHandler.enableScroll(); } diff --git a/src/elements/navigation/navigation/readme.md b/src/elements/navigation/navigation/readme.md index 6e2f14d971..270d03a88c 100644 --- a/src/elements/navigation/navigation/readme.md +++ b/src/elements/navigation/navigation/readme.md @@ -62,6 +62,7 @@ Similarly, if a navigation action is marked to indicate a selected option (e.g., | ------------------------- | --------------------------- | ------- | ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | | `accessibilityCloseLabel` | `accessibility-close-label` | public | `\| string \| undefined` | | This will be forwarded as aria-label to the close button element. | | `activeNavigationSection` | - | public | `HTMLElement \| null` | `null` | | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `trigger` | `trigger` | public | `string \| HTMLElement \| null` | `null` | The element that will trigger the navigation. Accepts both a string (id of an element) or an HTML element. | ## Methods diff --git a/src/elements/overlay/overlay-base-element.ts b/src/elements/overlay/overlay-base-element.ts index 100b4c5a24..d49043c3b5 100644 --- a/src/elements/overlay/overlay-base-element.ts +++ b/src/elements/overlay/overlay-base-element.ts @@ -3,13 +3,12 @@ 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 { SbbInertController, 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. @@ -36,6 +35,7 @@ export abstract class SbbOverlayBaseElement extends SbbNegativeMixin(SbbOpenClos protected ariaLiveRefToggle = false; protected ariaLiveRef!: SbbScreenReaderOnlyElement; protected language = new SbbLanguageController(this); + protected inertController = new SbbInertController(this); protected abstract onOverlayAnimationEnd(event: AnimationEvent): void; protected abstract setOverlayFocus(): void; @@ -65,10 +65,6 @@ export abstract class SbbOverlayBaseElement extends SbbNegativeMixin(SbbOpenClos super.connectedCallback(); this.overlayController?.abort(); this.overlayController = new AbortController(); - - if (this.state === 'opened') { - applyInertMechanism(this); - } } protected override firstUpdated(changedProperties: PropertyValues): void { @@ -84,7 +80,6 @@ export abstract class SbbOverlayBaseElement extends SbbNegativeMixin(SbbOpenClos this.openOverlayController?.abort(); this.focusHandler.disconnect(); this.removeInstanceFromGlobalCollection(); - removeInertMechanism(); this.scrollHandler.enableScroll(); } diff --git a/src/elements/overlay/overlay.ts b/src/elements/overlay/overlay.ts index 302c6866fa..395afdb92a 100644 --- a/src/elements/overlay/overlay.ts +++ b/src/elements/overlay/overlay.ts @@ -6,7 +6,6 @@ import { html, unsafeStatic } from 'lit/static-html.js'; import { getFirstFocusableElement, setModalityOnNextFocus } from '../core/a11y.js'; import { EventEmitter } from '../core/eventing.js'; import { i18nCloseDialog, i18nGoBack } from '../core/i18n.js'; -import { applyInertMechanism, removeInertMechanism } from '../core/overlay.js'; import { overlayRefs, SbbOverlayBaseElement } from './overlay-base-element.js'; import style from './overlay.scss?lit&inline'; @@ -99,7 +98,7 @@ export class SbbOverlayElement extends SbbOverlayBaseElement { if (event.animationName === 'open' && this.state === 'opening') { this.state = 'opened'; this.didOpen.emit(); - applyInertMechanism(this); + this.inertController.activate(); this.attachOpenOverlayEvents(); this.setOverlayFocus(); // Use timeout to read label after focused element @@ -108,7 +107,7 @@ export class SbbOverlayElement extends SbbOverlayBaseElement { } else if (event.animationName === 'close' && this.state === 'closing') { this._overlayContentElement?.scrollTo(0, 0); this.state = 'closed'; - removeInertMechanism(); + this.inertController.deactivate(); setModalityOnNextFocus(this.lastFocusedElement); // Manually focus last focused element this.lastFocusedElement?.focus(); diff --git a/src/elements/overlay/readme.md b/src/elements/overlay/readme.md index c450a8a593..f0bd71897f 100644 --- a/src/elements/overlay/readme.md +++ b/src/elements/overlay/readme.md @@ -81,6 +81,7 @@ When using a button to trigger the overlay, ensure to manage the appropriate ARI | `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. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | ## Methods diff --git a/src/elements/popover/popover/readme.md b/src/elements/popover/popover/readme.md index 0f0023961a..f9b0186c79 100644 --- a/src/elements/popover/popover/readme.md +++ b/src/elements/popover/popover/readme.md @@ -84,6 +84,7 @@ Overlays should always contain a heading level 2 title. It can be visually hidde | `closeDelay` | `close-delay` | public | `number` | `0` | Close the popover after a certain delay. | | `hideCloseButton` | `hide-close-button` | public | `boolean \| undefined` | `false` | Whether the close button should be hidden. | | `hoverTrigger` | `hover-trigger` | public | `boolean` | `false` | Whether the popover should be triggered on hover. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `openDelay` | `open-delay` | public | `number` | `0` | Open the popover after a certain delay. | | `trigger` | `trigger` | public | `string \| HTMLElement \| undefined` | | The element that will trigger the popover overlay. Accepts both a string (id of an element) or an HTML element. | diff --git a/src/elements/select/readme.md b/src/elements/select/readme.md index 17bc9967d6..0f1354c9e6 100644 --- a/src/elements/select/readme.md +++ b/src/elements/select/readme.md @@ -107,6 +107,7 @@ Opened panel: | Name | Attribute | Privacy | Type | Default | Description | | ------------- | ------------- | ------- | --------------------------------- | ------- | ------------------------------------------------------------------------ | | `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `multiple` | `multiple` | public | `boolean` | `false` | Whether the select allows for multiple selection. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | | `placeholder` | `placeholder` | public | `string \| undefined` | | The placeholder used if no value has been selected. | diff --git a/src/elements/toast/readme.md b/src/elements/toast/readme.md index 9ac76c0470..fbbabd3ae5 100644 --- a/src/elements/toast/readme.md +++ b/src/elements/toast/readme.md @@ -106,6 +106,7 @@ Unless strictly necessary, we advise you not to wrap it preventively and let the | ------------- | ------------- | ------- | ---------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `dismissible` | `dismissible` | public | `boolean` | `false` | Whether the toast has a close button. | | `iconName` | `icon-name` | public | `string \| undefined` | | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | +| `isOpen` | - | public | `boolean` | | Whether the element is open. | | `politeness` | `politeness` | public | `'polite' \| 'assertive' \| 'off'` | `'polite'` | The ARIA politeness level. Check https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA\_Live\_Regions#live\_regions for further info | | `position` | `position` | public | `SbbToastPosition` | `'bottom-center'` | The position where to place the toast. | | `timeout` | `timeout` | public | `number` | `6000` | The length of time in milliseconds to wait before automatically dismissing the toast. If 0, it stays open indefinitely. |