add TimeInput component with time-of-day form option
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
356
src/lib/TimeInput.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user