From f843c91284482cea9d3d411fc27aa9f15f279559 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Thu, 11 Dec 2025 15:20:42 -0800 Subject: [PATCH] add DurationInput component --- src/lib/DurationInput.svelte | 300 +++++++++++++++++++++++++++++++++++ src/lib/TimeInput.svelte | 71 +++------ src/lib/index.ts | 6 + src/lib/numeric-utils.ts | 67 ++++++++ src/routes/+page.svelte | 19 ++- 5 files changed, 408 insertions(+), 55 deletions(-) create mode 100644 src/lib/DurationInput.svelte create mode 100644 src/lib/numeric-utils.ts diff --git a/src/lib/DurationInput.svelte b/src/lib/DurationInput.svelte new file mode 100644 index 0000000..5b5561e --- /dev/null +++ b/src/lib/DurationInput.svelte @@ -0,0 +1,300 @@ + + + + +
+ {#if label} + + {/if} + + + (valid = e.detail.valid)} + bind:this={hiddenInput} + /> + +
+ {#each selectedComponents as componentKey[] as key, index} + {@const opts = components[key]} + {@const partID = generateIdentifier('duration-input-part', key)} +
+ liveValidator(n, keydownValidatorOpts)} + onkeydown={opts.onkeydown} + oninput={opts.oninput} + onblur={opts.onblur} + {@attach focusList.input({ selectAll: true })} + /> + + +
+ + {#if opts.divider && index < selectedComponents.length - 1} + + {opts.divider} + + {/if} + {/each} +
+
diff --git a/src/lib/TimeInput.svelte b/src/lib/TimeInput.svelte index 974ce40..0fc43df 100644 --- a/src/lib/TimeInput.svelte +++ b/src/lib/TimeInput.svelte @@ -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(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; } diff --git a/src/lib/index.ts b/src/lib/index.ts index 96a2fac..461f10f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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'; diff --git a/src/lib/numeric-utils.ts b/src/lib/numeric-utils.ts new file mode 100644 index 0000000..5a5e3b8 --- /dev/null +++ b/src/lib/numeric-utils.ts @@ -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; + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 51b01f8..c905127 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,10 @@