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