add focus tool; time input: refactor away from bind:ref
This commit is contained in:
@@ -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
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user