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
+166 -185

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);
}
return typeof value === 'string' ? parseInt(value) : value; function parseField(field: string): number {
return field === '' ? 0 : parseInt(field, 10);
} }
function computeValue(): number { function fieldsToSeconds(): number {
return Math.max( const total =
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds), (showHours ? parseField(hours) * 3600 : 0) +
1 parseField(minutes) * 60 +
); parseField(seconds);
// Callers divide by this value, so it must stay positive.
return Math.max(total, 1);
} }
$effect(() => { function pad(field: number): string {
const val = computeValue(); return field.toString().padStart(2, '0');
untrack(() => { }
value = val;
});
});
$effect(() => { function showValue(newValue: number | undefined) {
if (value === undefined) { if (!Number.isFinite(newValue)) {
untrack(() => { hours = minutes = seconds = '';
hours = '--'; return;
minutes = '--'; }
seconds = '--'; const total = Math.max(Math.round(newValue!), 1);
}); const totalMinutes = Math.floor(total / 60);
} else {
untrack(() => {
if (value != computeValue()) {
let rounded = Math.max(Math.round(value!), 1);
if (showHours) { if (showHours) {
hours = Math.floor(rounded / 3600); hours = Math.floor(total / 3600).toString();
minutes = Math.floor((rounded % 3600) / 60) minutes = pad(totalMinutes % 60);
.toString()
.padStart(2, '0');
} else { } else {
minutes = Math.floor(rounded / 60).toString(); minutes = totalMinutes.toString();
} }
seconds = (rounded % 60).toString().padStart(2, '0'); 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(() => {
const newValue = value;
void showHours;
untrack(() => {
if (newValue !== fieldsToSeconds()) {
showValue(newValue);
} }
}); });
}
}); });
let container: HTMLDivElement; /** A field without an upper bound: hours, or minutes when there are no hours. */
let countKeyPress = 0; function normalizeUnbounded(input: string): string {
function onKeyPress(e: KeyboardEvent) { const digits = input.replace(/\D/g, '').slice(-6);
const target = e.target as HTMLInputElement | null; return digits === '' ? '' : parseInt(digits, 10).toString();
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) { }
countKeyPress++;
if (countKeyPress === 2) { /** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
const nextInput = function normalizeSexagesimal(input: string): string {
target.id === 'hours' const digits = input.replace(/\D/g, '').slice(-2);
? (container.querySelector('#minutes') as HTMLInputElement) return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
: target.id === 'minutes' }
? (container.querySelector('#seconds') as HTMLInputElement)
: null; let digitsTyped = 0;
if (nextInput) {
nextInput.focus(); function handleInput(
e: Event & { currentTarget: HTMLInputElement },
normalize: (input: string) => string,
setField: (field: string) => void,
nextInput: HTMLInputElement | null
) {
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>