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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user