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 { 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
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;
|
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