/** * DOM helpers for inert siblings, scroll-locking the body, and reliable * mount-time autofocus. * * @module bquery/a11y * @since 1.14.0 */ /** * Handle returned by {@link inert} and {@link scrollLock}. * * @since 1.14.0 */ export type DisposableHandle = { /** Releases the effect, restoring previous DOM state. */ release: () => void; }; /** * Marks every sibling of `target` as `inert` and `aria-hidden`, hiding the * surrounding UI from assistive technologies and pointer/keyboard input while * a modal-like overlay is active. The previous values of those attributes are * restored when the handle's `release()` is called. * * Works against the immediate siblings within `target.parentElement`. For * shadow-root or portal scenarios, call once for each affected sibling root. * * @since 1.14.0 * * @example * ```ts * import { inert } from '@bquery/bquery/a11y'; * * const handle = inert(modalEl); * // ...later * handle.release(); * ``` */ export const inert = (target: Element): DisposableHandle => { const parent = target.parentElement; if (!parent) { return { release: () => {} }; } type Saved = { el: Element; inert: string | null; ariaHidden: string | null; }; const saved: Saved[] = []; for (const child of Array.from(parent.children)) { if (child === target) continue; saved.push({ el: child, inert: child.getAttribute('inert'), ariaHidden: child.getAttribute('aria-hidden'), }); child.setAttribute('inert', ''); child.setAttribute('aria-hidden', 'true'); } let released = false; return { release: () => { if (released) return; released = true; for (const item of saved) { if (item.inert === null) item.el.removeAttribute('inert'); else item.el.setAttribute('inert', item.inert); if (item.ariaHidden === null) item.el.removeAttribute('aria-hidden'); else item.el.setAttribute('aria-hidden', item.ariaHidden); } }, }; }; /** * Locks page scrolling by setting `overflow: hidden` on the document element * and body. Preserves the prior inline overflow styles and restores them on * `release()`. * * Multiple concurrent scroll locks are supported via reference counting. * * @since 1.14.0 */ let scrollLockRefs = 0; let savedDocOverflow: string | null = null; let savedBodyOverflow: string | null = null; let savedBodyPaddingRight: string | null = null; export const scrollLock = (): DisposableHandle => { if (typeof document === 'undefined') { return { release: () => {} }; } const root = document.documentElement; const body = document.body; if (!root || !body) { return { release: () => {} }; } if (scrollLockRefs === 0) { savedDocOverflow = root.style.overflow || null; savedBodyOverflow = body.style.overflow || null; savedBodyPaddingRight = body.style.paddingRight || null; // Compensate for the disappearing scrollbar so the layout doesn't shift. const viewportWidth = typeof window === 'undefined' || typeof window.innerWidth !== 'number' ? root.clientWidth : window.innerWidth; const scrollbar = viewportWidth - root.clientWidth; if (scrollbar > 0) { body.style.paddingRight = `${scrollbar}px`; } root.style.overflow = 'hidden'; body.style.overflow = 'hidden'; } scrollLockRefs++; let released = false; return { release: () => { if (released) return; released = true; scrollLockRefs = Math.max(0, scrollLockRefs - 1); if (scrollLockRefs === 0) { if (savedDocOverflow === null) { root.style.removeProperty('overflow'); } else { root.style.overflow = savedDocOverflow; } if (savedBodyOverflow === null) { body.style.removeProperty('overflow'); } else { body.style.overflow = savedBodyOverflow; } if (savedBodyPaddingRight === null) { body.style.removeProperty('padding-right'); } else { body.style.paddingRight = savedBodyPaddingRight; } } }, }; }; /** * Internal — resets the scroll-lock ref count. Tests only. * @internal */ export const _resetScrollLockForTests = (): void => { scrollLockRefs = 0; savedDocOverflow = null; savedBodyOverflow = null; savedBodyPaddingRight = null; }; /** * Reliable mount-time focus helper that defers focus to the next animation * frame (or `setTimeout(0)` when rAF is unavailable). Optionally selects the * text content of ``/`