rework time component

This commit is contained in:
vcoppe committed 2026-11-08 14:03:48 +01:00
1 parent 419489bcfe
commit 9e289c33be
3 files changed
+170 -189

No files matched your search

@@ -218,6 +218,7 @@
</span> </span>
{:else} {:else}
<TimePicker <TimePicker
id="speed"
bind:value={speed} bind:value={speed}
showHours={false} showHours={false}
disabled={!canUpdate} disabled={!canUpdate}
@@ -243,6 +244,7 @@
{i18n._('toolbar.time.total_time')} {i18n._('toolbar.time.total_time')}
</Label> </Label>
<TimePicker <TimePicker
id="duration"
bind:value={movingTime} bind:value={movingTime}
disabled={!canUpdate} disabled={!canUpdate}
onChange={() => { onChange={() => {
@@ -1,63 +1,40 @@
<script lang="ts"> <script lang="ts" module>
import { Input } from '$lib/components/ui/input'; /** Shown by an empty field: real text rather than a placeholder, so that it can be selected. */
export const EMPTY_FIELD = '--';
let {
id,
value = $bindable(),
disabled,
oninput = () => {},
onchange = () => {},
onkeypress = () => {},
onfocusin = () => {},
class: className,
}: {
id: string;
value: string | number;
disabled?: boolean;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onkeypress?: (e: KeyboardEvent) => void;
onfocusin?: (e: FocusEvent) => void;
class?: string;
} = $props();
</script> </script>
<div> <script lang="ts">
<Input import { cn, type WithElementRef } from '$lib/utils.js';
{id} import type { HTMLInputAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
value,
class: className,
...restProps
}: WithElementRef<Omit<HTMLInputAttributes, 'type' | 'value'>> & { value: string } = $props();
// The padding of the fields of a native time input, which is larger in Firefox.
const padding = 'px-px [@supports(-moz-appearance:none)]:px-[3px]';
</script>
<!-- The invisible copy of the text sizes the field to its exact width, like in a native time input. -->
<span class="relative inline-block">
<span aria-hidden="true" class={cn('invisible block whitespace-pre', padding)}>
{value || EMPTY_FIELD}
</span>
<input
bind:this={ref}
type="text" type="text"
step={1} inputmode="numeric"
bind:value autocomplete="off"
{disabled} value={value || EMPTY_FIELD}
{oninput} class={cn(
{onchange} 'absolute inset-0 w-full bg-transparent text-center outline-none',
{onkeypress} padding,
onfocusin={(e) => { className
let input = document.activeElement; )}
if (input instanceof HTMLInputElement) { onfocus={(e) => e.currentTarget.select()}
input.select(); {...restProps}
}
onfocusin(e);
}}
class="w-[22px] {className ?? ''}"
/> />
</div> </span>
<style lang="postcss">
@reference "tailwindcss";
div :global(input) {
@apply px-0.5;
@apply py-0;
@apply bg-transparent;
@apply text-right;
@apply border-none;
@apply shadow-none;
@apply focus:ring-0;
@apply focus:ring-offset-0;
@apply focus:outline-none;
@apply focus-visible:ring-0;
@apply focus-visible:ring-offset-0;
@apply focus-visible:outline-none;
}
</style>
@@ -1,174 +1,176 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import TimeComponentInput from './TimeComponentInput.svelte'; import { cn } from '$lib/utils.js';
import TimeComponentInput, { EMPTY_FIELD } from './TimeComponentInput.svelte';
let { let {
id,
showHours = true, showHours = true,
value = $bindable(), value = $bindable(),
disabled = false, disabled = false,
onChange = () => {}, onChange = () => {},
}: { }: {
/** Set on the first field, so that a label can target the picker. */
id?: string;
showHours?: boolean; showHours?: boolean;
value?: number; value?: number;
disabled?: boolean; disabled?: boolean;
onChange?: () => void; onChange?: () => void;
} = $props(); } = $props();
let hours: string | number = $state('--'); let hours = $state('');
let minutes: string | number = $state('--'); let minutes = $state('');
let seconds: string | number = $state('--'); let seconds = $state('');
function maybeParseInt(value: string | number): number { let hoursInput: HTMLInputElement | null = $state(null);
if (value === '--' || value === '') { let minutesInput: HTMLInputElement | null = $state(null);
return 0; let secondsInput: HTMLInputElement | null = $state(null);
function parseField(field: string): number {
return field === '' ? 0 : parseInt(field, 10);
}
function fieldsToSeconds(): number {
const total =
(showHours ? parseField(hours) * 3600 : 0) +
parseField(minutes) * 60 +
parseField(seconds);
// Callers divide by this value, so it must stay positive.
return Math.max(total, 1);
}
function pad(field: number): string {
return field.toString().padStart(2, '0');
}
function showValue(newValue: number | undefined) {
if (!Number.isFinite(newValue)) {
hours = minutes = seconds = '';
return;
} }
return typeof value === 'string' ? parseInt(value) : value; const total = Math.max(Math.round(newValue!), 1);
} const totalMinutes = Math.floor(total / 60);
if (showHours) {
function computeValue(): number { hours = Math.floor(total / 3600).toString();
return Math.max( minutes = pad(totalMinutes % 60);
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds), } else {
1 minutes = totalMinutes.toString();
); }
seconds = pad(total % 60);
} }
// Only the display follows `value`: it is written back on user input only, so that showing a
// value never rounds or overwrites it. A value that matches the fields comes from them, and
// must not reformat them during typing.
$effect(() => { $effect(() => {
const val = computeValue(); const newValue = value;
void showHours;
untrack(() => { untrack(() => {
value = val; if (newValue !== fieldsToSeconds()) {
showValue(newValue);
}
}); });
}); });
$effect(() => { /** A field without an upper bound: hours, or minutes when there are no hours. */
if (value === undefined) { function normalizeUnbounded(input: string): string {
untrack(() => { const digits = input.replace(/\D/g, '').slice(-6);
hours = '--'; return digits === '' ? '' : parseInt(digits, 10).toString();
minutes = '--'; }
seconds = '--';
});
} else {
untrack(() => {
if (value != computeValue()) {
let rounded = Math.max(Math.round(value!), 1);
if (showHours) {
hours = Math.floor(rounded / 3600);
minutes = Math.floor((rounded % 3600) / 60)
.toString()
.padStart(2, '0');
} else {
minutes = Math.floor(rounded / 60).toString();
}
seconds = (rounded % 60).toString().padStart(2, '0');
}
});
}
});
let container: HTMLDivElement; /** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
let countKeyPress = 0; function normalizeSexagesimal(input: string): string {
function onKeyPress(e: KeyboardEvent) { const digits = input.replace(/\D/g, '').slice(-2);
const target = e.target as HTMLInputElement | null; return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) { }
countKeyPress++;
if (countKeyPress === 2) { let digitsTyped = 0;
const nextInput =
target.id === 'hours' function handleInput(
? (container.querySelector('#minutes') as HTMLInputElement) e: Event & { currentTarget: HTMLInputElement },
: target.id === 'minutes' normalize: (input: string) => string,
? (container.querySelector('#seconds') as HTMLInputElement) setField: (field: string) => void,
: null; nextInput: HTMLInputElement | null
if (nextInput) { ) {
nextInput.focus(); const input = e.currentTarget;
} const field = normalize(input.value);
// The state is unchanged when the input is rejected (a letter, for example), in which case
// Svelte would leave the rejected text in the DOM.
input.value = field || EMPTY_FIELD;
if (!field) {
input.select();
}
setField(field);
value = fieldsToSeconds();
onChange();
if (/^\d$/.test((e as unknown as InputEvent).data ?? '')) {
digitsTyped++;
if (digitsTyped === 2) {
nextInput?.focus();
} }
} }
} }
function handlePointerDown(e: PointerEvent) {
// Like a native input, the whole box gives focus, not only the text.
if (e.target === e.currentTarget) {
e.preventDefault();
(hoursInput ?? minutesInput)?.focus();
}
}
function handleFocusOut(e: FocusEvent & { currentTarget: HTMLDivElement }) {
// Fill in the fields left empty once the user is done with the picker.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
showValue(value);
}
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={container} data-slot="time-picker"
class="h-9 flex flex-row items-center w-full min-w-fit border rounded-md px-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 {disabled class={cn(
? 'opacity-50 cursor-not-allowed' 'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex h-8 w-full min-w-28 cursor-text items-center justify-end rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors focus-within:ring-3 md:text-sm',
: ''}" disabled && 'bg-input/50 dark:bg-input/80 pointer-events-none cursor-not-allowed opacity-50'
)}
onpointerdown={handlePointerDown}
onfocusin={() => {
digitsTyped = 0;
}}
onfocusout={handleFocusOut}
> >
{#if showHours} {#if showHours}
<TimeComponentInput <TimeComponentInput
id="hours" {id}
bind:value={hours} bind:ref={hoursInput}
value={hours}
{disabled} {disabled}
class="w-[30px]" oninput={(e) =>
oninput={() => { handleInput(e, normalizeUnbounded, (field) => (hours = field), minutesInput)}
if (typeof hours === 'string') {
hours = parseInt(hours);
}
if (hours < 0) {
hours = 0;
}
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
<span class="text-sm">:</span> <span>:</span>
{/if} {/if}
<TimeComponentInput <TimeComponentInput
id="minutes" id={showHours ? undefined : id}
bind:value={minutes} bind:ref={minutesInput}
value={minutes}
{disabled} {disabled}
oninput={() => { oninput={(e) =>
if (typeof minutes === 'string') { handleInput(
minutes = parseInt(minutes); e,
} showHours ? normalizeSexagesimal : normalizeUnbounded,
if (showHours && minutes > 59) { (field) => (minutes = field),
minutes = 59; secondsInput
} else if (minutes < 0) { )}
minutes = 0;
}
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
<span class="text-sm">:</span> <span>:</span>
<TimeComponentInput <TimeComponentInput
id="seconds" bind:ref={secondsInput}
bind:value={seconds} value={seconds}
{disabled} {disabled}
oninput={() => { oninput={(e) => handleInput(e, normalizeSexagesimal, (field) => (seconds = field), null)}
if (typeof seconds === 'string') {
seconds = parseInt(seconds);
}
if (seconds > 59) {
seconds = 59;
} else if (seconds < 0) {
seconds = 0;
}
seconds = seconds.toString().padStart(2, '0');
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
</div> </div>
<style>
div :global(input::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
div :global(input::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
div :global(input[type='number']) {
appearance: textfield;
-moz-appearance: textfield;
}
</style>