import { tick } from 'svelte'; import type { Attachment } from 'svelte/attachments'; export type FocusKeymap = Record; export class FocusManager { private _wrap: boolean; private _elements: HTMLElement[] = []; private _activeIndex: number | null = null; constructor(allowWrapping: boolean = false) { this._wrap = allowWrapping; } /** * Focuses the next element in the focus manager. If the end is reached and * wrapping is enabled, wraps back to the first element. */ focusNext() { if (this._elements.length === 0) return; if (!this._wrap && this._activeIndex === this._elements.length - 1) return; const nextIndex = this._activeIndex !== null ? (this._activeIndex + 1) % this._elements.length : 0; this._elements[nextIndex]?.focus(); } /** * Focuses the previous element in the focus manager. If the start is reached and * wrapping is enabled, wraps back to the last element. */ focusPrevious() { if (this._elements.length === 0) return; if (!this._wrap && this._activeIndex === 0) return; const prevIndex = this._activeIndex !== null ? (this._activeIndex - 1 + this._elements.length) % this._elements.length : this._elements.length - 1; this._elements[prevIndex]?.focus(); } /** * Adds an element to the focus manager. * @param element The element to add. */ private add(element: HTMLElement) { this._elements.push(element); } /** * Removes an element from the focus manager. * @param element The element to remove. */ private remove(element: HTMLElement) { const index = this._elements.indexOf(element); if (index !== -1) { this._elements.splice(index, 1); } } /** * Configues onfocus and onblur event handlers for an element. */ private attachFocusHandlers(node: HTMLElement) { node.addEventListener('focus', () => { this._activeIndex = this._elements.indexOf(node); }); node.addEventListener('blur', () => { if (this._activeIndex === this._elements.indexOf(node)) { this._activeIndex = null; } }); } /** * Returns an attachment that adds a button element to the focus manager. */ button(): Attachment { return (node) => { this.add(node); this.attachFocusHandlers(node); /** Attach keyboard event handlers */ node.addEventListener('keydown', (e: KeyboardEvent) => { const target = e.target as HTMLButtonElement; if (!target) return; switch (e.key) { case 'ArrowUp': case 'ArrowLeft': e.preventDefault(); this.focusPrevious(); break; case 'ArrowDown': case 'ArrowRight': e.preventDefault(); this.focusNext(); break; } }); return () => this.remove(node); }; } /** * Returns an attachment that adds an input element to the focus manager. */ input(opts?: { keymap?: FocusKeymap; selectAll?: boolean }): Attachment { return (node) => { this.add(node); this.attachFocusHandlers(node); /** Attach keyboard event handlers */ node.addEventListener('keydown', (e: KeyboardEvent) => { const target = e.target as HTMLInputElement; if (!target) return; if ( (e.key === 'ArrowLeft' || e.key === 'Backspace' || e.key === 'Home') && target.selectionStart === 0 && target.selectionEnd === 0 ) { this.focusPrevious(); } else if ( (e.key === 'ArrowRight' || e.key === 'End') && target.selectionStart === target.selectionEnd && target.selectionEnd === target.value.length ) { this.focusNext(); } else { const keymap = opts?.keymap; if (keymap) { for (const [key, action] of Object.entries(keymap)) { if (e.key === key) { e.preventDefault(); if (action === 'next') { this.focusNext(); } else if (action === 'previous') { this.focusPrevious(); } return; } } } return; } e.preventDefault(); }); if (opts?.selectAll) { node.addEventListener('focus', async () => { await tick(); node.select(); }); } return () => this.remove(node); }; } }