Skip to content

Commit

Permalink
Expose the focusManager method
Browse files Browse the repository at this point in the history
  • Loading branch information
edoardocavazza committed Nov 9, 2023
1 parent 677b6df commit f04dd1c
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 119 deletions.
5 changes: 5 additions & 0 deletions .changeset/yellow-shoes-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chialab/loock': major
---

Expose the `focusManager` method.
61 changes: 0 additions & 61 deletions src/findFocusableChildren.ts

This file was deleted.

5 changes: 1 addition & 4 deletions src/focusEnterBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { focusManager, type FocusManagerOptions } from './focusManager';
import type { FocusManagerOptions } from './focusManager';

/**
* The focus enter options.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -77,6 +75,5 @@ export function focusEnterBehavior(node: HTMLElement, options: FocusEnterOptions
node.removeEventListener('focusin', onFocusIn);
node.removeEventListener('focusout', onFocusOut);
},
manager,
};
}
5 changes: 2 additions & 3 deletions src/focusFirstChildBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { focusEnterBehavior } from './focusEnterBehavior';
import { type FocusManagerOptions } from './focusManager';
import { focusManager, type FocusManagerOptions } from './focusManager';
import { restoreAttribute } from './helpers';

/**
Expand All @@ -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() {
Expand All @@ -24,7 +25,6 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusManager
restoreAttribute(node, 'tabindex', tabIndex);
},
});
const { manager } = enterBehavior;

const onFocus = () => {
const elements = manager.findFocusable();
Expand Down Expand Up @@ -67,6 +67,5 @@ export function focusFirstChildBehavior(node: HTMLElement, options: FocusManager
activeElement = null;
node.removeEventListener('focus', onFocus, true);
},
manager,
};
}
68 changes: 67 additions & 1 deletion src/focusManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { findFocusableByOptions } from './findFocusableChildren';
import { DEFAULT_IGNORE_SELECTORS, DEFAULT_SELECTORS } from './constants';

export interface FocusManagerOptions {
/**
Expand All @@ -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() {
Expand Down
44 changes: 1 addition & 43 deletions src/focusTrapBehavior.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -345,6 +304,5 @@ export function focusTrapBehavior(node: HTMLElement, options: FocusTrapOptions =
},
connect,
disconnect,
manager,
};
}
41 changes: 41 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 6 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 3 additions & 2 deletions src/keyboardNavigationBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { findFocusableByOptions } from './findFocusableChildren';
import { focusManager } from './focusManager';

/**
* The options for keyboard navigation.
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down

0 comments on commit f04dd1c

Please sign in to comment.