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 >
```