13 KiB
TimeRangePicker
TimeRangeField + Popover with per-endpoint sliders. The segmented inputs accept keyboard editing; the popover exposes drag sliders (hour / minute / second) plus an AM/PM toggle for each endpoint (start and end). Picker owns the shared reactive state; TimeRangeField and Popover read the same writableActive refs.
Anatomy
<TimeRangePicker.Provider bind:value bind:open>
<TimeRangePicker.Label>Meeting window</TimeRangePicker.Label>
<TimeRangePicker.Input type="start">
{#snippet children({ segments })}
{#each segments as segment, i (i)}
<TimeRangePicker.Segment part={segment.part}>{segment.value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<span aria-hidden="true">→</span>
<TimeRangePicker.Input type="end">
{#snippet children({ segments })}
{#each segments as segment, i (i)}
<TimeRangePicker.Segment part={segment.part}>{segment.value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Trigger>🕐</TimeRangePicker.Trigger>
<TimeRangePicker.Content side="bottom" align="end" sideOffset={8}>
<!-- START -->
<TimeRangePicker.DayPeriodToggle type="start" />
<TimeRangePicker.HourSlider type="start">
<TimeRangePicker.Range />
<TimeRangePicker.Thumb index={0} />
</TimeRangePicker.HourSlider>
<TimeRangePicker.MinuteSlider type="start">…</TimeRangePicker.MinuteSlider>
<TimeRangePicker.SecondSlider type="start">…</TimeRangePicker.SecondSlider>
<!-- END -->
<TimeRangePicker.DayPeriodToggle type="end" />
<TimeRangePicker.HourSlider type="end">…</TimeRangePicker.HourSlider>
<TimeRangePicker.MinuteSlider type="end">…</TimeRangePicker.MinuteSlider>
<TimeRangePicker.SecondSlider type="end">…</TimeRangePicker.SecondSlider>
</TimeRangePicker.Content>
</TimeRangePicker.Provider>
The per-endpoint slider / toggle wrappers read their endpoint from the required type: 'start' | 'end' prop and drive the same underlying { start, end } state as the segmented input.
Parts
| Part | Source | Description |
|---|---|---|
Provider |
range picker | Shared value: TimeRange + placeholder + open. Creates Popover + TRF. |
Trigger |
range picker | Popover trigger button. |
HourSlider |
range picker | Per-endpoint hour slider (type: 'start' | 'end'). |
MinuteSlider |
range picker | Per-endpoint minute slider. |
SecondSlider |
range picker | Per-endpoint second slider. |
DayPeriodToggle |
range picker | Per-endpoint AM/PM radio group (rendered only in 12-hour cycle). |
Label |
TimeRangeField | Re-export. Single label naming the whole range. |
Input |
TimeRangeField | Re-export. One endpoint's segmented input (type: 'start' | 'end'). |
Segment |
TimeField | Re-export. Resolves to the nearest Input's TimeField ctx. |
Content |
Popover | Re-export. Surface mounted via floating layer. |
Arrow |
Popover | Re-export. |
Close |
Popover | Re-export. |
Overlay |
Popover | Re-export. |
Anchor |
Popover | Re-export. |
Thumb |
Slider | Re-export. Used inside each slider wrapper. |
Range |
Slider | Re-export. Used inside each slider wrapper. |
Tick |
Slider | Re-export. Optional tick marks. |
ARIA
- Sliders emit
role="slider"with per-endpointaria-labelresolved through UIX lang refs (morfo.translationsfor component-owned labels,langs.tsonly for legacy imperative constants). DayPeriodToggleemitsrole="radiogroup"with two<input type="radio">items.- Input / Segment inherit TimeRangeField / TimeField ARIA.
Data Attributes
| Part | Attribute | Values |
|---|---|---|
| Root | data-time-range-picker |
Always present |
| Trigger | data-time-range-picker-trigger |
Always present |
| Trigger | data-state |
open | closed |
| Sliders | data-endpoint |
start | end |
| DayPeriodToggle | data-endpoint |
start | end |
| DayPeriodToggle | data-time-range-picker-day-period |
Always present |
| Input | + all data-time-range-field-* |
Inherited from TimeRangeField |
| Segment | + all data-time-field-segment-* |
Inherited from TimeField |
| Content / Arrow / Close / Overlay / Anchor | + all data-popover-* |
Inherited from Popover |
Keyboard
- Input segments: identical to TimeRangeField — arrows cycle, digits auto-advance, Home/End, Tab between segments, Backspace clears,
A/Ptoggles AM/PM. - Trigger: Enter/Space opens the popover.
- Sliders: Arrow keys step by
{hour,minute,second}Step, Home/End jump to min/max. - DayPeriodToggle: Arrow keys / Space toggle between AM and PM.
- Popover: Escape closes; focus returns to the trigger (non-modal via FocusScope).
Props
See types.ts for the JSDoc-documented surface. Summary:
| Prop | Type | Notes |
|---|---|---|
value |
TimeRange |
Bindable { start, end }. |
onValueChange |
(v) => void |
Fires when both endpoints resolve. |
onStartValueChange / onEndValueChange |
(v) => void |
Every endpoint change (even incomplete). |
placeholder |
TimeValue |
Bindable. Drives format for both inputs. |
open |
boolean |
Bindable. Popover open state. |
onOpenChange / onOpenChangeComplete |
(open) => void |
Change callbacks + post-animation. |
closeOnRangeComplete |
boolean |
Auto-close when both endpoints set. @default false |
validate |
TimeRangeValidator |
Receives { start, end } — return error or ∅. |
minValue / maxValue |
TimeValue |
Applied to both endpoints. |
disabled / readonly / required |
boolean |
Merged with parent Field if present. |
startReadonlySegments |
EditableTimeSegmentPart[] |
Per-endpoint locked segments on start (A25). |
endReadonlySegments |
EditableTimeSegmentPart[] |
Per-endpoint locked segments on end (A25). |
granularity |
'hour' | 'minute' | 'second' |
Drives segment set for both inputs. |
hourCycle |
12 | 24 |
Numeric. Falls back to locale. |
hideTimeZone |
boolean |
Hide tz segment on ZonedDateTime. |
hourStep / minuteStep / secondStep |
number |
Slider step sizes. @default 1 |
locale / dir |
string / 'ltr' | 'rtl' |
Inherited from Soma when omitted. |
errorMessageId |
string |
External error element id (aria-describedby). |
Field integration
Wrapping in Field.Provider inherits disabled/readonly/required/invalid into the picker (OR-merged). Labels/helper-text from Field wire normally.
Validation
Range validation runs on the provider when both endpoints are set and fires onInvalid with one of:
| Reason | Condition |
|---|---|
min |
start < minValue |
max |
end > maxValue or start > maxValue |
invalid |
end < start (natural order) |
custom |
validate({start,end}) returned a message |
Comparison with reference libraries
| Feature | React Aria | Ark UI | Bits UI | Soma |
|---|---|---|---|---|
| Dedicated time-range picker | ✗ | ✗ | ✗ | ✓ Soma innovation |
| Paired segmented inputs | ✓ (in Dateᵣ) | ✓ (in Dateᵣ) | ✓ (in Dateᵣ) | ✓ |
| Per-endpoint popover sliders | ✗ | ✗ | ✗ | ✓ |
closeOnRangeComplete |
✗ | closeOnComplete (DateRangePicker) |
✗ | ✓ |
onStartValueChange / onEndValueChange |
✓ (in Dateᵣ) | ✓ | ✓ | ✓ |
Range-level validate |
✓ (in Dateᵣ) | ✓ | ✓ | ✓ |
Natural order check (end ≥ start) |
✓ | ✓ | ✓ | ✓ |
Per-endpoint readonlySegments |
✗ | ✗ | ✓ | ✓ (A25) |
hh/mm/ss placeholders |
✗ | ✗ | ✗ | ✓ (A28) |
beforeinput blocks IME/paste |
✗ | ✗ | ✗ | ✓ (A26) |
| Shared state via single composed provider | ✓ | ✓ | ✓ | ✓ (A27) |
| Parent Field integration | via Form | via FieldRoot | ✗ | ✓ (soma Field) |
| Anatomy | — | — | — | 17 parts (6 unique + 11 re-exports) |
No mainstream library ships a dedicated time-range picker — they collapse it into a date-range picker via CalendarDateTime. Soma keeps them separate so pure time ranges (office hours, meeting windows) do not force a date.
Example — meeting scheduler
<script lang="ts">
import { TimeRangePicker } from '$soma/components';
import { Time } from '$libs/days';
import type { TimeRange } from '$libs/days';
let range = $state<TimeRange>({ start: undefined, end: undefined });
let open = $state(false);
</script>
<TimeRangePicker.Provider
bind:value={range}
bind:open
placeholder={new Time(9, 0, 0)}
hourCycle={24}
closeOnRangeComplete
>
<TimeRangePicker.Label>Availability</TimeRangePicker.Label>
<TimeRangePicker.Input type="start" name="start">
{#snippet children({ segments })}
{#each segments as segment, i (i)}
<TimeRangePicker.Segment part={segment.part}>{segment.value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Input type="end" name="end">
{#snippet children({ segments })}
{#each segments as segment, i (i)}
<TimeRangePicker.Segment part={segment.part}>{segment.value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Trigger>⏰</TimeRangePicker.Trigger>
<TimeRangePicker.Content side="bottom" align="end">
<!-- start + end sliders + AM/PM toggles -->
</TimeRangePicker.Content>
</TimeRangePicker.Provider>