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 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<componentKey, string> = $state({
hour: '',
minute: ''
});
let ampm: ampmKey = $state('AM');
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
let btnrefs: Partial<Record<ampmKey, HTMLButtonElement | null>> = $state({});
const inrefs: Record<componentsKey, HTMLInputElement | null> = $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<HTMLInputElement>(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<HTMLInputElement>(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<HTMLInputElement>(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<HTMLInputElement>(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<HTMLInputElement>(e);
if (target.value.length === 1) {
target.value = '0' + target.value;
}
}
}
@@ -329,25 +280,26 @@
<div class="flex items-center">
<!-- Hour, Minute Inputs -->
{#each ['hour', 'minute'] as componentsKey[] as key}
{@const value = components[key]}
{#each ['hour', 'minute'] as componentKey[] as key}
{@const opts = components[key]}
<StyledRawInput
bind:ref={inrefs[key as componentsKey]}
bind:value={values[key]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={value.placeholder ?? '00'}
name={name + '_' + key}
max={value.max}
placeholder={opts.placeholder ?? '00'}
max={opts.max}
min={0}
class="time-input"
validate={{ pattern: value.pattern }}
onkeydown={value.onkeydown}
oninput={value.oninput}
onblur={value.onblur}
validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)}
onkeydown={opts.onkeydown}
oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input()}
/>
{#if value.divider}
<span class="mx-2 text-3xl">{value.divider}</span>
{#if opts.divider}
<span class="mx-2 text-3xl">{opts.divider}</span>
{/if}
{/each}
@@ -355,7 +307,6 @@
<div class="ml-3 flex flex-col">
{#each ['AM', 'PM'] as (typeof ampm)[] as shade, index}
<button
bind:this={btnrefs[shade]}
class={[
'border-sui-accent dark:border-sui-accent/50 cursor-pointer border px-3 py-1 font-medium',
ampm === shade && [
@@ -368,23 +319,7 @@
ampm = shade;
updateValue();
}}
onkeydown={(e) => {
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();
}}
{@attach focusList.button()}
>
{shade}
</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;
}
};
/**
* 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;
}