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:
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
262
core/platform/src/browsers/focusScope/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
168
core/platform/src/browsers/focusScope/index.ts
Normal file
168
core/platform/src/browsers/focusScope/index.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
Reference in New Issue
Block a user