Files
sui/src/lib/TimeInput.svelte
2025-12-11 16:00:41 -08:00

325 lines
8.7 KiB
Svelte

<script lang="ts" module>
/**
* formatTime returns a human readable 12-hour based string.
*
* @param time The time to format, can be null or undefined.
* @param fallback Optional fallback string to return if time is null or undefined.
* If not provided, an empty string will be returned.
* If provided, it will be returned when time is null or undefined.
* @returns The time in 12-hour format or the fallback string if undefined.
*/
export const formatTime = (time: Time | null | undefined, fallback?: string): string => {
if (!time) return fallback ?? '';
return moment(time.toString(), 'HH:mm:ss').format('h:mm A');
};
</script>
<script lang="ts">
import { liveValidator, validate } from '@svelte-toolkit/validate';
import Label from './Label.svelte';
import StyledRawInput from './StyledRawInput.svelte';
import moment from 'moment';
import type { ClassValue } from 'svelte/elements';
import { generateIdentifier, prefixZero, targetMust } from './util';
import { FocusManager } from './focus';
import { Time } from '@internationalized/date';
import { incrementValue, decrementValue } from './numeric-utils';
interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string;
/** Label for the input */
label?: string;
/** Bindable Time value */
value?: Time | null;
/** Bindable formatted time string, always matches current value and cannot be set */
formattedValue?: string;
/** Whether the input is required */
required?: boolean;
/** Message to show when the input is invalid */
invalidMessage?: string;
/** Controls visibility for a confirmation text below the input */
showConfirm?: boolean;
/** Whether to use compact styling */
compact?: boolean;
/** Class is applied to the root container (div element) */
class?: ClassValue | null | undefined;
/** Triggered whenever the time value is changed by the user */
onchange?: (details: { time: Time | null; formattedTime: string }) => void;
}
let {
name,
label,
value = $bindable(null),
formattedValue = $bindable(''),
required,
invalidMessage = 'Please select a time',
showConfirm = false,
compact = false,
class: classValue,
onchange
}: Props = $props();
type ampmKey = 'AM' | 'PM';
type componentKey = 'hour' | 'minute';
const id = $derived(generateIdentifier('time-input', name));
const values: Record<componentKey, string> = $derived.by(() => {
let hour = '';
if (value) {
if (value.hour > 12) {
hour = (value.hour - 12).toString();
} else if (value.hour === 0) {
hour = '12'; // 12 AM case
} else {
hour = value.hour.toString();
}
}
return {
hour,
minute: !value ? '' : prefixZero(value.minute.toString())
};
});
let ampm: ampmKey = $derived.by(() => {
if (!value) return 'AM';
return value.hour >= 12 ? 'PM' : 'AM';
});
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
const focusList = new FocusManager();
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';
}
};
/**
* 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 = (override?: Partial<Record<componentKey, string>>) => {
let hourValue = parseInt(override?.hour || values.hour);
let minuteValue = parseInt(override?.minute || values.minute);
// if no hour, clear value
if (isNaN(hourValue)) {
value = null;
formattedValue = '';
updateHiddenInput();
return;
}
// if hour is greater than 12, set to 12-hour format
if (hourValue > 12) {
hourValue -= 12;
ampm = 'PM';
}
// convert to 24-hour format
if (ampm === 'PM' && hourValue < 12) hourValue += 12;
else if (ampm === 'AM' && hourValue >= 12) hourValue -= 12;
// default minutes to zero if empty
if (isNaN(minuteValue)) {
minuteValue = 0;
}
value = new Time(hourValue, minuteValue);
updateHiddenInput();
// update formatted value
formattedValue = formatTime(value);
};
/**
* 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?.toString() ?? '';
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
onchange?.({ time: value, formattedTime: formattedValue });
};
const components: Record<
componentKey,
{
max: number;
pattern: RegExp;
onkeydown?: (e: KeyboardEvent) => void;
oninput?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
divider?: string;
placeholder?: string;
}
> = {
/** Hour Input */
hour: {
max: 24,
pattern: hour24Pattern,
onkeydown: (e: KeyboardEvent) => {
const target = targetMust<HTMLInputElement>(e);
if (e.key === ':' && target.value.length !== 0) {
focusList.focusNext();
e.preventDefault();
}
if (e.key === 'ArrowUp') {
incrementValue(target, { max: 12, start: 1 });
if (target.value === '12') toggleAMPM();
} else if (e.key === 'ArrowDown') {
decrementValue(target, { max: 12, start: 1 });
if (target.value === '11') toggleAMPM();
} else {
return;
}
updateValue({ hour: target.value });
e.preventDefault();
},
oninput: (e) => {
const target = targetMust<HTMLInputElement>(e);
if (target.value.length === 2) {
focusList.focusNext();
}
},
onblur: (e) => {
const target = targetMust<HTMLInputElement>(e);
updateValue({ hour: target.value });
},
divider: ':'
},
/** Minute Input */
minute: {
max: 59,
pattern: minutePattern,
onkeydown: (e: KeyboardEvent) => {
const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') {
incrementValue(target, { max: 59, start: 0 });
} else if (e.key === 'ArrowDown') {
decrementValue(target, { max: 59, start: 0 });
} else {
return;
}
updateValue({ minute: target.value });
e.preventDefault();
},
onblur: (e: FocusEvent) => {
const target = targetMust<HTMLInputElement>(e);
updateValue({ minute: target.value });
if (target.value.length === 1) {
target.value = '0' + target.value;
}
}
}
};
</script>
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores the selected time in 24-hour format -->
<input
type="hidden"
{id}
{name}
use:validate={{ required, autovalOnInvalid: true }}
onvalidate={(e) => (valid = e.detail.valid)}
bind:this={hiddenInput}
/>
<div class="flex items-center">
<!-- Hour, Minute Inputs -->
{#each ['hour', 'minute'] as componentKey[] as key}
{@const opts = components[key]}
<StyledRawInput
class={[
'text-center text-xl focus:placeholder:text-transparent',
compact ? 'h-9 w-16!' : 'h-16 w-24!'
]}
value={values[key]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={opts.placeholder ?? '00'}
max={opts.max}
min={0}
validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)}
forceInvalid={!valid}
onkeydown={opts.onkeydown}
oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input({ selectAll: true })}
/>
{#if opts.divider}
<span class={[compact ? 'mx-1 text-2xl' : 'mx-2 text-3xl']}>{opts.divider}</span>
{/if}
{/each}
<!-- AM/PM Picker -->
<div class={['flex', compact ? 'ml-1.5' : 'ml-3 flex-col']}>
{#each ['AM', 'PM'] as (typeof ampm)[] as shade, index}
<button
type="button"
class={[
compact ? 'px-2 py-0.5' : 'px-3 py-1',
'border-sui-accent dark:border-sui-accent/50 cursor-pointer border 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'
]
: 'bg-white',
index === 0
? compact
? 'rounded-l-sm'
: 'rounded-t-sm'
: compact
? 'rounded-r-sm'
: 'rounded-b-sm'
]}
onclick={() => {
ampm = shade;
updateValue();
}}
{@attach focusList.button()}
>
{shade}
</button>
{/each}
</div>
</div>
<div class={['opacity-0 transition-opacity', (!valid || showConfirm) && 'opacity-100']}>
<Label for={id} error={!valid}>
{#if !valid}
{invalidMessage}
{:else if showConfirm}
{formattedValue !== '' ? `See you at ${formattedValue}!` : ''}
{/if}
</Label>
</div>
</div>