add TimeInput component with time-of-day form option

This commit is contained in:
Elijah Duffy
2025-05-06 09:08:36 -07:00
parent ca819834dd
commit be7ef6bdfd
4 changed files with 360 additions and 3 deletions

View File

@@ -67,7 +67,7 @@
<button
class={[
'button group relative flex gap-3 overflow-hidden rounded-sm px-5',
'text-background py-3 font-medium transition-colors',
'text-background cursor-pointer py-3 font-medium transition-colors',
!loading ? ' bg-primary hover:bg-secondary' : 'bg-primary/50 cursor-not-allowed '
]}
onclick={handleButtonClick}

View File

@@ -334,8 +334,8 @@
<!-- Back button -->
{#if backButtonVisible}
<button
class="text-text hover:text-text-700 dark:text-background dark:hover:text-background/80 flex items-center gap-2.5
font-medium transition-colors"
class="text-text hover:text-text-700 dark:text-background dark:hover:text-background/80 flex cursor-pointer items-center
gap-2.5 font-medium transition-colors"
onclick={() => (index -= 1)}
transition:fly={{ x: -200, duration: 200 }}
>

356
src/lib/TimeInput.svelte Normal file
View File

@@ -0,0 +1,356 @@
<script lang="ts">
import { inputValidator, keydownValidator, liveValidator, validate } from '@repo/validate';
import Label from './Label.svelte';
import StyledRawInput from './StyledRawInput.svelte';
import { onMount } from 'svelte';
let {
name,
label,
value = $bindable(''),
required,
invalidMessage = 'Please select a time'
}: {
name: string;
label?: string;
value?: string;
required?: boolean;
invalidMessage?: string;
} = $props();
let ampm: 'AM' | 'PM' = $state('AM');
let type: HTMLInputElement['type'] = $state('number');
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;
const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/;
const minutePattern = /^(0?[0-9]|[1-5][0-9])$/;
const keydownValidatorOpts = { constrain: true };
/**
* toggleAMPM toggles the current AM/PM state
*/
const toggleAMPM = () => {
if (ampm === 'AM') {
ampm = 'PM';
} else {
ampm = 'AM';
}
};
/**
* incrementValue increments the value of the input by 1
* @param input The input element to increment
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was incremented, false if it looped back to 0
*/
const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
const f = () => {
if (input.value.length === 0) {
input.value = start.toString();
return true;
}
const value = parseInt(input.value);
if (value === max) {
input.value = start.toString();
return false;
} else if (value > max) {
input.value = (value - max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
const res = f();
selectEnd(input);
return res;
};
/**
* decrementValue decrements the value of the input by 1
* @param input The input element to decrement
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was decremented, false if it looped back to max
*/
const decrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
const f = () => {
if (input.value.length === 0) {
input.value = max.toString();
return true;
}
const value = parseInt(input.value);
if (value <= start) {
input.value = max.toString();
return false;
} else {
input.value = (value - 1).toString();
return true;
}
};
const res = f();
selectEnd(input);
return res;
};
/**
* selectEnd selects the end of the input value
* @param input The input element to select
*/
const selectEnd = (input: HTMLInputElement) => {
if (input) {
setTimeout(() => {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
};
/**
* prefixZero adds a leading zero to the string if it is less than 10
* @param str The string to prefix
*/
const prefixZero = (str: string) => {
if (str.length === 1) {
return '0' + str;
}
return str;
};
/**
* updateValue updates `value` with the current time in 24-hour format.
* If any component is invalid or blank, it sets `value` to an empty string.
*/
const updateValue = () => {
if (!hourInput || !minuteInput) return;
let ampmLocal = ampm;
let hourValue = parseInt(hourInput.value);
let minuteValue = parseInt(minuteInput.value);
if (isNaN(hourValue)) {
value = '';
updateHiddenInput();
return;
}
// internally convert to 24-hour format
if (hourValue > 12) {
hourValue = hourValue - 12;
ampmLocal = 'PM';
}
if (isNaN(minuteValue)) {
minuteValue = 0;
}
value = `${prefixZero((hourValue + (ampmLocal === 'PM' ? 12 : 0)).toString())}:${prefixZero(minuteValue.toString())}`;
updateHiddenInput();
};
/**
* updateHiddenInput pushes the current value to the hidden input and triggers
* a keyup event to allow validation to detect the change.
*/
const updateHiddenInput = () => {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
};
onMount(() => {
if (hourInput) {
liveValidator(hourInput, keydownValidatorOpts);
}
if (minuteInput) {
liveValidator(minuteInput, keydownValidatorOpts);
}
if ('userAgentData' in navigator) {
if (!(navigator.userAgentData as any).mobile) type = 'text';
}
});
</script>
<div>
{#if label}
<Label for={name}>{label}</Label>
{/if}
<!-- Hidden input stores the selected time in 24-hour format -->
<input
type="hidden"
{name}
use:validate={{ required, autovalOnInvalid: true }}
onvalidate={(e) => (valid = e.detail.valid)}
bind:this={hiddenInput}
/>
<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;
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();
return;
}
}}
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 -->
<div class="flex flex-col">
<!-- AM Button -->
<button
class={['ampm rounded-t-sm border', ampm === 'AM' && 'selected']}
onclick={() => (ampm = 'AM')}
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>
<!-- PM Button -->
<button
class={['ampm rounded-b-sm border-r border-b border-l', ampm === 'PM' && 'selected']}
onclick={() => (ampm = 'PM')}
onkeydown={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') amButton?.focus();
}}
bind:this={pmButton}
>
PM
</button>
</div>
</div>
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={name} error>
{invalidMessage}
</Label>
</div>
</div>
<style lang="postcss">
@reference "@repo/tailwindcss-config/app.css";
:global(.time-input) {
@apply h-16 w-24 text-center text-xl;
&:focus::placeholder {
@apply text-transparent;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
@apply appearance-none;
}
}
.ampm {
@apply border-accent dark:border-accent/50 cursor-pointer px-3 py-1 font-medium;
&.selected {
@apply bg-accent text-background dark:bg-accent/30 dark:text-background/90;
}
}
</style>

View File

@@ -15,6 +15,7 @@ export { default as Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as TimeInput } from './TimeInput.svelte';
export { default as TimezoneInput } from './TimezoneInput.svelte';
export { default as ToggleGroup } from './ToggleGroup.svelte';
export { default as ToggleSelect } from './ToggleSelect.svelte';