Compare commits
8 Commits
f260038aac
...
3409adc614
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3409adc614 | ||
|
|
a538db4065 | ||
|
|
9d0f10f0fd | ||
|
|
df1dd238e2 | ||
|
|
0608343741 | ||
|
|
f843c91284 | ||
|
|
a7fa9fd6d8 | ||
|
|
7fd2bbb879 |
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name?: string;
|
name?: string;
|
||||||
value?: CalendarDate;
|
value?: CalendarDate | null;
|
||||||
min?: CalendarDate;
|
min?: CalendarDate;
|
||||||
// max?: CalendarDate; // TODO: Implement validation.
|
// max?: CalendarDate; // TODO: Implement validation.
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -24,11 +24,12 @@
|
|||||||
invalidMessage?: string;
|
invalidMessage?: string;
|
||||||
class?: ClassValue | undefined | null;
|
class?: ClassValue | undefined | null;
|
||||||
format?: FormatString[];
|
format?: FormatString[];
|
||||||
|
onchange?: (date: CalendarDate | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
value = $bindable<CalendarDate | undefined>(),
|
value = $bindable<CalendarDate | null>(null),
|
||||||
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
||||||
min = new CalendarDate(1900, 0, 1),
|
min = new CalendarDate(1900, 0, 1),
|
||||||
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
||||||
@@ -37,7 +38,8 @@
|
|||||||
required = false,
|
required = false,
|
||||||
invalidMessage = 'Valid date is required',
|
invalidMessage = 'Valid date is required',
|
||||||
class: classValue,
|
class: classValue,
|
||||||
format = ['year', 'month', 'day']
|
format = ['year', 'month', 'day'],
|
||||||
|
onchange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const id = $derived(generateIdentifier('dateinput', name));
|
const id = $derived(generateIdentifier('dateinput', name));
|
||||||
@@ -108,7 +110,8 @@
|
|||||||
if (value) {
|
if (value) {
|
||||||
setPrevious();
|
setPrevious();
|
||||||
previousYearValue = undefined;
|
previousYearValue = undefined;
|
||||||
value = undefined;
|
value = null;
|
||||||
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,7 +124,8 @@
|
|||||||
if (value) {
|
if (value) {
|
||||||
setPrevious();
|
setPrevious();
|
||||||
previousMonthValue = undefined;
|
previousMonthValue = undefined;
|
||||||
value = undefined;
|
value = null;
|
||||||
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,7 +138,8 @@
|
|||||||
if (value) {
|
if (value) {
|
||||||
setPrevious();
|
setPrevious();
|
||||||
previousDayValue = undefined;
|
previousDayValue = undefined;
|
||||||
value = undefined;
|
value = null;
|
||||||
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -146,6 +151,7 @@
|
|||||||
day ?? (value ? value.day : min.day)
|
day ?? (value ? value.day : min.day)
|
||||||
);
|
);
|
||||||
value = newDate;
|
value = newDate;
|
||||||
|
onchange?.(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
type caretPos = { start: number; end: number } | null;
|
type caretPos = { start: number; end: number } | null;
|
||||||
|
|||||||
308
src/lib/DurationInput.svelte
Normal file
308
src/lib/DurationInput.svelte
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
/**
|
||||||
|
* formatDuration returns a human readable string for a TimeDuration.
|
||||||
|
*
|
||||||
|
* @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: TimeDuration | null | undefined,
|
||||||
|
fallback?: string
|
||||||
|
): string => {
|
||||||
|
if (!duration) return fallback ?? '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
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 TimeDuration to an ISO 8601 duration string.
|
||||||
|
* @param duration The duration to convert.
|
||||||
|
* @returns The ISO 8601 duration string.
|
||||||
|
*/
|
||||||
|
export const durationToISO8601 = (duration: TimeDuration): string => {
|
||||||
|
let str = 'P';
|
||||||
|
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 TimeDuration.
|
||||||
|
* @param str The ISO 8601 duration string.
|
||||||
|
* @returns The TimeDuration object.
|
||||||
|
*/
|
||||||
|
export const iso8601ToDuration = (str: string): TimeDuration => {
|
||||||
|
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 {
|
||||||
|
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 { TimeDuration } from '@internationalized/date';
|
||||||
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import { FocusManager } from './focus';
|
||||||
|
import { generateIdentifier, 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 TimeDuration value */
|
||||||
|
value?: TimeDuration | 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 | null;
|
||||||
|
/** 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: TimeDuration | null; formattedDuration: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
precision = { max: 'minutes', min: 'hours' },
|
||||||
|
value = $bindable(null),
|
||||||
|
formattedValue = $bindable(''),
|
||||||
|
required = false,
|
||||||
|
invalidMessage = null,
|
||||||
|
compact,
|
||||||
|
class: classValue,
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const COMPONENT_KEYS = ['hours', 'minutes', 'seconds', 'milliseconds'] 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] = 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> = {
|
||||||
|
hours: parseInt(override?.hours ?? values.hours) || 0,
|
||||||
|
minutes: parseInt(override?.minutes ?? values.minutes) || 0,
|
||||||
|
seconds: parseInt(override?.seconds ?? values.seconds) || 0,
|
||||||
|
milliseconds: parseInt(override?.milliseconds ?? values.milliseconds) || 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 = () => {
|
||||||
|
hiddenInput.value = value ? durationToISO8601(value) : '';
|
||||||
|
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
|
||||||
|
onchange?.({ duration: value, formattedDuration: formattedValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
{
|
||||||
|
label?: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
onkeydown?: (e: KeyboardEvent) => void;
|
||||||
|
oninput?: (e: Event) => void;
|
||||||
|
onblur?: (e: FocusEvent) => void;
|
||||||
|
divider?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
hours: {
|
||||||
|
pattern: numericPattern,
|
||||||
|
divider: ':',
|
||||||
|
placeholder: '0',
|
||||||
|
...buildEventHandlers('hours')
|
||||||
|
},
|
||||||
|
minutes: {
|
||||||
|
pattern: numericPattern,
|
||||||
|
divider: ':',
|
||||||
|
placeholder: '0',
|
||||||
|
...buildEventHandlers('minutes')
|
||||||
|
},
|
||||||
|
seconds: {
|
||||||
|
pattern: numericPattern,
|
||||||
|
divider: '.',
|
||||||
|
placeholder: '0',
|
||||||
|
...buildEventHandlers('seconds')
|
||||||
|
},
|
||||||
|
milliseconds: {
|
||||||
|
label: 'MS',
|
||||||
|
pattern: numericPattern,
|
||||||
|
placeholder: '000',
|
||||||
|
...buildEventHandlers('milliseconds')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</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)}
|
||||||
|
forceInvalid={!valid}
|
||||||
|
onkeydown={opts.onkeydown}
|
||||||
|
oninput={opts.oninput}
|
||||||
|
onblur={opts.onblur}
|
||||||
|
{@attach focusList.input({ selectAll: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Label for={partID} class={['capitalize', compact && '-mt-0.5']}>{opts.label ?? 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>
|
||||||
|
|
||||||
|
{#if !valid && invalidMessage}
|
||||||
|
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
||||||
|
<Label for={id} error={true}>
|
||||||
|
{invalidMessage}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
use?: (node: HTMLInputElement) => void;
|
use?: (node: HTMLInputElement) => void;
|
||||||
ref?: HTMLInputElement | null;
|
ref?: HTMLInputElement | null;
|
||||||
|
/** Forces the input to be visually marked as invalid */
|
||||||
|
forceInvalid?: boolean;
|
||||||
onvalidate?: (e: InputValidatorEvent) => void;
|
onvalidate?: (e: InputValidatorEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
compact = false,
|
compact = false,
|
||||||
use,
|
use,
|
||||||
ref = $bindable<HTMLInputElement | null>(null),
|
ref = $bindable<HTMLInputElement | null>(null),
|
||||||
|
forceInvalid = false,
|
||||||
onvalidate,
|
onvalidate,
|
||||||
class: classValue,
|
class: classValue,
|
||||||
...others
|
...others
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800',
|
'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800',
|
||||||
'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2',
|
'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2',
|
||||||
!compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2',
|
!compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2',
|
||||||
!valid && 'border-red-500!',
|
(!valid || forceInvalid) && 'border-red-500!',
|
||||||
disabled &&
|
disabled &&
|
||||||
'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed',
|
'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed',
|
||||||
classValue
|
classValue
|
||||||
|
|||||||
@@ -23,17 +23,31 @@
|
|||||||
import { generateIdentifier, prefixZero, targetMust } from './util';
|
import { generateIdentifier, prefixZero, targetMust } from './util';
|
||||||
import { FocusManager } from './focus';
|
import { FocusManager } from './focus';
|
||||||
import { Time } from '@internationalized/date';
|
import { Time } from '@internationalized/date';
|
||||||
|
import { incrementValue, decrementValue } from './numeric-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Name of the input for form integration, form will receive ISO 8601
|
||||||
|
* formatted string.
|
||||||
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Label for the input */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Bindable Time value */
|
||||||
value?: Time | null;
|
value?: Time | null;
|
||||||
|
/** Bindable formatted time string, always matches current value and cannot be set */
|
||||||
formattedValue?: string;
|
formattedValue?: string;
|
||||||
|
/** Whether the input is required */
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
/** Message to show when the input is invalid */
|
||||||
invalidMessage?: string;
|
invalidMessage?: string;
|
||||||
|
/** Controls visibility for a confirmation text below the input */
|
||||||
showConfirm?: boolean;
|
showConfirm?: boolean;
|
||||||
|
/** Whether to use compact styling */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** Class is applied to the root container (div element) */
|
||||||
class?: ClassValue | null | undefined;
|
class?: ClassValue | null | undefined;
|
||||||
|
/** Triggered whenever the time value is changed by the user */
|
||||||
onchange?: (details: { time: Time | null; formattedTime: string }) => void;
|
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.
|
* 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.
|
* If any component is invalid or blank, it sets `value` to an empty string.
|
||||||
@@ -216,10 +181,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
incrementValue(target, 12, 1);
|
incrementValue(target, { max: 12, start: 1 });
|
||||||
if (target.value === '12') toggleAMPM();
|
if (target.value === '12') toggleAMPM();
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
decrementValue(target, 12, 1);
|
decrementValue(target, { max: 12, start: 1 });
|
||||||
if (target.value === '11') toggleAMPM();
|
if (target.value === '11') toggleAMPM();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
@@ -248,9 +213,9 @@
|
|||||||
const target = targetMust<HTMLInputElement>(e);
|
const target = targetMust<HTMLInputElement>(e);
|
||||||
|
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
incrementValue(target, 59, 0);
|
incrementValue(target, { max: 59, start: 0 });
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
decrementValue(target, 59, 0);
|
decrementValue(target, { max: 59, start: 0 });
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -301,6 +266,7 @@
|
|||||||
min={0}
|
min={0}
|
||||||
validate={{ pattern: opts.pattern }}
|
validate={{ pattern: opts.pattern }}
|
||||||
use={(n) => liveValidator(n, keydownValidatorOpts)}
|
use={(n) => liveValidator(n, keydownValidatorOpts)}
|
||||||
|
forceInvalid={!valid}
|
||||||
onkeydown={opts.onkeydown}
|
onkeydown={opts.onkeydown}
|
||||||
oninput={opts.oninput}
|
oninput={opts.oninput}
|
||||||
onblur={opts.onblur}
|
onblur={opts.onblur}
|
||||||
|
|||||||
@@ -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 Combobox, type ComboboxOption } from './Combobox.svelte';
|
||||||
export { default as DateInput } from './DateInput.svelte';
|
export { default as DateInput } from './DateInput.svelte';
|
||||||
export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.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 ErrorBox } from './ErrorBox.svelte';
|
||||||
export { default as FramelessButton } from './FramelessButton.svelte';
|
export { default as FramelessButton } from './FramelessButton.svelte';
|
||||||
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
|
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
|
||||||
|
|||||||
67
src/lib/numeric-utils.ts
Normal file
67
src/lib/numeric-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -110,12 +110,12 @@ export const capitalizeFirstLetter = (str: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* prefixZero adds a leading zero to the string if it is less than 10
|
* prefixZero adds a leading zero to the string if it is less than 10 and not 0
|
||||||
* @param str The string to prefix
|
* @param str The string to prefix
|
||||||
* @returns The string with a leading zero if it was only 1 digit long
|
* @returns The string with a leading zero if it was only 1 digit long
|
||||||
*/
|
*/
|
||||||
export const prefixZero = (str: string): string => {
|
export const prefixZero = (str: string): string => {
|
||||||
if (str.length === 1) {
|
if (str.length === 1 && str !== '0') {
|
||||||
return '0' + str;
|
return '0' + str;
|
||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<script lang="ts">
|
<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 Button from '$lib/Button.svelte';
|
||||||
import ActionSelect from '$lib/ActionSelect.svelte';
|
import ActionSelect from '$lib/ActionSelect.svelte';
|
||||||
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
|
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
import { onMount, type Component } from 'svelte';
|
import { onMount, type Component } from 'svelte';
|
||||||
import ErrorBox from '$lib/ErrorBox.svelte';
|
import ErrorBox from '$lib/ErrorBox.svelte';
|
||||||
import TextareaInput from '$lib/TextareaInput.svelte';
|
import TextareaInput from '$lib/TextareaInput.svelte';
|
||||||
|
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
|
||||||
|
|
||||||
// Lazy-load heavy components
|
// Lazy-load heavy components
|
||||||
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
|
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
|
||||||
@@ -53,13 +59,14 @@
|
|||||||
let dateInputValue = $state<CalendarDate | undefined>(undefined);
|
let dateInputValue = $state<CalendarDate | undefined>(undefined);
|
||||||
let checkboxValue = $state<CheckboxState>('indeterminate');
|
let checkboxValue = $state<CheckboxState>('indeterminate');
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let scrollableDialogOpen = $state(true);
|
let scrollableDialogOpen = $state(false);
|
||||||
let toggleOptions: Option[] = $state([
|
let toggleOptions: Option[] = $state([
|
||||||
'item one',
|
'item one',
|
||||||
'item two',
|
'item two',
|
||||||
{ value: 'complex', label: 'Complex item' }
|
{ value: 'complex', label: 'Complex item' }
|
||||||
]);
|
]);
|
||||||
let timeValue = $state<Time | null>(null);
|
let timeValue = $state<Time | null>(null);
|
||||||
|
let durationValue = $state<TimeDuration | null>(null);
|
||||||
|
|
||||||
const toolbar = new Toolbar();
|
const toolbar = new Toolbar();
|
||||||
const fontGroup = toolbar.group();
|
const fontGroup = toolbar.group();
|
||||||
@@ -286,11 +293,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="component">
|
<div class="component">
|
||||||
<p class="title">Time Input</p>
|
<p class="title">Time & Duration Input</p>
|
||||||
|
|
||||||
<InputGroup class="gap-8">
|
<InputGroup class="gap-8">
|
||||||
<TimeInput label="Regular time input" name="example-time-input" />
|
<TimeInput label="Regular time input" name="example-time-input" />
|
||||||
<TimeInput label="Compact time" compact bind:value={timeValue} />
|
<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>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p>
|
<p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p>
|
||||||
@@ -301,6 +315,7 @@
|
|||||||
>
|
>
|
||||||
Set 3:00 PM
|
Set 3:00 PM
|
||||||
</Button>
|
</Button>
|
||||||
|
<p>Precise duration is {formatDuration(durationValue)}</p>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user