1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

feat(vue/primitives): add FocusScope component with auto-focus and focus trap functionality

This commit is contained in:
2026-03-10 18:28:52 +07:00
parent a996eb74b9
commit 4574bae0b6
36 changed files with 1266 additions and 65 deletions

View File

@@ -0,0 +1,262 @@
import { afterEach, describe, it, expect } from 'vitest';
import {
getActiveElement,
getTabbableCandidates,
getTabbableEdges,
focusFirst,
focus,
isHidden,
isSelectableInput,
AUTOFOCUS_ON_MOUNT,
AUTOFOCUS_ON_UNMOUNT,
EVENT_OPTIONS,
} from '.';
function createContainer(html: string): HTMLElement {
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
return container;
}
describe('constants', () => {
it('exports correct event names', () => {
expect(AUTOFOCUS_ON_MOUNT).toBe('focusScope.autoFocusOnMount');
expect(AUTOFOCUS_ON_UNMOUNT).toBe('focusScope.autoFocusOnUnmount');
});
it('exports correct event options', () => {
expect(EVENT_OPTIONS).toEqual({ bubbles: false, cancelable: true });
});
});
describe('getActiveElement', () => {
it('returns document.body when nothing is focused', () => {
const active = getActiveElement();
expect(active).toBe(document.body);
});
it('returns the focused element', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
expect(getActiveElement()).toBe(input);
input.remove();
});
});
describe('getTabbableCandidates', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('returns focusable elements with tabindex >= 0', () => {
const container = createContainer(`
<input type="text" />
<button>Click</button>
<a href="#">Link</a>
<div tabindex="0">Div</div>
`);
const candidates = getTabbableCandidates(container);
expect(candidates.length).toBe(4);
container.remove();
});
it('skips disabled elements', () => {
const container = createContainer(`
<button disabled>Disabled</button>
<input type="text" />
`);
const candidates = getTabbableCandidates(container);
expect(candidates.length).toBe(1);
expect(candidates[0]!.tagName).toBe('INPUT');
container.remove();
});
it('skips hidden inputs', () => {
const container = createContainer(`
<input type="hidden" />
<input type="text" />
`);
const candidates = getTabbableCandidates(container);
expect(candidates.length).toBe(1);
expect((candidates[0] as HTMLInputElement).type).toBe('text');
container.remove();
});
it('skips elements with hidden attribute', () => {
const container = createContainer(`
<input type="text" hidden />
<input type="text" />
`);
const candidates = getTabbableCandidates(container);
expect(candidates.length).toBe(1);
container.remove();
});
it('returns empty array for container with no focusable elements', () => {
const container = createContainer(`
<div>Just text</div>
<span>More text</span>
`);
const candidates = getTabbableCandidates(container);
expect(candidates.length).toBe(0);
container.remove();
});
});
describe('getTabbableEdges', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('returns first and last tabbable elements', () => {
const container = createContainer(`
<input type="text" data-testid="first" />
<button>Middle</button>
<input type="text" data-testid="last" />
`);
const { first, last } = getTabbableEdges(container);
expect(first?.getAttribute('data-testid')).toBe('first');
expect(last?.getAttribute('data-testid')).toBe('last');
container.remove();
});
it('returns undefined for both when no tabbable elements', () => {
const container = createContainer(`<div>no focusable</div>`);
const { first, last } = getTabbableEdges(container);
expect(first).toBeUndefined();
expect(last).toBeUndefined();
container.remove();
});
});
describe('focusFirst', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('focuses the first element in the list', () => {
const container = createContainer(`
<input type="text" data-testid="a" />
<input type="text" data-testid="b" />
`);
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
focusFirst(candidates);
expect(document.activeElement).toBe(candidates[0]);
container.remove();
});
it('returns true when focus changed', () => {
const container = createContainer(`<input type="text" />`);
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
const result = focusFirst(candidates);
expect(result).toBe(true);
container.remove();
});
it('returns false when no candidate receives focus', () => {
const result = focusFirst([]);
expect(result).toBe(false);
});
});
describe('focus', () => {
it('does nothing when element is null', () => {
expect(() => focus(null)).not.toThrow();
});
it('focuses the given element', () => {
const input = document.createElement('input');
document.body.appendChild(input);
focus(input);
expect(document.activeElement).toBe(input);
input.remove();
});
it('calls select on input when select=true', () => {
const input = document.createElement('input');
input.value = 'hello';
document.body.appendChild(input);
focus(input, { select: true });
expect(document.activeElement).toBe(input);
input.remove();
});
});
describe('isSelectableInput', () => {
it('returns true for input elements', () => {
const input = document.createElement('input');
expect(isSelectableInput(input)).toBe(true);
});
it('returns false for non-input elements', () => {
const div = document.createElement('div');
expect(isSelectableInput(div)).toBe(false);
});
});
describe('isHidden', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('detects elements with visibility: hidden', () => {
const container = createContainer('');
const el = document.createElement('div');
el.style.visibility = 'hidden';
container.appendChild(el);
expect(isHidden(el)).toBe(true);
container.remove();
});
it('detects elements with display: none', () => {
const container = createContainer('');
const el = document.createElement('div');
el.style.display = 'none';
container.appendChild(el);
expect(isHidden(el)).toBe(true);
container.remove();
});
it('returns false for visible elements', () => {
const container = createContainer('');
const el = document.createElement('div');
container.appendChild(el);
expect(isHidden(el, container)).toBe(false);
container.remove();
});
});

View File

@@ -0,0 +1,168 @@
export type FocusableTarget = HTMLElement | { focus: () => void };
export const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
export const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
/**
* @name getActiveElement
* @category Browsers
* @description Returns the active element of the document (or shadow root)
*
* @since 0.0.5
*/
export function getActiveElement(doc: Document | ShadowRoot = document): HTMLElement | null {
let active = doc.activeElement as HTMLElement | null;
while (active?.shadowRoot)
active = active.shadowRoot.activeElement as HTMLElement | null;
return active;
}
/**
* @name isSelectableInput
* @category Browsers
* @description Checks if an element is an input element with a select method
*
* @since 0.0.5
*/
export function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
return element instanceof HTMLInputElement && 'select' in element;
}
/**
* @name focus
* @category Browsers
* @description Focuses an element without scrolling. Optionally calls select on input elements.
*
* @since 0.0.5
*/
export function focus(element?: FocusableTarget | null, { select = false } = {}) {
if (element && element.focus) {
const previouslyFocused = getActiveElement();
element.focus({ preventScroll: true });
if (element !== previouslyFocused && isSelectableInput(element) && select) {
element.select();
}
}
}
/**
* @name focusFirst
* @category Browsers
* @description Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
*
* @since 0.0.5
*/
export function focusFirst(candidates: HTMLElement[], { select = false } = {}): boolean {
const previouslyFocused = getActiveElement();
for (const candidate of candidates) {
focus(candidate, { select });
if (getActiveElement() !== previouslyFocused)
return true;
}
return false;
}
/**
* @name getTabbableCandidates
* @category Browsers
* @description Collects all tabbable candidates via TreeWalker (faster than querySelectorAll).
* This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
*
* @since 0.0.5
*/
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: HTMLElement) => {
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
if ((node as any).disabled || node.hidden || isHiddenInput)
return NodeFilter.FILTER_SKIP;
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode())
nodes.push(walker.currentNode as HTMLElement);
return nodes;
}
/**
* @name isHidden
* @category Browsers
* @description Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
*
* @since 0.0.5
*/
export function isHidden(node: HTMLElement, upTo?: HTMLElement): boolean {
const style = getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none')
return true;
while (node.parentElement) {
node = node.parentElement;
if (upTo !== undefined && node === upTo)
return false;
if (getComputedStyle(node).display === 'none')
return true;
}
return false;
}
/**
* @name findFirstVisible
* @category Browsers
* @description Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
*
* @since 0.0.5
*/
export function findFirstVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
for (const element of elements) {
if (!isHidden(element, container))
return element;
}
}
/**
* @name findLastVisible
* @category Browsers
* @description Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
*
* @since 0.0.5
*/
export function findLastVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
for (let i = elements.length - 1; i >= 0; i--) {
if (!isHidden(elements[i]!, container))
return elements[i];
}
}
/**
* @name getTabbableEdges
* @category Browsers
* @description Returns the first and last tabbable elements inside a container
*
* @since 0.0.5
*/
export function getTabbableEdges(container: HTMLElement): { first: HTMLElement | undefined; last: HTMLElement | undefined } {
const candidates = getTabbableCandidates(container);
const first = findFirstVisible(candidates, container);
const last = findLastVisible(candidates, container);
return { first, last };
}

View File

@@ -1,2 +1,3 @@
export * from './animationLifecycle';
export * from './focusGuard';
export * from './focusScope';