add DurationInput component
This commit is contained in:
300
src/lib/DurationInput.svelte
Normal file
300
src/lib/DurationInput.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user