You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/time-range-picker/README.md

202 lines
13 KiB

soma: 10 new components + reactivity audit + guide rules A30–A33 Components shipped: - AlertDialog (thin Dialog specialisation with Escape=close default) - Breadcrumb (+ interactive Ellipsis composing DropdownMenu) - Carousel (gesture + autoplay + indicators) - Listbox (roving tabindex + typeahead + Ctrl+A multi-select) - NavigationMenu (hover-intent + data-motion direction + Indicator CSS vars) - PinInput (Field-aware segmented input with paste transformer) - RatingGroup (half-step + clearable + keyboard digits) - Toggle (standalone two-state with Field OR-merge) - VirtualGrid (2D windowing for spreadsheets / thumbnail grids) - VirtualList (fixed + dynamic heights, ResizeObserver anti-jump, WindowViewport) Guide rules (src/uix/soma/COMPONENT_GUIDE.md): - A30 + item 32: id registration via direct assign, never $effect — the $effect(() => parent.id = opts.id) pattern causes reactive loops that freeze the page. Ref: PinInput + Listbox incidents. - A31 + item 33: per-entity $derived must not call provider methods that read global state — lift to single provider $derived, per-entity derivations pointer-compare. Ref: Command (2026-04-17), Listbox rovingTarget. - A32 + items 34–35: present comparison table in conversation before declaring done. Every ❌/⚠️ gets an explicit decision (implement / defer to v2 / drop); deferred features become their own README section. - A33 + item 36: reactive collections use SvelteMap / SvelteSet from svelte/reactivity — $state(new Map()) only tracks field reassignment, not per-entry .set/.get/.has reads inside $derived. Reactivity audits + fixes: - VirtualList sizeCache → SvelteMap (dynamic heights were silently stuck). - Form touched + registry → SvelteMap (isTouched / isDirty / onBlur validation $derived never re-ran with plain Map). - Combobox labelRegistry → SvelteMap (removed clone-and-reassign workaround, O(N)→O(1) per registration). Other fixes this pass: - ColorField/ColorPicker RGB co-increment bug (HSV round-trip was lossy). - 24 state_referenced_locally warnings swept across date/time/color field+picker wrappers (removed `void X;` placeholders that Svelte 5 flagged, added svelte-ignore for intentional mount-time reads). - AlertDialog Content wrapper forces escapeKeydownBehavior=close (overrides Dialog's alertdialog→ignore default → matches WAI-ARIA). - Breadcrumb Ellipsis interactive prop renders <button aria-haspopup> so DropdownMenu composition works without manual trigger plumbing. - VirtualList Viewport drops `contain: strict` (was breaking programmatic scrollTo in some engines). - Memory entries: feedback_id_effect_assign_directly, feedback_scope_approval, feedback_svelte_reactivity_collections. Sium library (delegated session, landed in tree): - Core schema + primitives + combinators + pipe + refines + modifiers. - Langs module with idlangref resolver. - Svelte form adapter. - Protocol docs + status ledger for Codex coordination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
# 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
```svelte
<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-endpoint `aria-label` resolved through UIX lang refs (`morfo.translations` for component-owned labels, `langs.ts` only for legacy imperative constants).
soma: 10 new components + reactivity audit + guide rules A30–A33 Components shipped: - AlertDialog (thin Dialog specialisation with Escape=close default) - Breadcrumb (+ interactive Ellipsis composing DropdownMenu) - Carousel (gesture + autoplay + indicators) - Listbox (roving tabindex + typeahead + Ctrl+A multi-select) - NavigationMenu (hover-intent + data-motion direction + Indicator CSS vars) - PinInput (Field-aware segmented input with paste transformer) - RatingGroup (half-step + clearable + keyboard digits) - Toggle (standalone two-state with Field OR-merge) - VirtualGrid (2D windowing for spreadsheets / thumbnail grids) - VirtualList (fixed + dynamic heights, ResizeObserver anti-jump, WindowViewport) Guide rules (src/uix/soma/COMPONENT_GUIDE.md): - A30 + item 32: id registration via direct assign, never $effect — the $effect(() => parent.id = opts.id) pattern causes reactive loops that freeze the page. Ref: PinInput + Listbox incidents. - A31 + item 33: per-entity $derived must not call provider methods that read global state — lift to single provider $derived, per-entity derivations pointer-compare. Ref: Command (2026-04-17), Listbox rovingTarget. - A32 + items 34–35: present comparison table in conversation before declaring done. Every ❌/⚠️ gets an explicit decision (implement / defer to v2 / drop); deferred features become their own README section. - A33 + item 36: reactive collections use SvelteMap / SvelteSet from svelte/reactivity — $state(new Map()) only tracks field reassignment, not per-entry .set/.get/.has reads inside $derived. Reactivity audits + fixes: - VirtualList sizeCache → SvelteMap (dynamic heights were silently stuck). - Form touched + registry → SvelteMap (isTouched / isDirty / onBlur validation $derived never re-ran with plain Map). - Combobox labelRegistry → SvelteMap (removed clone-and-reassign workaround, O(N)→O(1) per registration). Other fixes this pass: - ColorField/ColorPicker RGB co-increment bug (HSV round-trip was lossy). - 24 state_referenced_locally warnings swept across date/time/color field+picker wrappers (removed `void X;` placeholders that Svelte 5 flagged, added svelte-ignore for intentional mount-time reads). - AlertDialog Content wrapper forces escapeKeydownBehavior=close (overrides Dialog's alertdialog→ignore default → matches WAI-ARIA). - Breadcrumb Ellipsis interactive prop renders <button aria-haspopup> so DropdownMenu composition works without manual trigger plumbing. - VirtualList Viewport drops `contain: strict` (was breaking programmatic scrollTo in some engines). - Memory entries: feedback_id_effect_assign_directly, feedback_scope_approval, feedback_svelte_reactivity_collections. Sium library (delegated session, landed in tree): - Core schema + primitives + combinators + pipe + refines + modifiers. - Langs module with idlangref resolver. - Svelte form adapter. - Protocol docs + status ledger for Codex coordination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
- `DayPeriodToggle` emits `role="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`/`P` toggles 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
```svelte
<script lang="ts">
import { TimeRangePicker } from '$soma/components';
import { Time } from '$libs/days';
import type { TimeRange } from '$libs/days';
soma: 10 new components + reactivity audit + guide rules A30–A33 Components shipped: - AlertDialog (thin Dialog specialisation with Escape=close default) - Breadcrumb (+ interactive Ellipsis composing DropdownMenu) - Carousel (gesture + autoplay + indicators) - Listbox (roving tabindex + typeahead + Ctrl+A multi-select) - NavigationMenu (hover-intent + data-motion direction + Indicator CSS vars) - PinInput (Field-aware segmented input with paste transformer) - RatingGroup (half-step + clearable + keyboard digits) - Toggle (standalone two-state with Field OR-merge) - VirtualGrid (2D windowing for spreadsheets / thumbnail grids) - VirtualList (fixed + dynamic heights, ResizeObserver anti-jump, WindowViewport) Guide rules (src/uix/soma/COMPONENT_GUIDE.md): - A30 + item 32: id registration via direct assign, never $effect — the $effect(() => parent.id = opts.id) pattern causes reactive loops that freeze the page. Ref: PinInput + Listbox incidents. - A31 + item 33: per-entity $derived must not call provider methods that read global state — lift to single provider $derived, per-entity derivations pointer-compare. Ref: Command (2026-04-17), Listbox rovingTarget. - A32 + items 34–35: present comparison table in conversation before declaring done. Every ❌/⚠️ gets an explicit decision (implement / defer to v2 / drop); deferred features become their own README section. - A33 + item 36: reactive collections use SvelteMap / SvelteSet from svelte/reactivity — $state(new Map()) only tracks field reassignment, not per-entry .set/.get/.has reads inside $derived. Reactivity audits + fixes: - VirtualList sizeCache → SvelteMap (dynamic heights were silently stuck). - Form touched + registry → SvelteMap (isTouched / isDirty / onBlur validation $derived never re-ran with plain Map). - Combobox labelRegistry → SvelteMap (removed clone-and-reassign workaround, O(N)→O(1) per registration). Other fixes this pass: - ColorField/ColorPicker RGB co-increment bug (HSV round-trip was lossy). - 24 state_referenced_locally warnings swept across date/time/color field+picker wrappers (removed `void X;` placeholders that Svelte 5 flagged, added svelte-ignore for intentional mount-time reads). - AlertDialog Content wrapper forces escapeKeydownBehavior=close (overrides Dialog's alertdialog→ignore default → matches WAI-ARIA). - Breadcrumb Ellipsis interactive prop renders <button aria-haspopup> so DropdownMenu composition works without manual trigger plumbing. - VirtualList Viewport drops `contain: strict` (was breaking programmatic scrollTo in some engines). - Memory entries: feedback_id_effect_assign_directly, feedback_scope_approval, feedback_svelte_reactivity_collections. Sium library (delegated session, landed in tree): - Core schema + primitives + combinators + pipe + refines + modifiers. - Langs module with idlangref resolver. - Svelte form adapter. - Protocol docs + status ledger for Codex coordination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
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>
```

Powered by TurnKey Linux.