add DurationInput component

This commit is contained in:
Elijah Duffy
2025-12-11 15:20:42 -08:00
parent a7fa9fd6d8
commit f843c91284
5 changed files with 408 additions and 55 deletions

View File

@@ -0,0 +1,300 @@
<script lang="ts" module>
/**
* formatDuration returns a human readable string for a DateTimeDuration.
*
* @param duration The duration to format, can be null or undefined.
* @param fallback Optional fallback string to return if duration is null or undefined.
* If not provided, an empty string will be returned.
* If provided, it will be returned when duration is null or undefined.
* @returns The duration in human readable format or the fallback string if undefined.
*/
export const formatDuration = (
duration: DateTimeDuration | null | undefined,
fallback?: string
): string => {
if (!duration) return fallback ?? '';
const parts: string[] = [];
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds) parts.push(`${duration.seconds}s`);
return parts.join(' ');
};
/**
* durationToISO8601 converts a DateTimeDuration to an ISO 8601 duration string.
* @param duration The duration to convert.
* @returns The ISO 8601 duration string.
*/
export const durationToISO8601 = (duration: DateTimeDuration): string => {
let str = 'P';
if (duration.days) str += `${duration.days}D`;
if (duration.hours || duration.minutes || duration.seconds) str += 'T';
if (duration.hours) str += `${duration.hours}H`;
if (duration.minutes) str += `${duration.minutes}M`;
if (duration.seconds) str += `${duration.seconds}S`;
return str;
};
/**
* iso8601ToDuration converts an ISO 8601 duration string to a DateTimeDuration.
* @param str The ISO 8601 duration string.
* @returns The DateTimeDuration object.
*/
export const iso8601ToDuration = (str: string): DateTimeDuration => {
const regex = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const matches = str.match(regex);
if (!matches) {
throw new Error('Invalid ISO 8601 duration string');
}
return {
days: matches[1] ? parseInt(matches[1], 10) : 0,
hours: matches[2] ? parseInt(matches[2], 10) : 0,
minutes: matches[3] ? parseInt(matches[3], 10) : 0,
seconds: matches[4] ? parseInt(matches[4], 10) : 0
};
};
</script>
<script lang="ts">
import type { DateTimeDuration } from '@internationalized/date';
import type { ClassValue } from 'svelte/elements';
import { FocusManager } from './focus';
import { generateIdentifier, prefixZero, targetMust } from './util';
import { decrementValue, incrementValue } from './numeric-utils';
import Label from './Label.svelte';
import { liveValidator, validate } from '@svelte-toolkit/validate';
import StyledRawInput from './StyledRawInput.svelte';
interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string;
/** Label for the input */
label?: string;
/** Precision for duration input */
precision?: { min: componentKey; max: componentKey };
/** Bindable DateTimeDuration value */
value?: DateTimeDuration | null;
/** Bindable formatted duration 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;
/** Whether to use compact styling */
compact?: boolean;
/** Additional classes are applied to the root container (div element) */
class?: ClassValue | null;
/** Triggered whenever the duration value is changed by the user */
onchange?: (details: { duration: DateTimeDuration | null; formattedDuration: string }) => void;
}
let {
name,
label,
precision = { max: 'minutes', min: 'hours' },
value = $bindable(null),
formattedValue = $bindable(''),
required = false,
invalidMessage = 'Please enter a duration',
compact,
class: classValue,
onchange
}: Props = $props();
const COMPONENT_KEYS = ['days', 'hours', 'minutes', 'seconds'] as const;
type componentKey = (typeof COMPONENT_KEYS)[number];
/** selected components are controlled by min and max precision */
let selectedComponents: componentKey[] = $derived.by(() => {
const minIndex = COMPONENT_KEYS.indexOf(precision.min);
const maxIndex = COMPONENT_KEYS.indexOf(precision.max);
if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
throw new Error('Invalid precision settings for DurationInput');
}
return COMPONENT_KEYS.slice(minIndex, maxIndex + 1);
});
const focusList = new FocusManager();
const numericPattern = /^\d*$/;
const keydownValidatorOpts = { constrain: true };
const id = $derived(generateIdentifier('duration-input', name));
const values: Record<componentKey, string> = $derived.by(() => {
return COMPONENT_KEYS.reduce(
(acc, key) => {
if (!value || value[key] === 0) {
acc[key] = '';
} else {
acc[key] = prefixZero(value[key]?.toString() || '0');
}
return acc;
},
{} as Record<componentKey, string>
);
});
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
/** updateValue updates `value` with the current values from the input fields */
const updateValue = (override?: Partial<Record<componentKey, string>>) => {
const newValues: Record<componentKey, number> = {
days: parseInt(override?.days ?? values.days) || 0,
hours: parseInt(override?.hours ?? values.hours) || 0,
minutes: parseInt(override?.minutes ?? values.minutes) || 0,
seconds: parseInt(override?.seconds ?? values.seconds) || 0
};
let zero = true;
for (const key of selectedComponents) {
if (newValues[key] !== 0) {
zero = false;
break;
}
}
if (zero) {
value = null;
} else if (!value) {
// Create a new value object if we don't have one yet
value = newValues;
} else {
// Otherwise apply all new values to existing value object
for (const [k, v] of Object.entries(newValues)) {
value[k as componentKey] = v;
}
}
updateHiddenInput();
// update formatted value
formattedValue = formatDuration(value);
};
/**
* updateHiddenInput pushes the current value to the hidden input in ISO
* 8601 duration format and triggers a keyup event to allow validation to
* detect the change.
*/
const updateHiddenInput = () => {
// TODO: Make sure this formats correctly
hiddenInput.value = value ? durationToISO8601(value) : '';
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
onchange?.({ duration: value, formattedDuration: formattedValue });
console.log('updated hidden input', hiddenInput.value);
};
/**
* buildEventHandlers generates a fairly generic onkeydown and onblur
* handler pair for a given component key. Start is always 0, max is
* unset for all keys. No special handling per key.
*/
const buildEventHandlers = (key: componentKey) => {
return {
onkeydown: (e: KeyboardEvent) => {
const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') {
incrementValue(target, { start: 0 });
} else if (e.key === 'ArrowDown') {
decrementValue(target, { start: 0 });
} else {
return;
}
updateValue({ [key]: target.value });
e.preventDefault();
},
onblur: (e: FocusEvent) => {
const target = targetMust<HTMLInputElement>(e);
updateValue({ [key]: target.value });
}
};
};
/** component definitions */
const components: Record<
componentKey,
{
pattern: RegExp;
onkeydown?: (e: KeyboardEvent) => void;
oninput?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
divider?: string;
placeholder?: string;
}
> = {
days: {
pattern: numericPattern,
...buildEventHandlers('days')
},
hours: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('hours')
},
minutes: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('minutes')
},
seconds: {
pattern: numericPattern,
placeholder: '0',
...buildEventHandlers('seconds')
}
};
</script>
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores the selected duration in ISO 8601 format -->
<input
type="hidden"
{id}
{name}
use:validate={{ required, autovalOnInvalid: true }}
onvalidate={(e) => (valid = e.detail.valid)}
bind:this={hiddenInput}
/>
<div class="flex items-start">
{#each selectedComponents as componentKey[] as key, index}
{@const opts = components[key]}
{@const partID = generateIdentifier('duration-input-part', key)}
<div class="flex flex-col items-center">
<StyledRawInput
id={partID}
class={[
'text-center text-xl focus:placeholder:text-transparent',
compact ? 'h-9 w-16! placeholder:text-base' : 'h-16 w-24!'
]}
value={values[key]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={opts.placeholder}
validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)}
onkeydown={opts.onkeydown}
oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input({ selectAll: true })}
/>
<Label for={partID} class={['capitalize', compact && '-mt-0.5']}>{key}</Label>
</div>
{#if opts.divider && index < selectedComponents.length - 1}
<span class={[compact ? 'mx-1 text-2xl' : 'mx-2 mt-3 text-3xl']}>
{opts.divider}
</span>
{/if}
{/each}
</div>
</div>

View File

@@ -23,17 +23,31 @@
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;
}
@@ -95,55 +109,6 @@
}
};
/**
* 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 => {
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;
}
};
/**
* 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 => {
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;
}
};
/**
* 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.
@@ -216,10 +181,10 @@
}
if (e.key === 'ArrowUp') {
incrementValue(target, 12, 1);
incrementValue(target, { max: 12, start: 1 });
if (target.value === '12') toggleAMPM();
} else if (e.key === 'ArrowDown') {
decrementValue(target, 12, 1);
decrementValue(target, { max: 12, start: 1 });
if (target.value === '11') toggleAMPM();
} else {
return;
@@ -248,9 +213,9 @@
const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') {
incrementValue(target, 59, 0);
incrementValue(target, { max: 59, start: 0 });
} else if (e.key === 'ArrowDown') {
decrementValue(target, 59, 0);
decrementValue(target, { max: 59, start: 0 });
} else {
return;
}

View File

@@ -6,6 +6,12 @@ export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.svelte';
export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte';
export {
default as DurationInput,
formatDuration,
durationToISO8601,
iso8601ToDuration
} from './DurationInput.svelte';
export { default as ErrorBox } from './ErrorBox.svelte';
export { default as FramelessButton } from './FramelessButton.svelte';
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';

67
src/lib/numeric-utils.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* numeric-utils.ts
* Utility functions for numeric input manipulation.
*/
/**
* 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
*/
export const incrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
if (input.value.length === 0) {
input.value = opts.start.toString();
return true;
}
const value = parseInt(input.value);
if (value === opts.max) {
input.value = opts.start.toString();
return false;
} else if (opts.max && value > opts.max) {
input.value = (value - opts.max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
/**
* 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
*/
export const decrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
const setToMax = (): boolean => {
if (opts.max) {
input.value = opts.max.toString();
return true;
} else {
input.value = '0';
return false;
}
};
if (input.value.length === 0) {
return setToMax();
}
const value = parseInt(input.value);
if (value <= opts.start) {
return !setToMax();
} else {
input.value = (value - 1).toString();
return true;
}
};

View File

@@ -1,5 +1,10 @@
<script lang="ts">
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
import {
CalendarDate,
today,
getLocalTimeZone,
type TimeDuration
} from '@internationalized/date';
import Button from '$lib/Button.svelte';
import ActionSelect from '$lib/ActionSelect.svelte';
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
@@ -33,6 +38,7 @@
import { onMount, type Component } from 'svelte';
import ErrorBox from '$lib/ErrorBox.svelte';
import TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
// Lazy-load heavy components
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
@@ -60,6 +66,7 @@
{ value: 'complex', label: 'Complex item' }
]);
let timeValue = $state<Time | null>(null);
let durationValue = $state<TimeDuration | null>(null);
const toolbar = new Toolbar();
const fontGroup = toolbar.group();
@@ -286,11 +293,18 @@
</div>
<div class="component">
<p class="title">Time Input</p>
<p class="title">Time & Duration Input</p>
<InputGroup class="gap-8">
<TimeInput label="Regular time input" name="example-time-input" />
<TimeInput label="Compact time" compact bind:value={timeValue} />
<DurationInput
label="Duration input"
name="example-duration-input"
precision={{ min: 'hours', max: 'seconds' }}
bind:value={durationValue}
/>
<DurationInput label="Compact duration" compact />
</InputGroup>
<InputGroup>
<p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p>
@@ -301,6 +315,7 @@
>
Set 3:00 PM
</Button>
<p>Precise duration is {formatDuration(durationValue)}</p>
</InputGroup>
</div>