fix(frontend): improve time picker (#7893)

* clean layout

* remove useless toggle

* improve layout

* nit

* Fix input type seconds

* convert secondInputs to svelte 5

* redesign seconds input

* Adapt layout where time input is used

* nit
This commit is contained in:
Guilhem
2026-02-11 00:48:18 +00:00
committed by GitHub
parent 0ad4f0232e
commit 31bfccc745
13 changed files with 287 additions and 157 deletions
+10 -3
View File
@@ -667,10 +667,17 @@
<div class="flex space-x-1">
{#if inputCat == 'number'}
{#if extra['min'] != undefined && extra['max'] != undefined}
{#if extra['seconds'] !== undefined}
<div class="w-full">
<SecondsInput
bind:seconds={value}
onfocus={bubble('focus')}
{defaultValue}
clearable={extra['clearable'] !== false}
/>
</div>
{:else if extra['min'] != undefined && extra['max'] != undefined}
<Range bind:value min={extra['min']} max={extra['max']} {defaultValue} />
{:else if extra['seconds'] !== undefined}
<SecondsInput bind:seconds={value} on:focus />
{:else if extra?.currency}
<CurrencyInput
inputClasses={{
@@ -919,6 +919,7 @@
? 60 * 60 * 24 * 30
: undefined}
bind:seconds={$values[setting.key]}
clearable
/>
</div>
{:else if setting.fieldType == 'select'}
@@ -1,8 +1,9 @@
<script lang="ts">
import Label from './Label.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import { selectOptions } from './apps/editor/component'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Select from './select/Select.svelte'
import TextInput from './text_input/TextInput.svelte'
@@ -11,83 +12,80 @@
max: number | undefined
currency: string | undefined
currencyLocale: string | undefined
seconds: boolean | undefined
}
let {
min = $bindable(),
max = $bindable(),
currency = $bindable(),
currencyLocale = $bindable()
currencyLocale = $bindable(),
seconds = $bindable()
}: Props = $props()
let minChecked: boolean = $state(min != undefined)
let maxChecked: boolean = $state(max != undefined)
let mode: string | undefined = $state(seconds ? 'seconds' : currency ? 'currency' : undefined)
function onModeChange(newMode: string | undefined) {
mode = newMode
if (newMode === 'seconds') {
seconds = true
currency = undefined
currencyLocale = undefined
} else if (newMode === 'currency') {
seconds = undefined
} else {
seconds = undefined
currency = undefined
currencyLocale = undefined
}
}
</script>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-4">
<Label label="Min" class="w-full col-span-1">
{#snippet header()}
<Tooltip light small>
Set a minimum value for the number. If both min and max are set, the input will render as
a range slider.
</Tooltip>
{/snippet}
{#snippet action()}
<Toggle
bind:checked={minChecked}
on:change={(e) => {
if (e.detail) {
min = 0
} else {
min = undefined
}
}}
options={{ right: 'Enabled' }}
size="xs"
/>
{/snippet}
<TextInput
inputProps={{ type: 'number', disabled: !minChecked }}
bind:value={() => min?.toString(), (v) => (min = v ? parseInt(v) : undefined)}
/>
</Label>
<div class="grid grid-cols-2 gap-4 p-4 border rounded-md">
<Label label="Min" class="w-full">
{#snippet header()}
<Tooltip light small>
Set a minimum value for the number. If both min and max are set, the input will render as a
range slider.
</Tooltip>
{/snippet}
<TextInput
inputProps={{ type: 'number' }}
bind:value={() => min?.toString(), (v) => (min = v ? parseInt(v) : undefined)}
/>
</Label>
<Label label="Max" class="w-full col-span-1 ">
{#snippet header()}
<Tooltip light small>
Set a maximum value for the number. If both min and max are set, the input will render as
a range slider.
</Tooltip>
{/snippet}
{#snippet action()}
<Toggle
bind:checked={maxChecked}
on:change={(e) => {
if (e.detail) {
max = 42
} else {
max = undefined
}
}}
options={{ right: 'Enabled' }}
size="xs"
/>
{/snippet}
<TextInput
inputProps={{ type: 'number', disabled: !maxChecked }}
bind:value={() => max?.toString(), (v) => (max = v ? parseInt(v) : undefined)}
/>
</Label>
</div>
<div class="flex gap-2">
<Label label="Currency">
{#snippet header()}
<Tooltip light small>
Select a currency to display the number in. If a currency is selected, you can also select
a locale to format the number according to that locale.
</Tooltip>
<Label label="Max" class="w-full">
{#snippet header()}
<Tooltip light small>
Set a maximum value for the number. If both min and max are set, the input will render as a
range slider.
</Tooltip>
{/snippet}
<TextInput
inputProps={{ type: 'number' }}
bind:value={() => max?.toString(), (v) => (max = v ? parseInt(v) : undefined)}
/>
</Label>
<Label label="Format" class="w-full col-span-2">
{#snippet header()}
<Tooltip light small>Display the number as a currency or as a duration in seconds.</Tooltip>
{/snippet}
<ToggleButtonGroup
selected={mode ?? 'none'}
onSelected={(v) => onModeChange(v === 'none' ? undefined : v)}
>
{#snippet children({ item })}
<ToggleButton value="none" label="None" {item} size="sm" />
<ToggleButton value="currency" label="Currency" {item} size="sm" />
<ToggleButton value="seconds" label="Seconds" {item} size="sm" />
{/snippet}
</ToggleButtonGroup>
</Label>
{#if mode === 'currency'}
<Label label="Currency" class="w-full">
<Select
bind:value={
() => currency,
@@ -101,6 +99,7 @@
clearable
/>
</Label>
<Label label="Currency locale" class="w-full">
<Select
bind:value={currencyLocale}
@@ -110,5 +109,5 @@
clearable
/>
</Label>
</div>
{/if}
</div>
@@ -1323,9 +1323,7 @@
/>
{#if script.cache_ttl}
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<div class="-mt-5">
<SecondsInput bind:seconds={script.cache_ttl} />
</div>
<SecondsInput bind:seconds={script.cache_ttl} />
<Toggle
size="2xs"
bind:checked={
@@ -341,6 +341,7 @@
{/if}
{:else if kind == 'none'}
{#if !noExtra}
<div class="mt-2"></div>
<Label label="Min textarea rows">
<TextInput
inputProps={{ type: 'number' }}
@@ -54,7 +54,7 @@
right: 'Cache the results for each possible inputs'
}}
/>
<div class="mb-4">
<div class="mt-6">
<span class="text-xs font-bold">How long to keep cache valid</span>
{#if cache_ttl}
@@ -1,36 +1,60 @@
<script lang="ts">
type TimeUnit = number | undefined
import { untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '../../text_input/TextInput.svelte'
import type { ButtonType } from '../button/model'
import CloseButton from '../CloseButton.svelte'
const ONE_DAY_IN_SECONDS = 86400 as const
const ONE_HOUR_IN_SECONDS = 3600 as const
const ONE_MINUTE_IN_SECONDS = 60 as const
export let seconds = 0
export let hideDisplay = false
export let disabled = false
export let max: number | undefined = undefined
const segmentClass =
'no-default-style !bg-transparent border-none !w-7 text-right p-0 text-xs font-medium focus:ring-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none'
let day: TimeUnit = undefined
let hour: TimeUnit = undefined
let min: TimeUnit = undefined
let sec: TimeUnit = undefined
interface Props {
seconds?: number
disabled?: boolean
max?: number | undefined
size?: ButtonType.UnifiedSize
clearable?: boolean
defaultValue?: number | undefined
onfocus?: (e: FocusEvent) => void
}
$: convertSecondsToTime(seconds)
let {
seconds = $bindable(),
disabled = false,
max = undefined,
size = 'md',
clearable = false,
defaultValue = undefined,
onfocus
}: Props = $props()
function convertSecondsToTime(seconds) {
day = Math.floor(seconds / ONE_DAY_IN_SECONDS)
seconds -= day * ONE_DAY_IN_SECONDS
day = day || undefined
let day: number = $state(0)
let hour: number = $state(0)
let min: number = $state(0)
let sec: number = $state(0)
hour = Math.floor(seconds / ONE_HOUR_IN_SECONDS)
seconds -= hour * ONE_HOUR_IN_SECONDS
hour = hour || undefined
let containerFocused = $state(false)
let hasValue = $derived(seconds != null)
min = Math.floor(seconds / ONE_MINUTE_IN_SECONDS)
seconds -= min * ONE_MINUTE_IN_SECONDS
min = min || undefined
function convertSecondsToTime(s: number) {
day = Math.floor(s / ONE_DAY_IN_SECONDS)
s -= day * ONE_DAY_IN_SECONDS
sec = seconds || undefined
hour = Math.floor(s / ONE_HOUR_IN_SECONDS)
s -= hour * ONE_HOUR_IN_SECONDS
min = Math.floor(s / ONE_MINUTE_IN_SECONDS)
s -= min * ONE_MINUTE_IN_SECONDS
sec = s
}
function convertUnitsToSeconds() {
@@ -44,70 +68,172 @@
seconds = max
}
}
function clamp(value: number, maxVal: number) {
return Math.max(0, Math.min(maxVal, value))
}
function handleKeydown(
e: KeyboardEvent,
getter: () => number,
setter: (v: number) => void,
maxVal: number
) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
setter(clamp((getter() || 0) + (e.key === 'ArrowUp' ? 1 : -1), maxVal))
convertUnitsToSeconds()
}
}
function handleInput(e: Event, setter: (v: number) => void, maxVal: number) {
const input = e.currentTarget as HTMLInputElement
const clamped = clamp(parseInt(input.value) || 0, maxVal)
setter(clamped)
input.value = String(clamped)
convertUnitsToSeconds()
}
function handleFocus(e: FocusEvent) {
containerFocused = true
if (seconds == null) {
seconds = 0
}
onfocus?.(e)
}
function handleBlur() {
containerFocused = false
}
$effect(() => {
const s = seconds
untrack(() => convertSecondsToTime(s ?? 0))
})
</script>
<div class="flex flex-wrap gap-x-4">
{#if !hideDisplay}
<input
value={seconds == null || seconds == undefined
? 'Not set'
: disabled
? ''
: seconds + ' second' + (seconds === 1 ? '' : 's')}
{disabled}
readonly
type="text"
class="max-w-[248px] bg-gray-50 mb-2 mt-6"
/>
{/if}
<div class="flex flex-wrap items-center gap-2 text-xs font-medium">
<div class="flex items-center gap-2">
<label>
Sec
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<div
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
inputBorderClass({ forceFocus: containerFocused }),
'flex items-center gap-0.5 !w-fit'
)}
>
<div class="flex items-baseline">
<input
type="number"
class="!w-14"
class={segmentClass}
value={hasValue ? day || 0 : ''}
placeholder="_ _"
min="0"
{disabled}
bind:value={sec}
on:change={convertUnitsToSeconds}
on:focus
oninput={(e) => handleInput(e, (v) => (day = v), Infinity)}
onkeydown={(e) =>
handleKeydown(
e,
() => day,
(v) => (day = v),
Infinity
)}
onfocus={handleFocus}
onblur={handleBlur}
/>
</label>
<label>
Min
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">days</span><span>{day && day > 1 ? 'days' : 'day'}</span></span
>
</div>
<div class="flex items-baseline">
<input
type="number"
class="!w-14"
class={segmentClass}
value={hasValue ? hour || 0 : ''}
placeholder="_ _"
min="0"
max="23"
{disabled}
bind:value={min}
on:change={convertUnitsToSeconds}
on:focus
oninput={(e) => handleInput(e, (v) => (hour = v), 23)}
onkeydown={(e) =>
handleKeydown(
e,
() => hour,
(v) => (hour = v),
23
)}
onfocus={handleFocus}
onblur={handleBlur}
/>
</label>
</div>
<div class="flex items-center gap-2">
<label>
Hour
<input
type="number"
class="!w-14"
{disabled}
bind:value={hour}
on:change={convertUnitsToSeconds}
on:focus
/>
</label>
<label>
Day
<input
type="number"
class="!w-14"
{disabled}
bind:value={day}
on:change={convertUnitsToSeconds}
on:focus
/>
</label>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">hrs</span><span>{hour && hour > 1 ? 'hrs' : 'hr'}</span></span
>
</div>
<div class="flex items-baseline">
<input
type="number"
class={segmentClass}
value={hasValue ? min || 0 : ''}
placeholder="_ _"
min="0"
max="59"
{disabled}
oninput={(e) => handleInput(e, (v) => (min = v), 59)}
onkeydown={(e) =>
handleKeydown(
e,
() => min,
(v) => (min = v),
59
)}
onfocus={handleFocus}
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">mins</span><span>{min && min > 1 ? 'mins' : 'min'}</span></span
>
</div>
<div class="flex items-baseline">
<input
type="number"
class={segmentClass}
value={hasValue ? sec || 0 : ''}
placeholder="_ _"
min="0"
max="59"
{disabled}
oninput={(e) => handleInput(e, (v) => (sec = v), 59)}
onkeydown={(e) =>
handleKeydown(
e,
() => sec,
(v) => (sec = v),
59
)}
onfocus={handleFocus}
onblur={handleBlur}
/>
<span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1"
><span class="invisible">secs</span><span>{sec && sec > 1 ? 'secs' : 'sec'}</span></span
>
</div>
</div>
{#if clearable && !disabled && seconds != null && seconds !== defaultValue}
<CloseButton
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={() => {
seconds = defaultValue
}}
/>
{/if}
</div>
{#if seconds != null}
<span class="text-2xs text-hint">
= {seconds.toLocaleString()} second{seconds === 1 ? '' : 's'}
</span>
{:else}
<span class="text-2xs text-hint"> no time set </span>
{/if}
</div>
@@ -46,9 +46,7 @@
/>
{#if flowModule.cache_ttl}
<Label label="How long to keep cache valid">
<div class="-mt-5">
<SecondsInput bind:seconds={flowModule.cache_ttl} />
</div>
<SecondsInput bind:seconds={flowModule.cache_ttl} />
</Label>
<Toggle
size="2xs"
@@ -1195,6 +1195,7 @@
<SecondsInput
disabled={!$enterpriseLicense}
bind:seconds={flowModule.value.concurrency_time_window_s}
clearable
/>
</Label>
<Label label="Custom concurrency key (optional)">
@@ -91,7 +91,7 @@
argName="sleep"
{schema}
{previousModuleId}
argExtra={{ seconds: true }}
argExtra={{ seconds: true, clearable: false }}
bind:editor
/>
</PropPickerWrapper>
@@ -234,7 +234,7 @@
>max</button
>
</div>
<div class="text-xs font-bold !mt-2">Delay</div>
<div class="text-xs font-bold !mt-4">Delay</div>
<SecondsInput bind:seconds={flowModuleRetry.constant.seconds} />
{/if}
{:else if delayType === 'exponential'}
@@ -211,9 +211,7 @@
{#if flowStore.val.value.cache_ttl}
<div class="flex gap-x-4 flex-col gap-1 mt-2" transition:slide={{ duration: 120 }}>
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<div class="-mt-5">
<SecondsInput bind:seconds={flowStore.val.value.cache_ttl} />
</div>
<SecondsInput bind:seconds={flowStore.val.value.cache_ttl} />
<Toggle
size="2xs"
bind:checked={
@@ -249,6 +249,7 @@
bind:max={extra['max']}
bind:currency={extra['currency']}
bind:currencyLocale={extra['currencyLocale']}
bind:seconds={extra['seconds']}
/>
{/if}
</div>