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>
|
</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>
|
|
||||||
Reference in new issue
Block a user