mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
rework time component
This commit is contained in:
1 parent
419489bcfe
commit
9e289c33be
3 files changed
+166
-185
No files matched your search
@@ -218,6 +218,7 @@
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
id="speed"
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
@@ -243,6 +244,7 @@
|
||||
{i18n._('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
id="duration"
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={() => {
|
||||
|
||||
@@ -1,63 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
||||
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 lang="ts" module>
|
||||
/** Shown by an empty field: real text rather than a placeholder, so that it can be selected. */
|
||||
export const EMPTY_FIELD = '--';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
{id}
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
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"
|
||||
step={1}
|
||||
bind:value
|
||||
{disabled}
|
||||
{oninput}
|
||||
{onchange}
|
||||
{onkeypress}
|
||||
onfocusin={(e) => {
|
||||
let input = document.activeElement;
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.select();
|
||||
}
|
||||
onfocusin(e);
|
||||
}}
|
||||
class="w-[22px] {className ?? ''}"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
value={value || EMPTY_FIELD}
|
||||
class={cn(
|
||||
'absolute inset-0 w-full bg-transparent text-center outline-none',
|
||||
padding,
|
||||
className
|
||||
)}
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</span>
|
||||
@@ -1,174 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TimeComponentInput from './TimeComponentInput.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import TimeComponentInput, { EMPTY_FIELD } from './TimeComponentInput.svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
showHours = true,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
}: {
|
||||
/** Set on the first field, so that a label can target the picker. */
|
||||
id?: string;
|
||||
showHours?: boolean;
|
||||
value?: number;
|
||||
disabled?: boolean;
|
||||
onChange?: () => void;
|
||||
} = $props();
|
||||
|
||||
let hours: string | number = $state('--');
|
||||
let minutes: string | number = $state('--');
|
||||
let seconds: string | number = $state('--');
|
||||
let hours = $state('');
|
||||
let minutes = $state('');
|
||||
let seconds = $state('');
|
||||
|
||||
function maybeParseInt(value: string | number): number {
|
||||
if (value === '--' || value === '') {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === 'string' ? parseInt(value) : value;
|
||||
let hoursInput: HTMLInputElement | null = $state(null);
|
||||
let minutesInput: HTMLInputElement | null = $state(null);
|
||||
let secondsInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function parseField(field: string): number {
|
||||
return field === '' ? 0 : parseInt(field, 10);
|
||||
}
|
||||
|
||||
function computeValue(): number {
|
||||
return Math.max(
|
||||
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds),
|
||||
1
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const val = computeValue();
|
||||
untrack(() => {
|
||||
value = val;
|
||||
});
|
||||
});
|
||||
function pad(field: number): string {
|
||||
return field.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (value === undefined) {
|
||||
untrack(() => {
|
||||
hours = '--';
|
||||
minutes = '--';
|
||||
seconds = '--';
|
||||
});
|
||||
} else {
|
||||
untrack(() => {
|
||||
if (value != computeValue()) {
|
||||
let rounded = Math.max(Math.round(value!), 1);
|
||||
function showValue(newValue: number | undefined) {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
hours = minutes = seconds = '';
|
||||
return;
|
||||
}
|
||||
const total = Math.max(Math.round(newValue!), 1);
|
||||
const totalMinutes = Math.floor(total / 60);
|
||||
if (showHours) {
|
||||
hours = Math.floor(rounded / 3600);
|
||||
minutes = Math.floor((rounded % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
hours = Math.floor(total / 3600).toString();
|
||||
minutes = pad(totalMinutes % 60);
|
||||
} 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;
|
||||
let countKeyPress = 0;
|
||||
function onKeyPress(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLInputElement | null;
|
||||
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
||||
countKeyPress++;
|
||||
if (countKeyPress === 2) {
|
||||
const nextInput =
|
||||
target.id === 'hours'
|
||||
? (container.querySelector('#minutes') as HTMLInputElement)
|
||||
: target.id === 'minutes'
|
||||
? (container.querySelector('#seconds') as HTMLInputElement)
|
||||
: null;
|
||||
if (nextInput) {
|
||||
nextInput.focus();
|
||||
/** A field without an upper bound: hours, or minutes when there are no hours. */
|
||||
function normalizeUnbounded(input: string): string {
|
||||
const digits = input.replace(/\D/g, '').slice(-6);
|
||||
return digits === '' ? '' : parseInt(digits, 10).toString();
|
||||
}
|
||||
|
||||
/** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
|
||||
function normalizeSexagesimal(input: string): string {
|
||||
const digits = input.replace(/\D/g, '').slice(-2);
|
||||
return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
|
||||
}
|
||||
|
||||
let digitsTyped = 0;
|
||||
|
||||
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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
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
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
data-slot="time-picker"
|
||||
class={cn(
|
||||
'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}
|
||||
<TimeComponentInput
|
||||
id="hours"
|
||||
bind:value={hours}
|
||||
{id}
|
||||
bind:ref={hoursInput}
|
||||
value={hours}
|
||||
{disabled}
|
||||
class="w-[30px]"
|
||||
oninput={() => {
|
||||
if (typeof hours === 'string') {
|
||||
hours = parseInt(hours);
|
||||
}
|
||||
if (hours < 0) {
|
||||
hours = 0;
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
oninput={(e) =>
|
||||
handleInput(e, normalizeUnbounded, (field) => (hours = field), minutesInput)}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<span>:</span>
|
||||
{/if}
|
||||
<TimeComponentInput
|
||||
id="minutes"
|
||||
bind:value={minutes}
|
||||
id={showHours ? undefined : id}
|
||||
bind:ref={minutesInput}
|
||||
value={minutes}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
if (typeof minutes === 'string') {
|
||||
minutes = parseInt(minutes);
|
||||
}
|
||||
if (showHours && minutes > 59) {
|
||||
minutes = 59;
|
||||
} else if (minutes < 0) {
|
||||
minutes = 0;
|
||||
}
|
||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
oninput={(e) =>
|
||||
handleInput(
|
||||
e,
|
||||
showHours ? normalizeSexagesimal : normalizeUnbounded,
|
||||
(field) => (minutes = field),
|
||||
secondsInput
|
||||
)}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<span>:</span>
|
||||
<TimeComponentInput
|
||||
id="seconds"
|
||||
bind:value={seconds}
|
||||
bind:ref={secondsInput}
|
||||
value={seconds}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
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;
|
||||
}}
|
||||
oninput={(e) => handleInput(e, normalizeSexagesimal, (field) => (seconds = field), null)}
|
||||
/>
|
||||
</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>
|
||||
Reference in new issue
Block a user