add focus tool; time input: refactor away from bind:ref
This commit is contained in:
156
src/lib/focus.ts
Normal file
156
src/lib/focus.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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 }): 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.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();
|
||||
});
|
||||
|
||||
return () => this.remove(node);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user