325 lines
8.7 KiB
Svelte
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>
|