time input: partial refactor

Lots of weird issues right now that seem primarily to arise from binding
to `ref` on `StyledRawInput` components and then storing that binding
in a stateful object. Ideally I want to do this in order to simplify
declaration of inputs so that I reuse other controls more readily.

Second step of this refactor should be to remove reliance on binding a
reference to the underlying HTML element.
This commit is contained in:
Elijah Duffy
2025-07-21 18:14:38 -07:00
parent c45224aff8
commit e3c08b4247
2 changed files with 194 additions and 158 deletions

View File

@@ -15,6 +15,7 @@
required?: boolean;
invalidMessage?: string;
showConfirm?: boolean;
compact?: boolean;
class?: ClassValue | null | undefined;
}
@@ -26,19 +27,24 @@
required,
invalidMessage = 'Please select a time',
showConfirm = false,
compact = false,
class: classValue
}: Props = $props();
type ampmKey = 'AM' | 'PM';
type componentsKey = 'hour' | 'minute';
const id = $derived(generateIdentifier('time-input', name));
let ampm: 'AM' | 'PM' = $state('AM');
let type: HTMLInputElement['type'] = $state('number');
let ampm: ampmKey = $state('AM');
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
let hourInput = $state<HTMLInputElement | null>(null);
let minuteInput = $state<HTMLInputElement | null>(null);
let amButton: HTMLButtonElement;
let pmButton: HTMLButtonElement;
let btnrefs: Partial<Record<ampmKey, HTMLButtonElement | null>> = $state({});
const inrefs: Record<componentsKey, HTMLInputElement | null> = $state({
hour: null,
minute: null
});
const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/;
const minutePattern = /^(0?[0-9]|[1-5][0-9])$/;
@@ -64,6 +70,7 @@
*/
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;
@@ -123,7 +130,9 @@
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);
}
};
@@ -144,11 +153,11 @@
* If any component is invalid or blank, it sets `value` to an empty string.
*/
const updateValue = () => {
if (!hourInput || !minuteInput) return;
if (!inrefs.hour || !inrefs.minute) return;
let ampmLocal = ampm;
let hourValue = parseInt(hourInput.value);
let minuteValue = parseInt(minuteInput.value);
let hourValue = parseInt(inrefs.hour.value);
let minuteValue = parseInt(inrefs.minute.value);
if (isNaN(hourValue)) {
value = '';
@@ -184,17 +193,123 @@
};
onMount(() => {
if (hourInput) {
liveValidator(hourInput, keydownValidatorOpts);
if (inrefs.hour) {
liveValidator(inrefs.hour, keydownValidatorOpts);
}
if (minuteInput) {
liveValidator(minuteInput, keydownValidatorOpts);
}
if ('userAgentData' in navigator) {
if (!(navigator.userAgentData as { mobile: boolean }).mobile) type = 'text';
if (inrefs.minute) {
liveValidator(inrefs.minute, keydownValidatorOpts);
}
});
const components: Record<
componentsKey,
{
max: number;
pattern: RegExp;
onkeydown: (e: KeyboardEvent) => void;
oninput: () => void;
onblur: () => void;
divider?: string;
placeholder?: string;
}
> = {
/** Hour Input */
hour: {
max: 24,
pattern: hour24Pattern,
onkeydown: (e: KeyboardEvent) => {
if (!inrefs.hour) return;
if (e.key === ':' && inrefs.hour.value.length !== 0) {
inrefs.minute?.focus();
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)) {
toggleAMPM();
}
}
if (e.key === 'ArrowDown') {
if (!decrementValue(inrefs.hour, 12, 1)) {
toggleAMPM();
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
}
},
oninput: () => {
updateValue();
if (inrefs.hour?.value.length === 2) {
inrefs.minute?.focus();
}
},
onblur: () => {
if (!inrefs.hour) return;
const hourValue = parseInt(inrefs.hour.value);
if (hourValue > 12) {
inrefs.hour.value = (hourValue - 12).toString();
ampm = 'PM';
}
updateValue();
},
divider: ':'
},
/** Minute Input */
minute: {
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();
}
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();
return;
}
},
oninput: () => {
updateValue();
},
onblur: () => {
if (inrefs.minute?.value.length === 1) {
inrefs.minute.value = '0' + inrefs.minute.value;
}
}
}
};
</script>
<div class={classValue}>
@@ -213,141 +328,67 @@
/>
<div class="flex items-center">
<!-- Hour input -->
<StyledRawInput
placeholder="00"
name={name + '_hour'}
{type}
max={24}
min={0}
class="time-input"
validate={{ pattern: hour24Pattern }}
onkeydown={(e) => {
if (!hourInput) return;
<!-- Hour, Minute Inputs -->
{#each ['hour', 'minute'] as componentsKey[] as key}
{@const value = components[key]}
<StyledRawInput
bind:ref={inrefs[key as componentsKey]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={value.placeholder ?? '00'}
name={name + '_' + key}
max={value.max}
min={0}
class="time-input"
validate={{ pattern: value.pattern }}
onkeydown={value.onkeydown}
oninput={value.oninput}
onblur={value.onblur}
/>
if (e.key === ':' && hourInput.value.length !== 0) {
minuteInput?.focus();
e.preventDefault();
}
if (e.key === 'ArrowRight' && hourInput.selectionEnd === hourInput.value.length) {
minuteInput?.focus();
}
if (e.key === 'ArrowUp') {
if (!incrementValue(hourInput, 12, 1)) {
toggleAMPM();
}
}
if (e.key === 'ArrowDown') {
if (!decrementValue(hourInput, 12, 1)) {
toggleAMPM();
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
}
}}
oninput={() => {
updateValue();
if (hourInput?.value.length === 2) {
minuteInput?.focus();
}
}}
onblur={() => {
if (!hourInput) return;
const hourValue = parseInt(hourInput.value);
if (hourValue > 12) {
hourInput.value = (hourValue - 12).toString();
ampm = 'PM';
}
updateValue();
}}
bind:ref={hourInput}
/>
<span class="mx-2 text-3xl">:</span>
<!-- Minute input -->
<StyledRawInput
placeholder="00"
name={name + '_minute'}
{type}
max={59}
min={0}
class="time-input mr-3"
validate={{ pattern: minutePattern }}
onkeydown={(e) => {
if (!minuteInput) return;
if (e.key === 'ArrowLeft' && minuteInput.selectionStart === 0) {
hourInput?.focus();
} else if (
e.key === 'ArrowRight' &&
minuteInput.selectionEnd === minuteInput.value.length
) {
amButton?.focus();
} else if (e.key === 'Backspace' && minuteInput.value.length === 0) {
hourInput?.focus();
}
if (e.key === 'ArrowUp') {
incrementValue(minuteInput, 59, 0);
}
if (e.key === 'ArrowDown') {
decrementValue(minuteInput, 59, 0);
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
return;
}
}}
oninput={() => {
updateValue();
}}
onblur={() => {
if (minuteInput?.value.length === 1) {
minuteInput.value = '0' + minuteInput.value;
}
}}
bind:ref={minuteInput}
/>
{#if value.divider}
<span class="mx-2 text-3xl">{value.divider}</span>
{/if}
{/each}
<!-- AM/PM Picker -->
<div class="flex flex-col">
<!-- AM Button -->
<button
class={['ampm rounded-t-sm border', ampm === 'AM' && 'selected']}
onclick={() => {
ampm = 'AM';
updateValue();
}}
onkeydown={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') minuteInput?.focus();
else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') pmButton.focus();
}}
bind:this={amButton}
>
AM
</button>
<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 && [
'bg-sui-accent text-sui-background dark:bg-sui-accent/30 dark:text-sui-background/90',
'ring-sui-text z-10 ring-1 dark:ring-blue-300'
],
index === 0 ? 'rounded-t-sm' : 'rounded-b-sm'
]}
onclick={() => {
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;
}
<!-- PM Button -->
<button
class={['ampm rounded-b-sm border-r border-b border-l', ampm === 'PM' && 'selected']}
onclick={() => {
ampm = 'PM';
updateValue();
}}
onkeydown={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') amButton?.focus();
}}
bind:this={pmButton}
>
PM
</button>
e.preventDefault();
}}
>
{shade}
</button>
{/each}
</div>
</div>
@@ -378,12 +419,4 @@
@apply m-0 appearance-none;
}
}
.ampm {
@apply border-sui-accent dark:border-sui-accent/50 cursor-pointer px-3 py-1 font-medium;
&.selected {
@apply bg-sui-accent text-sui-background dark:bg-sui-accent/30 dark:text-sui-background/90 ring-sui-text ring-1 dark:ring-blue-300;
}
}
</style>

View File

@@ -259,7 +259,10 @@
<div class="component">
<p class="title">Time Input</p>
<TimeInput name="example-time-input" label="Enter a time" />
<InputGroup class="gap-8">
<TimeInput label="Regular time input" name="example-time-input" />
<TimeInput label="Compact time" compact />
</InputGroup>
</div>
<div class="component">
@@ -417,7 +420,7 @@
}
}}
onopen={(dialog) => {
dialog.error(ErrorMessage.from('Example error message!'));
dialog.error('Example error message!');
dialog.loading();
setTimeout(() => {
dialog.loaded();