Files
sui/src/lib/focus.ts

167 lines
4.1 KiB
TypeScript

import { tick } from 'svelte';
import type { Attachment } from 'svelte/attachments';
export type FocusKeymap = Record<string, 'next' | 'previous'>;
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<HTMLButtonElement> {
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<HTMLInputElement> {
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);
};
}
}