diff --git a/.changeset/yellow-shoes-mix.md b/.changeset/yellow-shoes-mix.md new file mode 100644 index 0000000..617e5f2 --- /dev/null +++ b/.changeset/yellow-shoes-mix.md @@ -0,0 +1,5 @@ +--- +'@chialab/loock': major +--- + +Expose the `focusManager` method. diff --git a/src/findFocusableChildren.ts b/src/findFocusableChildren.ts deleted file mode 100644 index 9ed307c..0000000 --- a/src/findFocusableChildren.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { DEFAULT_IGNORE_SELECTORS, DEFAULT_SELECTORS } from './constants'; - -/** - * Find all focusable elements by options. - * @param node The target node. - * @param options The options. - */ -export function findFocusableByOptions( - node: HTMLElement, - options: { - elements?: HTMLElement[] | (() => HTMLElement[]); - include?: string[]; - exclude?: string[]; - } -) { - const { include, exclude, elements } = options; - if (!elements) { - return findFocusableChildren(node, include, exclude); - } - - if (typeof elements === 'function') { - return elements(); - } - - return elements; -} - -/** - * Find all focusable children of a node. - * @param node The target node. - * @param include The selectors to include. - * @param exclude The selectors to exclude. - * @returns The focusable children list. - */ -export function findFocusableChildren( - node: Element, - include: string[] = DEFAULT_SELECTORS, - exclude: string[] = DEFAULT_IGNORE_SELECTORS -) { - return (Array.from(node.querySelectorAll(include.join(', '))) as HTMLElement[]).filter((element) => { - if (exclude.some((selector) => element.matches(selector))) { - return false; - } - - if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'radio') { - const name = (element as HTMLInputElement).name; - const inputs = node.querySelectorAll(`input[type="radio"][name="${name}"]`); - const checked = Array.from(inputs).find((input) => (input as HTMLInputElement).checked); - if (checked) { - if (checked !== element) { - return false; - } - } else if (element !== inputs[0]) { - return false; - } - } - - const { width, height } = element.getBoundingClientRect(); - return !!height && !!width; - }); -} diff --git a/src/focusEnterBehavior.ts b/src/focusEnterBehavior.ts index e3f1757..d3b3ebe 100644 --- a/src/focusEnterBehavior.ts +++ b/src/focusEnterBehavior.ts @@ -1,4 +1,4 @@ -import { focusManager, type FocusManagerOptions } from './focusManager'; +import type { FocusManagerOptions } from './focusManager'; /** * The focus enter options. @@ -26,8 +26,6 @@ export function focusEnterBehavior(node: HTMLElement, options: FocusEnterOptions let focused = false; let connected = false; - const manager = focusManager(node, options); - const onFocusIn = () => { const activeElement = document.activeElement; if (focused || !activeElement) { @@ -77,6 +75,5 @@ export function focusEnterBehavior(node: HTMLElement, options: FocusEnterOptions node.removeEventListener('focusin', onFocusIn); node.removeEventListener('focusout', onFocusOut); }, - manager, }; } diff --git a/src/focusFirstChildBehavior.ts b/src/focusFirstChildBehavior.ts index 914b8b4..e510360 100644 --- a/src/focusFirstChildBehavior.ts +++ b/src/focusFirstChildBehavior.ts @@ -1,5 +1,5 @@ import { focusEnterBehavior } from './focusEnterBehavior'; -import { type FocusManagerOptions } from './focusManager'; +import { focusManager, type FocusManagerOptions } from './focusManager'; import { restoreAttribute } from './helpers'; /** @@ -14,6 +14,7 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusManager let connected = false; let tabIndex: string | null = null; + const manager = focusManager(node, options); const enterBehavior = focusEnterBehavior(node, { ...options, onEnter() { @@ -24,7 +25,6 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusManager restoreAttribute(node, 'tabindex', tabIndex); }, }); - const { manager } = enterBehavior; const onFocus = () => { const elements = manager.findFocusable(); @@ -67,6 +67,5 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusManager activeElement = null; node.removeEventListener('focus', onFocus, true); }, - manager, }; } diff --git a/src/focusManager.ts b/src/focusManager.ts index f29c4e6..34ea12f 100644 --- a/src/focusManager.ts +++ b/src/focusManager.ts @@ -1,4 +1,4 @@ -import { findFocusableByOptions } from './findFocusableChildren'; +import { DEFAULT_IGNORE_SELECTORS, DEFAULT_SELECTORS } from './constants'; export interface FocusManagerOptions { /** @@ -15,6 +15,72 @@ export interface FocusManagerOptions { exclude?: string[]; } +/** + * Find all focusable elements by options. + * @param node The target node. + * @param options The options. + */ +function findFocusableByOptions( + node: HTMLElement, + options: { + elements?: HTMLElement[] | (() => HTMLElement[]); + include?: string[]; + exclude?: string[]; + } +) { + const { include, exclude, elements } = options; + if (!elements) { + return findFocusableChildren(node, include, exclude); + } + + if (typeof elements === 'function') { + return elements(); + } + + return elements; +} + +/** + * Find all focusable children of a node. + * @param node The target node. + * @param include The selectors to include. + * @param exclude The selectors to exclude. + * @returns The focusable children list. + */ +function findFocusableChildren( + node: Element, + include: string[] = DEFAULT_SELECTORS, + exclude: string[] = DEFAULT_IGNORE_SELECTORS +) { + return (Array.from(node.querySelectorAll(include.join(', '))) as HTMLElement[]).filter((element) => { + if (exclude.some((selector) => element.matches(selector))) { + return false; + } + + if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'radio') { + const name = (element as HTMLInputElement).name; + const inputs = node.querySelectorAll(`input[type="radio"][name="${name}"]`); + const checked = Array.from(inputs).find((input) => (input as HTMLInputElement).checked); + if (checked) { + if (checked !== element) { + return false; + } + } else if (element !== inputs[0]) { + return false; + } + } + + const { width, height } = element.getBoundingClientRect(); + return !!height && !!width; + }); +} + +/** + * Create a focus manager. + * @param node The target node. + * @param options The options. + * @returns The focus manager. + */ export function focusManager(node: HTMLElement, options: FocusManagerOptions = {}) { return { findFocusable() { diff --git a/src/focusTrapBehavior.ts b/src/focusTrapBehavior.ts index b3ac8b3..35082ac 100644 --- a/src/focusTrapBehavior.ts +++ b/src/focusTrapBehavior.ts @@ -1,46 +1,5 @@ import { focusManager, type FocusManagerOptions } from './focusManager'; -import { restoreAttribute } from './helpers'; - -/** - * Inert node ancestors up to the root node. - * @param node The node to start from. - * @param until The root node. - * @returns A list of functions to restore the original state. - */ -function inertTree(node: HTMLElement, until: HTMLElement = document.documentElement): () => void { - const parentNode = node.parentNode as HTMLElement; - if (!parentNode) { - return () => {}; - } - - let restore = until !== parentNode ? inertTree(parentNode, until) : () => {}; - - const children = /** @type {HTMLElement[]} */ Array.from(parentNode.children); - for (let i = 0; i < children.length; i++) { - const child = children[i] as HTMLElement; - if (child === node) { - continue; - } - - const inert = child.inert; - const tabindex = child.getAttribute('tabindex'); - const ariaHidden = child.getAttribute('aria-hidden'); - // Inert elements are not focusable nor clickable - child.inert = true; - // Some inert elements, like video, disable click but not tabbing - child.setAttribute('tabindex', '-1'); - // Fallback for browsers that don't support inert - child.setAttribute('aria-hidden', 'true'); - restore = ((prev) => () => { - prev(); - child.inert = inert; - restoreAttribute(child, 'tabindex', tabindex); - restoreAttribute(child, 'aria-hidden', ariaHidden); - })(restore); - } - - return restore; -} +import { inertTree, restoreAttribute } from './helpers'; /** * Create a focus trap helper span. @@ -345,6 +304,5 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = }, connect, disconnect, - manager, }; } diff --git a/src/helpers.ts b/src/helpers.ts index 72823f7..fc7cab1 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -11,3 +11,44 @@ export function restoreAttribute(node: HTMLElement, name: string, value: string node.setAttribute(name, value); } } + +/** + * Inert node ancestors up to the root node. + * @param node The node to start from. + * @param until The root node. + * @returns A list of functions to restore the original state. + */ +export function inertTree(node: HTMLElement, until: HTMLElement = document.documentElement): () => void { + const parentNode = node.parentNode as HTMLElement; + if (!parentNode) { + return () => {}; + } + + let restore = until !== parentNode ? inertTree(parentNode, until) : () => {}; + + const children = /** @type {HTMLElement[]} */ Array.from(parentNode.children); + for (let i = 0; i < children.length; i++) { + const child = children[i] as HTMLElement; + if (child === node) { + continue; + } + + const inert = child.inert; + const tabindex = child.getAttribute('tabindex'); + const ariaHidden = child.getAttribute('aria-hidden'); + // Inert elements are not focusable nor clickable + child.inert = true; + // Some inert elements, like video, disable click but not tabbing + child.setAttribute('tabindex', '-1'); + // Fallback for browsers that don't support inert + child.setAttribute('aria-hidden', 'true'); + restore = ((prev) => () => { + prev(); + child.inert = inert; + restoreAttribute(child, 'tabindex', tabindex); + restoreAttribute(child, 'aria-hidden', ariaHidden); + })(restore); + } + + return restore; +} diff --git a/src/index.ts b/src/index.ts index 6f80cc7..9405a06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './constants'; -export * from './findFocusableChildren'; -export * from './focusEnterBehavior'; -export * from './focusFirstChildBehavior'; -export * from './focusTrapBehavior'; -export * from './keyboardNavigationBehavior'; +export { inertTree } from './helpers'; +export { focusManager } from './focusManager'; +export { focusEnterBehavior } from './focusEnterBehavior'; +export { focusFirstChildBehavior } from './focusFirstChildBehavior'; +export { focusTrapBehavior } from './focusTrapBehavior'; +export { keyboardNavigationBehavior } from './keyboardNavigationBehavior'; diff --git a/src/keyboardNavigationBehavior.ts b/src/keyboardNavigationBehavior.ts index 8c816bc..dccebe3 100644 --- a/src/keyboardNavigationBehavior.ts +++ b/src/keyboardNavigationBehavior.ts @@ -1,4 +1,4 @@ -import { findFocusableByOptions } from './findFocusableChildren'; +import { focusManager } from './focusManager'; /** * The options for keyboard navigation. @@ -44,6 +44,7 @@ export function keyboardNavigationBehavior(node: HTMLElement, options: KeyboardN const document = node.ownerDocument; let connected = false; + const manager = focusManager(node, options); const onKeydown = (event: KeyboardEvent) => { if (event.defaultPrevented) { return; @@ -60,7 +61,7 @@ export function keyboardNavigationBehavior(node: HTMLElement, options: KeyboardN homeKeys = ['Home'], endKeys = ['End'], } = options; - const elements = findFocusableByOptions(node, options); + const elements = manager.findFocusable(); const index = elements.findIndex((el) => el === current || el.contains(current)); if (prevKeys.includes(event.key)) { // select previous list item