diff --git a/.changeset/clever-crabs-camp.md b/.changeset/clever-crabs-camp.md new file mode 100644 index 0000000..5177525 --- /dev/null +++ b/.changeset/clever-crabs-camp.md @@ -0,0 +1,5 @@ +--- +'@chialab/loock': minor +--- + +Add focus manager to behaviors. diff --git a/README.md b/README.md index 07fb48d..3c129ed 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,11 @@ Should restore the focus to the previously focused element when the context is e Default: `true`. -#### `trap` - -Should trap the focus inside the context when the context is active. - -Default: `true`. - #### `focusContainer` Focus the context container when the context is entered. -Default: `true`. +Default: `false`. #### `onEnter` diff --git a/package.json b/package.json index f725e6b..06447ed 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "scripts": { "dev": "vite", "build": "rimraf 'dist' 'types' && yarn types && yarn build:esm && yarn build:cjs", - "build:esm": "esbuild src/index.ts --outfile=dist/esm/loock.js --format=esm --bundle --minify --sourcemap", - "build:cjs": "esbuild src/index.ts --outfile=dist/cjs/loock.cjs --format=cjs --bundle --minify --sourcemap", + "build:esm": "esbuild src/index.ts --outfile=dist/esm/loock.js --format=esm --bundle --sourcemap", + "build:cjs": "esbuild src/index.ts --outfile=dist/cjs/loock.cjs --format=cjs --bundle --sourcemap", "types": "tsc --declaration --emitDeclarationOnly --declarationDir 'types' --incremental false", "test": "playwright test", "lint": "prettier --check . && eslint src", diff --git a/src/focusEnterBehavior.ts b/src/focusEnterBehavior.ts index df213c7..e3f1757 100644 --- a/src/focusEnterBehavior.ts +++ b/src/focusEnterBehavior.ts @@ -1,7 +1,9 @@ +import { focusManager, type FocusManagerOptions } from './focusManager'; + /** * The focus enter options. */ -export interface FocusEnterOptions { +export interface FocusEnterOptions extends FocusManagerOptions { /** * The callback when focus enter. */ @@ -24,6 +26,8 @@ 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) { @@ -73,5 +77,6 @@ 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 0e64ab2..914b8b4 100644 --- a/src/focusFirstChildBehavior.ts +++ b/src/focusFirstChildBehavior.ts @@ -1,38 +1,33 @@ -import { findFocusableByOptions } from './findFocusableChildren'; +import { focusEnterBehavior } from './focusEnterBehavior'; +import { type FocusManagerOptions } from './focusManager'; import { restoreAttribute } from './helpers'; -/** - * The options for focus first option on focus enter. - */ -export interface FocusFirstChildOptions { - /** - * The focusable elements. - */ - elements?: HTMLElement[] | (() => HTMLElement[]); - /** - * The selectors for focusable nodes. - */ - include?: string[]; - /** - * The selectors for ignored nodes. - */ - exclude?: string[]; -} - /** * Focus first option on focus enter. * @param node The target element. * @param options The options. * @returns The behavior controller. */ -export function focusFirstChildBehavior(node: HTMLElement, options: FocusFirstChildOptions = {}) { +export function focusFirstChildBehavior(node: HTMLElement, options: FocusManagerOptions = {}) { const document = node.ownerDocument; let activeElement: HTMLElement | null = null; let connected = false; - let tabindex: string | null = null; + let tabIndex: string | null = null; + + const enterBehavior = focusEnterBehavior(node, { + ...options, + onEnter() { + tabIndex = node.getAttribute('tabindex'); + node.setAttribute('tabindex', '-1'); + }, + onExit() { + restoreAttribute(node, 'tabindex', tabIndex); + }, + }); + const { manager } = enterBehavior; const onFocus = () => { - const elements = findFocusableByOptions(node, options); + const elements = manager.findFocusable(); const target = document.activeElement as HTMLElement; if (target === node) { if (activeElement && node.contains(activeElement)) { @@ -55,20 +50,23 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusFirstCh if (connected) { return; } + enterBehavior.connect(); connected = true; activeElement = null; - tabindex = node.getAttribute('tabindex'); - node.setAttribute('tabindex', '-1'); + if (document.activeElement && node.contains(document.activeElement)) { + onFocus(); + } node.addEventListener('focus', onFocus, true); }, disconnect() { if (!connected) { return; } + enterBehavior.disconnect(); connected = false; activeElement = null; - restoreAttribute(node, 'tabindex', tabindex); node.removeEventListener('focus', onFocus, true); }, + manager, }; } diff --git a/src/focusManager.ts b/src/focusManager.ts new file mode 100644 index 0000000..f29c4e6 --- /dev/null +++ b/src/focusManager.ts @@ -0,0 +1,38 @@ +import { findFocusableByOptions } from './findFocusableChildren'; + +export interface FocusManagerOptions { + /** + * The focusable elements. + */ + elements?: HTMLElement[] | (() => HTMLElement[]); + /** + * The selectors for focusable nodes. + */ + include?: string[]; + /** + * The selectors for ignored nodes. + */ + exclude?: string[]; +} + +export function focusManager(node: HTMLElement, options: FocusManagerOptions = {}) { + return { + findFocusable() { + return findFocusableByOptions(node, options); + }, + + /** + * Focus the first focusable child. + */ + focusFirst() { + this.findFocusable().shift()?.focus(); + }, + + /** + * Focus the last focusable child. + */ + focusLast() { + this.findFocusable().pop()?.focus(); + }, + }; +} diff --git a/src/focusTrapBehavior.ts b/src/focusTrapBehavior.ts index 43be566..b3ac8b3 100644 --- a/src/focusTrapBehavior.ts +++ b/src/focusTrapBehavior.ts @@ -1,4 +1,4 @@ -import { findFocusableByOptions } from './findFocusableChildren'; +import { focusManager, type FocusManagerOptions } from './focusManager'; import { restoreAttribute } from './helpers'; /** @@ -72,19 +72,7 @@ export interface FocusTrapImpl { endHelper?: HTMLElement; } -export interface FocusTrapOptions { - /** - * The focusable elements. - */ - elements?: HTMLElement[] | (() => HTMLElement[]); - /** - * The selectors to use to find focusable elements. - */ - include?: string[]; - /** - * The selectors to use to ignore focusable elements. - */ - exclude?: string[]; +export interface FocusTrapOptions extends FocusManagerOptions { /** * Whether to inert the other elements of the page. */ @@ -93,10 +81,6 @@ export interface FocusTrapOptions { * Whether to restore focus to the previous element. */ restore?: boolean; - /** - * Whether to trap the focus. - */ - trap?: boolean; /** * The focus trap implementation configuration. */ @@ -133,7 +117,7 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = /** * The tabindex of the root node. */ - let tabindex: string | null = null; + let tabIndex: string | null = null; /** * A function that restores tree status. @@ -160,21 +144,7 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = */ let trapEnd: HTMLElement | null = null; - /** - * Focus the first focusable child. - */ - const focusFirst = () => { - const elements = findFocusableByOptions(node, options); - elements.shift()?.focus(); - }; - - /** - * Focus the last focusable child. - */ - const focusLast = () => { - const elements = findFocusableByOptions(node, options); - elements.pop()?.focus(); - }; + const manager = focusManager(node, options); /** * Enter the focus context. @@ -186,70 +156,68 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = connected = true; - const { trap = true, trapImpl = {}, inert = false, focusContainer = false, onEnter } = options; + const { trapImpl = {}, inert = false, focusContainer = false, onEnter } = options; const { useShadowDOM = true, startHelper = null, endHelper = null } = trapImpl; - tabindex = node.getAttribute('tabindex'); - if (focusContainer && !tabindex) { + tabIndex = node.getAttribute('tabindex'); + if (focusContainer && !tabIndex) { node.setAttribute('tabindex', '0'); } - if (trap) { - const document = node.ownerDocument; - trapStart = startHelper || createTrapHelper(document); - trapStart.addEventListener( - 'focus', - (event) => { - event.stopImmediatePropagation(); - event.stopPropagation(); - event.preventDefault(); + const document = node.ownerDocument; + trapStart = startHelper || createTrapHelper(document); + trapStart.addEventListener( + 'focus', + (event) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); - if (currentNode === node) { - focusFirst(); - } else if (focusContainer) { - currentNode = node; - node.focus(); - } else { - focusLast(); - } - }, - true - ); - - trapEnd = endHelper || createTrapHelper(document); - trapEnd.addEventListener( - 'focus', - (event) => { - event.stopImmediatePropagation(); - event.stopPropagation(); - event.preventDefault(); + if (currentNode === node) { + manager.focusFirst(); + } else if (focusContainer) { + currentNode = node; + node.focus(); + } else { + manager.focusLast(); + } + }, + true + ); + + trapEnd = endHelper || createTrapHelper(document); + trapEnd.addEventListener( + 'focus', + (event) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); - if (currentNode === node) { - focusLast(); - } else if (focusContainer) { - currentNode = node; - node.focus(); - } else { - focusFirst(); - } - }, - true - ); - - let root: HTMLElement | DocumentFragment = node; - if (useShadowDOM) { - if (node.shadowRoot) { - root = node.shadowRoot; + if (currentNode === node) { + manager.focusLast(); + } else if (focusContainer) { + currentNode = node; + node.focus(); } else { - root = node.attachShadow({ mode: 'open' }); - root.append(document.createElement('slot')); + manager.focusFirst(); } + }, + true + ); + + let root: HTMLElement | DocumentFragment = node; + if (useShadowDOM) { + if (node.shadowRoot) { + root = node.shadowRoot; + } else { + root = node.attachShadow({ mode: 'open' }); + root.append(document.createElement('slot')); } - if (root.firstChild !== trapStart) { - root.prepend(trapStart); - } - if (root.lastChild !== trapEnd) { - root.append(trapEnd); - } + } + if (root.firstChild !== trapStart) { + root.prepend(trapStart); + } + if (root.lastChild !== trapEnd) { + root.append(trapEnd); } // MUST use the `focusin` event because it fires after the bound `focus` on trap helpers @@ -268,7 +236,7 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = } else if (focusContainer) { node.focus(); } else { - focusFirst(); + manager.focusFirst(); } if (inert) { restoreTreeState = inertTree(node, node.ownerDocument.body); @@ -296,7 +264,7 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = connected = false; - restoreAttribute(node, 'tabindex', tabindex); + restoreAttribute(node, 'tabindex', tabIndex); node.ownerDocument.removeEventListener('focus', handleFocusOut, true); node.removeEventListener('focusin', handleFocusIn, true); node.removeEventListener('keydown', handleKeyDown, true); @@ -341,7 +309,7 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = if (focusContainer) { node.focus(); } else { - focusFirst(); + manager.focusFirst(); } } }; @@ -351,7 +319,6 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = * @param event The keydown event. */ const handleKeyDown = (event: KeyboardEvent) => { - const { trap = true } = options; switch (event.key) { case 'Esc': case 'Escape': @@ -359,13 +326,13 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = disconnect(); break; case 'Tab': - if (trap && currentNode === node) { + if (currentNode === node) { event.preventDefault(); if (event.shiftKey) { - focusLast(); + manager.focusLast(); } else { - focusFirst(); + manager.focusFirst(); } } break; @@ -378,5 +345,6 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions = }, connect, disconnect, + manager, }; }