add focus tool; time input: refactor away from bind:ref

This commit is contained in:
Elijah Duffy
2025-07-21 19:41:44 -07:00
parent e3c08b4247
commit 123594f828
3 changed files with 231 additions and 122 deletions

View File

@@ -2,10 +2,10 @@
import { liveValidator, validate } from '@svelte-toolkit/validate'; import { liveValidator, validate } from '@svelte-toolkit/validate';
import Label from './Label.svelte'; import Label from './Label.svelte';
import StyledRawInput from './StyledRawInput.svelte'; import StyledRawInput from './StyledRawInput.svelte';
import { onMount } from 'svelte';
import moment from 'moment'; import moment from 'moment';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { generateIdentifier } from './util'; import { generateIdentifier, targetMust } from './util';
import { FocusManager } from './focus';
interface Props { interface Props {
name?: string; name?: string;
@@ -32,20 +32,19 @@
}: Props = $props(); }: Props = $props();
type ampmKey = 'AM' | 'PM'; type ampmKey = 'AM' | 'PM';
type componentsKey = 'hour' | 'minute'; type componentKey = 'hour' | 'minute';
const id = $derived(generateIdentifier('time-input', name)); const id = $derived(generateIdentifier('time-input', name));
const values: Record<componentKey, string> = $state({
hour: '',
minute: ''
});
let ampm: ampmKey = $state('AM'); let ampm: ampmKey = $state('AM');
let valid: boolean = $state(true); let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement; let hiddenInput: HTMLInputElement;
let btnrefs: Partial<Record<ampmKey, HTMLButtonElement | null>> = $state({}); const focusList = new FocusManager();
const inrefs: Record<componentsKey, HTMLInputElement | null> = $state({
hour: null,
minute: null
});
const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/; const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/;
const minutePattern = /^(0?[0-9]|[1-5][0-9])$/; const minutePattern = /^(0?[0-9]|[1-5][0-9])$/;
const keydownValidatorOpts = { constrain: true }; const keydownValidatorOpts = { constrain: true };
@@ -70,7 +69,6 @@
*/ */
const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => { const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
const f = () => { const f = () => {
console.log('incrementing', input);
if (input.value.length === 0) { if (input.value.length === 0) {
input.value = start.toString(); input.value = start.toString();
return true; return true;
@@ -90,7 +88,7 @@
}; };
const res = f(); const res = f();
selectEnd(input); updateValue();
return res; return res;
}; };
@@ -119,24 +117,10 @@
}; };
const res = f(); const res = f();
selectEnd(input); updateValue();
return res; 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 * prefixZero adds a leading zero to the string if it is less than 10
* @param str The string to prefix * @param str The string to prefix
@@ -153,11 +137,9 @@
* If any component is invalid or blank, it sets `value` to an empty string. * If any component is invalid or blank, it sets `value` to an empty string.
*/ */
const updateValue = () => { const updateValue = () => {
if (!inrefs.hour || !inrefs.minute) return;
let ampmLocal = ampm; let ampmLocal = ampm;
let hourValue = parseInt(inrefs.hour.value); let hourValue = parseInt(values.hour);
let minuteValue = parseInt(inrefs.minute.value); let minuteValue = parseInt(values.minute);
if (isNaN(hourValue)) { if (isNaN(hourValue)) {
value = ''; value = '';
@@ -192,23 +174,14 @@
hiddenInput.dispatchEvent(new KeyboardEvent('keyup')); hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
}; };
onMount(() => {
if (inrefs.hour) {
liveValidator(inrefs.hour, keydownValidatorOpts);
}
if (inrefs.minute) {
liveValidator(inrefs.minute, keydownValidatorOpts);
}
});
const components: Record< const components: Record<
componentsKey, componentKey,
{ {
max: number; max: number;
pattern: RegExp; pattern: RegExp;
onkeydown: (e: KeyboardEvent) => void; onkeydown: (e: KeyboardEvent) => void;
oninput: () => void; oninput: (e: Event) => void;
onblur: () => void; onblur: (e: FocusEvent) => void;
divider?: string; divider?: string;
placeholder?: string; placeholder?: string;
} }
@@ -218,44 +191,41 @@
max: 24, max: 24,
pattern: hour24Pattern, pattern: hour24Pattern,
onkeydown: (e: KeyboardEvent) => { onkeydown: (e: KeyboardEvent) => {
if (!inrefs.hour) return; const target = targetMust<HTMLInputElement>(e);
if (e.key === ':' && inrefs.hour.value.length !== 0) { if (e.key === ':' && target.value.length !== 0) {
inrefs.minute?.focus(); focusList.focusNext();
e.preventDefault(); e.preventDefault();
} }
if (e.key === 'ArrowRight' && inrefs.hour.selectionEnd === inrefs.hour.value.length) {
inrefs.minute?.focus();
}
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
if (!incrementValue(inrefs.hour, 12, 1)) { if (!incrementValue(target, 12, 1)) {
toggleAMPM(); toggleAMPM();
} }
} } else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') { if (!decrementValue(target, 12, 1)) {
if (!decrementValue(inrefs.hour, 12, 1)) {
toggleAMPM(); toggleAMPM();
} }
} else {
return;
} }
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
}
}, },
oninput: () => { oninput: (e) => {
const target = targetMust<HTMLInputElement>(e);
updateValue(); updateValue();
if (inrefs.hour?.value.length === 2) { if (target.value.length === 2) {
inrefs.minute?.focus(); focusList.focusNext();
} }
}, },
onblur: () => { onblur: (e: FocusEvent) => {
if (!inrefs.hour) return; const target = targetMust<HTMLInputElement>(e);
const hourValue = parseInt(inrefs.hour.value); const hourValue = parseInt(target.value);
if (hourValue > 12) { if (hourValue > 12) {
inrefs.hour.value = (hourValue - 12).toString(); target.value = (hourValue - 12).toString();
ampm = 'PM'; ampm = 'PM';
} }
@@ -268,44 +238,25 @@
max: 59, max: 59,
pattern: minutePattern, pattern: minutePattern,
onkeydown: (e: KeyboardEvent) => { onkeydown: (e: KeyboardEvent) => {
if (!inrefs.minute) return; const target = targetMust<HTMLInputElement>(e);
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();
}
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
incrementValue(target, 59, 0); incrementValue(target, 59, 0);
} } else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') { decrementValue(target, 59, 0);
decrementValue(inrefs.minute, 59, 0); } else {
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
return; return;
} }
e.preventDefault();
}, },
oninput: () => { oninput: () => {
updateValue(); updateValue();
}, },
onblur: () => { onblur: (e: FocusEvent) => {
if (inrefs.minute?.value.length === 1) { const target = targetMust<HTMLInputElement>(e);
inrefs.minute.value = '0' + inrefs.minute.value; if (target.value.length === 1) {
target.value = '0' + target.value;
} }
} }
} }
@@ -329,25 +280,26 @@
<div class="flex items-center"> <div class="flex items-center">
<!-- Hour, Minute Inputs --> <!-- Hour, Minute Inputs -->
{#each ['hour', 'minute'] as componentsKey[] as key} {#each ['hour', 'minute'] as componentKey[] as key}
{@const value = components[key]} {@const opts = components[key]}
<StyledRawInput <StyledRawInput
bind:ref={inrefs[key as componentsKey]} bind:value={values[key]}
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
placeholder={value.placeholder ?? '00'} placeholder={opts.placeholder ?? '00'}
name={name + '_' + key} max={opts.max}
max={value.max}
min={0} min={0}
class="time-input" class="time-input"
validate={{ pattern: value.pattern }} validate={{ pattern: opts.pattern }}
onkeydown={value.onkeydown} use={(n) => liveValidator(n, keydownValidatorOpts)}
oninput={value.oninput} onkeydown={opts.onkeydown}
onblur={value.onblur} oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input()}
/> />
{#if value.divider} {#if opts.divider}
<span class="mx-2 text-3xl">{value.divider}</span> <span class="mx-2 text-3xl">{opts.divider}</span>
{/if} {/if}
{/each} {/each}
@@ -355,7 +307,6 @@
<div class="ml-3 flex flex-col"> <div class="ml-3 flex flex-col">
{#each ['AM', 'PM'] as (typeof ampm)[] as shade, index} {#each ['AM', 'PM'] as (typeof ampm)[] as shade, index}
<button <button
bind:this={btnrefs[shade]}
class={[ class={[
'border-sui-accent dark:border-sui-accent/50 cursor-pointer border px-3 py-1 font-medium', 'border-sui-accent dark:border-sui-accent/50 cursor-pointer border px-3 py-1 font-medium',
ampm === shade && [ ampm === shade && [
@@ -368,23 +319,7 @@
ampm = shade; ampm = shade;
updateValue(); updateValue();
}} }}
onkeydown={(e) => { {@attach focusList.button()}
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
if (index === 0) {
inrefs.minute?.focus();
} else {
btnrefs.AM?.focus();
}
} else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
if (index === 0) {
btnrefs.PM?.focus();
}
} else {
return;
}
e.preventDefault();
}}
> >
{shade} {shade}
</button> </button>

156
src/lib/focus.ts Normal file
View 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);
};
}
}

View File

@@ -69,3 +69,21 @@ export const getValue = (option: Option): string => {
return option.value; 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<T extends HTMLElement>(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;
}