167 lines
4.1 KiB
TypeScript
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);
|
|
};
|
|
}
|
|
}
|