diff --git a/src/lib/TimeInput.svelte b/src/lib/TimeInput.svelte index 837cf29..b0a95bc 100644 --- a/src/lib/TimeInput.svelte +++ b/src/lib/TimeInput.svelte @@ -2,10 +2,10 @@ import { liveValidator, validate } from '@svelte-toolkit/validate'; import Label from './Label.svelte'; import StyledRawInput from './StyledRawInput.svelte'; - import { onMount } from 'svelte'; import moment from 'moment'; import type { ClassValue } from 'svelte/elements'; - import { generateIdentifier } from './util'; + import { generateIdentifier, targetMust } from './util'; + import { FocusManager } from './focus'; interface Props { name?: string; @@ -32,20 +32,19 @@ }: Props = $props(); type ampmKey = 'AM' | 'PM'; - type componentsKey = 'hour' | 'minute'; + type componentKey = 'hour' | 'minute'; const id = $derived(generateIdentifier('time-input', name)); + const values: Record = $state({ + hour: '', + minute: '' + }); let ampm: ampmKey = $state('AM'); let valid: boolean = $state(true); let hiddenInput: HTMLInputElement; - let btnrefs: Partial> = $state({}); - const inrefs: Record = $state({ - hour: null, - minute: null - }); - + const focusList = new FocusManager(); const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/; const minutePattern = /^(0?[0-9]|[1-5][0-9])$/; const keydownValidatorOpts = { constrain: true }; @@ -70,7 +69,6 @@ */ const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => { const f = () => { - console.log('incrementing', input); if (input.value.length === 0) { input.value = start.toString(); return true; @@ -90,7 +88,7 @@ }; const res = f(); - selectEnd(input); + updateValue(); return res; }; @@ -119,24 +117,10 @@ }; const res = f(); - selectEnd(input); + updateValue(); return res; }; - /** - * selectEnd selects the end of the input value - * @param input The input element to select - */ - const selectEnd = (input: HTMLInputElement) => { - if (input) { - setTimeout(() => { - console.log('selecting end', input, input.isConnected); - input.setSelectionRange(input.value.length, input.value.length); - console.log('selected end vibes'); - }, 0); - } - }; - /** * prefixZero adds a leading zero to the string if it is less than 10 * @param str The string to prefix @@ -153,11 +137,9 @@ * If any component is invalid or blank, it sets `value` to an empty string. */ const updateValue = () => { - if (!inrefs.hour || !inrefs.minute) return; - let ampmLocal = ampm; - let hourValue = parseInt(inrefs.hour.value); - let minuteValue = parseInt(inrefs.minute.value); + let hourValue = parseInt(values.hour); + let minuteValue = parseInt(values.minute); if (isNaN(hourValue)) { value = ''; @@ -192,23 +174,14 @@ hiddenInput.dispatchEvent(new KeyboardEvent('keyup')); }; - onMount(() => { - if (inrefs.hour) { - liveValidator(inrefs.hour, keydownValidatorOpts); - } - if (inrefs.minute) { - liveValidator(inrefs.minute, keydownValidatorOpts); - } - }); - const components: Record< - componentsKey, + componentKey, { max: number; pattern: RegExp; onkeydown: (e: KeyboardEvent) => void; - oninput: () => void; - onblur: () => void; + oninput: (e: Event) => void; + onblur: (e: FocusEvent) => void; divider?: string; placeholder?: string; } @@ -218,44 +191,41 @@ max: 24, pattern: hour24Pattern, onkeydown: (e: KeyboardEvent) => { - if (!inrefs.hour) return; + const target = targetMust(e); - if (e.key === ':' && inrefs.hour.value.length !== 0) { - inrefs.minute?.focus(); + if (e.key === ':' && target.value.length !== 0) { + focusList.focusNext(); e.preventDefault(); } - if (e.key === 'ArrowRight' && inrefs.hour.selectionEnd === inrefs.hour.value.length) { - inrefs.minute?.focus(); - } - if (e.key === 'ArrowUp') { - if (!incrementValue(inrefs.hour, 12, 1)) { + if (!incrementValue(target, 12, 1)) { toggleAMPM(); } - } - if (e.key === 'ArrowDown') { - if (!decrementValue(inrefs.hour, 12, 1)) { + } else if (e.key === 'ArrowDown') { + if (!decrementValue(target, 12, 1)) { toggleAMPM(); } + } else { + return; } - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault(); - } + + e.preventDefault(); }, - oninput: () => { + oninput: (e) => { + const target = targetMust(e); updateValue(); - if (inrefs.hour?.value.length === 2) { - inrefs.minute?.focus(); + if (target.value.length === 2) { + focusList.focusNext(); } }, - onblur: () => { - if (!inrefs.hour) return; - const hourValue = parseInt(inrefs.hour.value); + onblur: (e: FocusEvent) => { + const target = targetMust(e); + const hourValue = parseInt(target.value); if (hourValue > 12) { - inrefs.hour.value = (hourValue - 12).toString(); + target.value = (hourValue - 12).toString(); ampm = 'PM'; } @@ -268,44 +238,25 @@ max: 59, pattern: minutePattern, onkeydown: (e: KeyboardEvent) => { - if (!inrefs.minute) return; - const target = e.target as HTMLInputElement; - - console.log( - e.key, - inrefs.minute.selectionStart, - inrefs.minute.selectionEnd, - target.selectionStart - ); - - if (e.key === 'ArrowLeft' && inrefs.minute.selectionStart === 0) { - inrefs.hour?.focus(); - } else if ( - e.key === 'ArrowRight' && - inrefs.minute.selectionEnd === inrefs.minute.value.length - ) { - btnrefs.AM?.focus(); - } else if (e.key === 'Backspace' && inrefs.minute.value.length === 0) { - inrefs.hour?.focus(); - } + const target = targetMust(e); if (e.key === 'ArrowUp') { incrementValue(target, 59, 0); - } - if (e.key === 'ArrowDown') { - decrementValue(inrefs.minute, 59, 0); - } - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault(); + } else if (e.key === 'ArrowDown') { + decrementValue(target, 59, 0); + } else { return; } + + e.preventDefault(); }, oninput: () => { updateValue(); }, - onblur: () => { - if (inrefs.minute?.value.length === 1) { - inrefs.minute.value = '0' + inrefs.minute.value; + onblur: (e: FocusEvent) => { + const target = targetMust(e); + if (target.value.length === 1) { + target.value = '0' + target.value; } } } @@ -329,25 +280,26 @@
- {#each ['hour', 'minute'] as componentsKey[] as key} - {@const value = components[key]} + {#each ['hour', 'minute'] as componentKey[] as key} + {@const opts = components[key]} liveValidator(n, keydownValidatorOpts)} + onkeydown={opts.onkeydown} + oninput={opts.oninput} + onblur={opts.onblur} + {@attach focusList.input()} /> - {#if value.divider} - {value.divider} + {#if opts.divider} + {opts.divider} {/if} {/each} @@ -355,7 +307,6 @@
{#each ['AM', 'PM'] as (typeof ampm)[] as shade, index} diff --git a/src/lib/focus.ts b/src/lib/focus.ts new file mode 100644 index 0000000..2b22add --- /dev/null +++ b/src/lib/focus.ts @@ -0,0 +1,156 @@ +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 }): 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.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); + }; + } +} diff --git a/src/lib/util.ts b/src/lib/util.ts index 0f14a6d..888a621 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -69,3 +69,21 @@ export const getValue = (option: Option): string => { return option.value; } }; + +/** + * targetMust returns the target of an event coerced to a particular type and + * throws an error if the target does not exist or is not connected. + * + * @returns The target element coerced to a particular type. + * @throws Will throw an error if the target is null or not connected to the DOM. + */ +export function targetMust(event: Event): T { + const target = event.target as T | null; + if (!target) { + throw new Error('Event target is null'); + } + if (!target.isConnected) { + throw new Error('Event target is not connected to the DOM'); + } + return target; +}