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

View File

@@ -259,7 +259,10 @@
<div class="component"> <div class="component">
<p class="title">Time Input</p> <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>
<div class="component"> <div class="component">
@@ -417,7 +420,7 @@
} }
}} }}
onopen={(dialog) => { onopen={(dialog) => {
dialog.error(ErrorMessage.from('Example error message!')); dialog.error('Example error message!');
dialog.loading(); dialog.loading();
setTimeout(() => { setTimeout(() => {
dialog.loaded(); dialog.loaded();