feat(select,toggle-group): canonical readonly prop; chronos consolidation pass

Adds a canonical `readonly` prop to Select and ToggleGroup (morfo -> soma ->
eidos + provider tests), mirroring radio-group/Field: the value stays legible
(unlike `disabled`) while interaction is gated. Chronos view-mode composes it
and the `[data-view]` disabled-stripping workaround is removed.

Chronos consolidation (recipe/doctrine + behavior), verified in-browser:
- recipe: focus box-shadow -> outline (theming ss32), opacity/typography ->
  tokens, hover -> state-layer (R-4.2 / R-2.7 / R-4.3).
- event tooltip composed from the framework Tooltip (`eventTooltip` snippet);
  `color` prop wired to the accent (`--_chronos-accent` per `data-color`).
- edits dispatch the `update` command; Intl -> getCachedDateFormat; ToggleGroup
  and Select migrated `type` -> `selectionMode`.
- README restructured to the F-* format (Baseline / Comparativa / Gaps).

recipes/base.ts + generated/base.css are intentionally excluded (their working
tree carries unrelated typography/font changes); the now-unused chronos
`focus-shadow` recipe token stays until a clean regen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 58c798b195
commit e75f85becc

@ -1,7 +1,7 @@
# Chronos (eidos)
Event calendar / scheduler. **v1 = Month view.** Design, competitive matrix and
roadmap: [`SPEC.md`](./SPEC.md).
Event calendar / scheduler. Month · Week · Day · Agenda. Design, competitive
matrix and roadmap: [`SPEC.md`](./SPEC.md).
```svelte
<script lang="ts">
@ -22,18 +22,46 @@ roadmap: [`SPEC.md`](./SPEC.md).
<Chronos {events} onEventCreate={(e) => /* persist */ undefined} />
```
## Baseline
**No Air baseline** — Chronos is a net-new component (no prior in the retired
`air/` tree). Its closest priors in the catalog are `calendar` / `date-picker`,
which it deliberately does **not** couple to: the month grid is self-contained on
`$libs/days` (`engine/grid.ts`) so an event calendar and a date picker can evolve
independently. Reference schedulers were audited fresh (see Comparativa + SPEC §2).
## Comparativa
Condensed matrix vs the reference schedulers (full 7-library matrix in
[`SPEC.md`](./SPEC.md) §2). Legend: ✅ yes · ⚠️ partial · ❌ no · 💲 paid · 🔜 roadmap.
| Capability | FullCalendar | Schedule-X | SVAR | Mobiscroll | **Chronos** |
| -------------------------------- | ------------ | ----------- | -------------- | ---------- | --------------------------- |
| Month / Week / Day / Agenda | ✅ | ✅ | ✅ (agenda 💲) | ✅ | ✅ |
| Drag-move / resize / drag-create | ✅ | 💲 | ✅ | ✅ | ✅ / 🔜 / ✅ |
| Integrated editor | ❌ BYO | 💲 | ✅ | ✅ | ✅ (Dialog, replaceable) |
| **Undo / redo** | ❌ | ❌ | ❌ | ❌ | ✅ (no competitor ships it) |
| ARIA + keyboard **declared** | ⚠️ | ❌ | ⚠️ | ⚠️ | ✅ (morfo) |
| Runes-native / real headless | ❌ / ❌ | ❌ / ⚠️ | ✅ / ⚠️ | ❌ / ❌ | ✅ / ✅ |
| Timezone + DST at the core | ✅ | ⚠️ | ❌ | ✅ | ✅ |
| License | MIT + 💲 | freemium 💲 | GPL / 💲 | 💲 | internal, no tier |
**Thesis** (SPEC §1): Chronos doesn't compete on feature-count; it wins where all
references fail at once — declared a11y, runes reactivity, real headless, tz+DST,
and undo/redo — plus no paywall.
## Layers
- **morfo** `morfo/components/chronos.ts` — grid contract (parts, events, ARIA, keyboard).
- **soma** `soma/components/chronos/` — provider + `engine/` (pure: commands, history,
layout, grid, event-store; reactive `state.svelte.ts`). The engine owns the event set
post-mount so undo/redo history stays intact.
- **sema** `sema/components/chronos.ts` — sound/haptic cascade (not yet registered).
- **sema** `sema/components/chronos.ts` — sound/haptic cascade (registered in `sema/components/index.ts`).
- **eidos** here — wrapper + `chronos.css` + `chronos-when-editor.svelte`. **Every chrome
control is a framework component** (no native/reinvented buttons): the toolbar uses
**IconButton** (prev / next / search) + **Button** (today / "+N more" / Add event) + **Select**
(view); the editor uses **Dialog** + **Field** + **Select** (details) and the "when" editor
uses **ToggleGroup** (mode) + **DateRangePicker** (date span) + **TimePicker** (times) +
uses **ToggleGroup** (mode) + **DateRangePicker** (date span) + **TimeRangePicker** (times) +
**IconButton** (add / remove band). Only the calendar-specific surfaces (grid, day cells,
event chips/bars, overflow, time-grid) are net-new.
@ -42,17 +70,23 @@ roadmap: [`SPEC.md`](./SPEC.md).
`date` (initial month) · `events` (initial set) · `schema` (event types + predefined colors) ·
`otherTypeLabel` · `view` (initial) · `weekStartsOn` · `fixedWeeks` · `locale` ·
`maxEventsPerCell` · `editable` · `readonly` · `disabled` · `size` (sm/md/lg) ·
`color` (ColorRole). Callbacks: `onViewChange` / `onEventCreate` / `onEventUpdate` /
`onEventDelete` / `onRangeChange` / `onEventClick`.
`color` (ColorRole — retints the accent: today marker + now-indicator). Callbacks:
`onViewChange` / `onEventCreate` / `onEventUpdate` / `onEventDelete` / `onRangeChange` /
`onEventClick`.
- **`editor`** — optional snippet `Snippet<[ChronosEditorContext]>`. Replaces the editor
dialog's **detail** fields (the event's "what") with app-defined ones; Chronos always keeps
the header, the "when" controls (date span + time mode + bands) and the footer. The context
exposes `draft` + `save` / `remove` / `cancel` / `setAllDay`, all wired to the engine.
- **`eventTooltip`** — optional snippet `Snippet<[ChronosTooltipContext]>`. Custom body for the
event-chip hover tooltip (composed from the framework `Tooltip`); without it a default
title · time · type body is shown.
## Decisiones
- **Engine en soma** (`engine/`), no en lib aparte — patrón `words/engine` + `table`.
- **Engine en soma** (`engine/`), no en lib aparte — precedente `words/engine`. _(Nota: el
motor es puro; si aparece un 2º consumidor, migrarlo a `$libs/` sería lo canónico — el motor
de `table` vive en `$libs/datagrid`.)_
- **`$libs/days`** para todo el modelo de fechas (tz + DST), nunca Temporal/`@internationalized`.
- **Provider dueño de los eventos** post-montaje (historia íntegra); cambios vía callbacks,
sin two-way binding de `events`.
@ -65,12 +99,15 @@ roadmap: [`SPEC.md`](./SPEC.md).
editor SIEMPRE muestra el "cuándo" (fecha + modo + horas/franjas) y el footer; el snippet
`editor` solo personaliza el "qué".
- **Cero controles nativos**: todo el chrome se compone de componentes del framework
(IconButton/Button/Select/ToggleGroup/DateRangePicker/TimePicker/Dialog/Field). Los
`data-chronos-*-button` del morfo se conservan como passthrough sobre los Buttons.
(IconButton/Button/Select/ToggleGroup/DateRangePicker/TimeRangePicker/Dialog/Field). El modo
vista compone `readonly` (Select/ToggleGroup lo soportan canónicamente) — legible, no atenuado,
sin overrides desde el host.
- **Drag & drop con el componente `DragDrop`**: mes (chips→celdas, conserva `role=gridcell`
vía snippet `child`), semana/día (chips temporales→slots por hora; all-day→columnas;
`accept` separa `timed`/`allday`). Las franjas `split` se renderizan como bloques pero
NO son arrastrables (una franja suelta no tiene ancla de movimiento única).
- **Tooltip de evento** compuesto del `Tooltip` del framework (asChild sobre el chip Draggable),
con cuerpo por defecto y `eventTooltip` para personalizar.
## Vistas
@ -83,25 +120,38 @@ Selector en el header (Select): **Month · Week · Day · Agenda**, vía `view`
hoy resaltado.
- **Agenda** — lista cronológica del mes agrupada por día.
## Pending (v1+ / follow-ups)
- **Resize** de eventos por arrastre (morfo declara `handle-resize` + `commit-move`; falta
el cableado). Drag&drop de movimiento YA está (mes + semana/día).
- **Drag de franjas `split`** y **snap más fino** (hoy semana/día snap a la hora).
- **Navegación por teclado** del grid (roving focus) — hoy los elementos interactivos
(botones, chips) son alcanzables, pero falta el roving de celdas.
- **Formato vía `Intl` directo** en provider/vista en vez del motor `$format`/`uix.langs`
(deuda heredada; re-rutear es un refactor transversal, correctness-neutral).
- **Swatches de color** y **filas de agenda** son superficies nativas bespoke (no hay
primitiva de framework equivalente); revisar si emergen primitivas.
- **Day-view sidebar** (mini-calendario + panel de detalle del evento) de la referencia —
requiere datos de evento (invitados, recordatorios) fuera del modelo actual.
- **Vistas Año/Timeline-recursos, RRULE, virtualización, tz per-event** — roadmap (SPEC §12).
## Gaps
Cada ítem con disposición: **implementar** (deuda a saldar) · **diferir** (roadmap) ·
**descartar** (no pertenece a chronos).
- **Contrato morfo de Week/Day/Agenda + soma transcribe las sub-parts** — **implementar**.
Hoy la rejilla del mes está en el morfo pero las vistas semana/día/agenda + peek se
renderizan con clases planas; soma solo registra la part `provider` (los `role`/`aria`/
`data-chronos-*` de las sub-parts se estampan a mano en eidos). Devolver a la cadena
`morfo→soma→eidos` (requiere decidir compound vs `private`).
- **Navegación por teclado del grid (roving) + `runtime.keydown`** — **implementar**. El morfo
declara el mapa de teclado pero no está cableado; hoy botones/chips son alcanzables, falta el
roving de celdas.
- **Resize de eventos por arrastre** — **implementar**. El morfo declara `handle-resize`;
falta cablear el drag del borde (drag-move ya está en mes + semana/día).
- **i18n** — **implementar** (P2). Strings/aria del editor + mensajes de validación están
hardcodeados; enrutar por `langs` (catálogo `langs/components/chronos.ts` + `morfo.texts`).
- **`color` en el day-peek portaled** — **diferir**. El acento in-tree ya sigue `color`; el peek
se renderiza fuera de `[data-chronos]` (portal) y mantiene primary — limitación documentada.
- **Drag de franjas `split` · snap sub-hora** — **diferir** (v1+).
- **Swatches de color · filas de agenda** son superficies bespoke (no hay primitiva de framework
equivalente) — **diferir** (revisar si emergen primitivas).
- **Day-view sidebar** (mini-calendario + detalle del evento) — **diferir** (requiere datos de
evento —invitados, recordatorios— fuera del modelo actual).
- **Año / Timeline-recursos / RRULE / virtualización / tz per-event** — **diferir** (roadmap SPEC §12).
## Theming
Tokens en el recipe central `lib/recipes/base.ts` (bloque `chronos:`), emitidos a
`generated/base.css` vía `npm run generate:eidos-css`. `chronos.css` solo referencia esos
`--chronos-*` + primitivas del tema; el focus usa `--chronos-focus-shadow` (patrón del
sistema). El componente es **code-split** (importa su CSS desde `chronos.svelte`), así que
NO se añade a `eidos/index.css`. Sema pack registrado en `sema/components/index.ts`.
`--chronos-*` + primitivas del tema. Foco vía `outline` + `--focus-ring-*` (§32, HCM-safe);
hovers vía la state-layer `--state-*`; opacity/tipografía vía tokens (`--opacity-*`/`--leading-*`).
El acento (marcador de hoy · now-indicator · drag) sigue `data-color` vía `--_chronos-accent`.
El componente es **code-split** (importa su CSS desde `chronos.svelte`), así que NO se añade a
`eidos/index.css`.

@ -4,12 +4,14 @@
ChronosProvider,
type ChronosEvent,
type ChronosView,
type ChronosEditorContext
type ChronosEditorContext,
type ChronosTooltipContext
} from '$soma/components/chronos';
import {
isToday,
isWeekend,
getLocalTimeZone,
getCachedDateFormat,
today,
parseDate,
toCalendarDate
@ -23,6 +25,7 @@
import { Select } from '$uix/eidos/components/select';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { Tooltip } from '$uix/eidos/components/tooltip';
import {
ChevronLeft,
ChevronRight,
@ -34,7 +37,13 @@
import ChronosWhenEditor from './chronos-when-editor.svelte';
import ChronosDayPeek from './chronos-day-peek.svelte';
let { editor }: { editor?: Snippet<[ChronosEditorContext]> } = $props();
let {
editor,
eventTooltip
}: {
editor?: Snippet<[ChronosEditorContext]>;
eventTooltip?: Snippet<[ChronosTooltipContext]>;
} = $props();
const provider = ChronosProvider.require();
const tz = getLocalTimeZone();
@ -72,9 +81,19 @@
return layout.segments.filter((s) => s.weekIndex === w);
}
// Resolved bits handed to the (optional) `eventTooltip` snippet + the default body.
function tooltipCtx(ev: ChronosEvent): ChronosTooltipContext {
return {
event: ev,
typeLabel: provider.eventTypeLabel(ev),
timeLabel: provider.eventRangeLabel(ev),
color: provider.resolveColor(ev)
};
}
// ── Header derived strings ────────────────────────────────────────────────
const badgeMonth = $derived(
new Intl.DateTimeFormat(locale, { month: 'short' }).format(todayDate.toDate(tz)).toUpperCase()
getCachedDateFormat(locale, { month: 'short' }).format(todayDate.toDate(tz)).toUpperCase()
);
const weekOfMonth = $derived(Math.ceil(todayDate.day / 7));
@ -90,13 +109,13 @@
function eventTime(ev: ChronosEvent): string {
// Empty for all-day AND split (date-only start → no single time).
if (ev.allDay || !('hour' in ev.start)) return '';
return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
return getCachedDateFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
ev.start.toDate(tz)
);
}
const fmtMin = (min: number): string =>
new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
getCachedDateFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
new Date(2020, 0, 1, Math.floor(min / 60), min % 60)
);
@ -206,7 +225,7 @@
<div class="chronos-view-select">
<Select
type="single"
selectionMode="single"
value={[provider.view]}
onValueChange={(v) => v[0] && provider.setView(v[0] as ChronosView)}
>
@ -238,6 +257,19 @@
the dragged source is only that week's fragment; size to the FULL event span
(× day-cell width, capped to one week) instead. Single-day / timed chips
have no overlay parent and fall back to the source rect. -->
{#snippet tooltipBody(ctx: ChronosTooltipContext)}
{#if eventTooltip}
{@render eventTooltip(ctx)}
{:else}
<div class="chronos-tooltip">
<span class="chronos-tooltip-title">{ctx.event.title || '(untitled)'}</span>
<span class="chronos-tooltip-meta"
>{ctx.timeLabel}{ctx.typeLabel ? ` · ${ctx.typeLabel}` : ''}</span
>
</div>
{/if}
{/snippet}
{#snippet dragPreview(active: { value: string; label: string; source: HTMLElement })}
{@const ev = provider.calendar.events.find((e) => e.id === active.value)}
{@const rect = active.source.getBoundingClientRect()}
@ -312,32 +344,47 @@
<div class="chronos-overlay">
{#each segsForWeek(w) as seg (seg.event.id)}
<DragDrop.Draggable
value={seg.event.id}
data={{ eventId: seg.event.id }}
textValue={seg.event.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
data-all-day={seg.event.allDay ? '' : undefined}
data-continues-before={seg.continuesBefore ? '' : undefined}
data-continues-after={seg.continuesAfter ? '' : undefined}
data-single-day={seg.span === 1 && !seg.continuesBefore && !seg.continuesAfter
? ''
: undefined}
aria-label={provider.eventAriaLabel(seg.event)}
style="grid-column: {seg.startCol + 1} / span {seg.span}; grid-row: {seg.lane +
1}; {eventColorVars(provider.resolveColor(seg.event))}"
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(seg.event);
}}
>
<span class="chronos-chip-title">{seg.event.title || '(untitled)'}</span>
{#if !seg.event.allDay}
<span class="chronos-chip-time">{eventTime(seg.event)}</span>
{/if}
</DragDrop.Draggable>
<Tooltip openDelay={500}>
<Tooltip.Trigger>
{#snippet child({ props: tip })}
<DragDrop.Draggable
{...tip}
value={seg.event.id}
data={{ eventId: seg.event.id }}
textValue={seg.event.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
data-all-day={seg.event.allDay ? '' : undefined}
data-continues-before={seg.continuesBefore ? '' : undefined}
data-continues-after={seg.continuesAfter ? '' : undefined}
data-single-day={seg.span === 1 &&
!seg.continuesBefore &&
!seg.continuesAfter
? ''
: undefined}
aria-label={provider.eventAriaLabel(seg.event)}
style="grid-column: {seg.startCol +
1} / span {seg.span}; grid-row: {seg.lane + 1}; {eventColorVars(
provider.resolveColor(seg.event)
)}"
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(seg.event);
}}
>
<span class="chronos-chip-title">{seg.event.title || '(untitled)'}</span>
{#if !seg.event.allDay}
<span class="chronos-chip-time">{eventTime(seg.event)}</span>
{/if}
</DragDrop.Draggable>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>{@render tooltipBody(tooltipCtx(seg.event))}</Tooltip.Content
>
</Tooltip.Portal>
</Tooltip>
{/each}
{#each week as date, col (date.toString())}
@ -391,22 +438,32 @@
data-dragover={over ? '' : undefined}
>
{#each provider.timeLayoutFor(day)?.allDay ?? [] as ev (ev.id)}
<DragDrop.Draggable
value={ev.id}
data={{ eventId: ev.id, kind: 'allday' }}
textValue={ev.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(ev)}
style={eventColorVars(provider.resolveColor(ev))}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(ev);
}}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
</DragDrop.Draggable>
<Tooltip openDelay={500}>
<Tooltip.Trigger>
{#snippet child({ props: tip })}
<DragDrop.Draggable
{...tip}
value={ev.id}
data={{ eventId: ev.id, kind: 'allday' }}
textValue={ev.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(ev)}
style={eventColorVars(provider.resolveColor(ev))}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(ev);
}}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
</DragDrop.Draggable>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>{@render tooltipBody(tooltipCtx(ev))}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
{/each}
</div>
{/snippet}
@ -448,39 +505,61 @@
{#each dl?.timed ?? [] as b (b.key)}
{#if b.key.includes('#')}
<!-- Split band: not draggable (a band has no single movable anchor). -->
<button
data-chronos-event-chip
data-timed
type="button"
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
<Tooltip openDelay={500}>
<Tooltip.Trigger>
{#snippet child({ props: tip })}
<button
{...tip}
data-chronos-event-chip
data-timed
type="button"
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>{@render tooltipBody(tooltipCtx(b.event))}</Tooltip.Content
>
</Tooltip.Portal>
</Tooltip>
{:else}
<DragDrop.Draggable
value={b.event.id}
data={{ eventId: b.event.id, kind: 'timed' }}
textValue={b.event.title || 'event'}
data-chronos-event-chip
data-timed
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</DragDrop.Draggable>
<Tooltip openDelay={500}>
<Tooltip.Trigger>
{#snippet child({ props: tip })}
<DragDrop.Draggable
{...tip}
value={b.event.id}
data={{ eventId: b.event.id, kind: 'timed' }}
textValue={b.event.title || 'event'}
data-chronos-event-chip
data-timed
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</DragDrop.Draggable>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>{@render tooltipBody(tooltipCtx(b.event))}</Tooltip.Content
>
</Tooltip.Portal>
</Tooltip>
{/if}
{/each}
{#if isToday(day, tz)}
@ -576,7 +655,7 @@
</IconButton>
{/if}
<div class="chronos-editor-fields" data-view={!editable ? '' : undefined}>
<div class="chronos-editor-fields">
<!-- Details — the event's "what". Customizable: a host `editor` snippet
replaces these fields; the "when" controls + footer always stay.
In view mode controls are readonly/disabled. -->
@ -598,8 +677,8 @@
<div class="chronos-field">
<span class="chronos-field-label">Type</span>
<Select
type="single"
disabled={!editable}
selectionMode="single"
readonly={!editable}
value={editing.draft.typeId ? [editing.draft.typeId] : []}
onValueChange={(v) => (editing.draft.typeId = v[0])}
>

@ -96,16 +96,16 @@
</TimeRangePicker>
{/snippet}
<div class="chronos-when" data-view={!editable ? '' : undefined}>
<!-- Mode switch — primary control, full width. No `readonly` prop on ToggleGroup,
so it stays `disabled`; the [data-view] CSS removes the dimming so it reads. -->
<div class="chronos-when">
<!-- Mode switch — primary control, full width. `readonly` in view mode keeps the
selected mode legible (not dimmed) while blocking toggles. -->
<ToggleGroup
type="single"
selectionMode="single"
deselectable={false}
attached
block
color="primary"
disabled={!editable}
readonly={!editable}
aria-label="Time mode"
value={provider.draftMode ? [provider.draftMode] : []}
onValueChange={(v) => v[0] && provider.setDraftTimeMode(v[0] as ChronosTimeMode)}

@ -15,8 +15,11 @@
[data-chronos] {
--_chronos-cell-min-block: var(--chronos-cell-min-block-md);
--_chronos-lane-height: var(--chronos-lane-height-md);
/* Accent (today marker · now-indicator · drag feedback) — follows the `color` prop. */
--_chronos-accent: var(--color-primary-solid);
--_chronos-accent-border: var(--color-primary-border);
/* Drop-target highlight tint (single source; state tokens are pending). */
--_chronos-dragover-bg: color-mix(in oklab, var(--color-primary-solid) 14%, transparent);
--_chronos-dragover-bg: color-mix(in oklab, var(--_chronos-accent) 14%, transparent);
/* Depth of the multi-day chip continuation arrow / notch. */
--_chronos-chip-arrow: var(--space-1-5);
@ -25,7 +28,7 @@
gap: var(--space-3);
inline-size: 100%;
color: var(--color-content-primary);
font-family: var(--font-ui);
font-family: var(--style-label-font-family);
font-size: var(--font-size-sm);
line-height: var(--leading-ui);
}
@ -44,6 +47,42 @@
pointer-events: none;
}
/* The `color` prop retints the accent (today marker · now-indicator · drag feedback).
Portaled surfaces (the day-peek popover) keep primary — they render outside
[data-chronos] so this private token can't reach them. */
[data-chronos][data-color='secondary'] {
--_chronos-accent: var(--color-secondary-solid);
--_chronos-accent-border: var(--color-secondary-border);
}
[data-chronos][data-color='tertiary'] {
--_chronos-accent: var(--color-tertiary-solid);
--_chronos-accent-border: var(--color-tertiary-border);
}
[data-chronos][data-color='neutral'] {
--_chronos-accent: var(--color-neutral-solid);
--_chronos-accent-border: var(--color-neutral-border);
}
[data-chronos][data-color='affirm'] {
--_chronos-accent: var(--color-affirm-solid);
--_chronos-accent-border: var(--color-affirm-border);
}
[data-chronos][data-color='fulfill'] {
--_chronos-accent: var(--color-fulfill-solid);
--_chronos-accent-border: var(--color-fulfill-border);
}
[data-chronos][data-color='risk'] {
--_chronos-accent: var(--color-risk-solid);
--_chronos-accent-border: var(--color-risk-border);
}
[data-chronos][data-color='threat'] {
--_chronos-accent: var(--color-threat-solid);
--_chronos-accent-border: var(--color-threat-border);
}
[data-chronos][data-color='loss'] {
--_chronos-accent: var(--color-loss-solid);
--_chronos-accent-border: var(--color-loss-border);
}
/* ── Header ──────────────────────────────────────────────────────────────── */
[data-chronos-toolbar] {
@ -65,12 +104,12 @@
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
line-height: 1.1;
line-height: var(--leading-tight);
}
.chronos-date-badge-month {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
letter-spacing: 0.04em; /* literal: optical tracking on the compact date badge */
color: var(--color-content-muted);
}
.chronos-date-badge-day {
@ -170,10 +209,10 @@
}
[data-chronos-day-cell][data-outside-month] [data-chronos-day-number] {
color: var(--color-content-muted);
opacity: 0.6;
opacity: var(--opacity-muted);
}
[data-chronos-day-cell]:hover {
background: var(--color-surface-overlay);
background-image: linear-gradient(var(--state-hover), var(--state-hover));
}
[data-chronos][data-readonly] [data-chronos-day-cell],
[data-chronos][data-disabled] [data-chronos-day-cell] {
@ -181,15 +220,15 @@
}
[data-chronos][data-readonly] [data-chronos-day-cell]:hover,
[data-chronos][data-disabled] [data-chronos-day-cell]:hover {
background: transparent;
background-image: none;
}
[data-chronos-day-cell]:focus-visible {
outline: none;
box-shadow: var(--chronos-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: calc(var(--focus-ring-width) * -1);
}
[data-chronos-day-cell][data-dragover] {
background: var(--_chronos-dragover-bg);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--color-primary-border);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--_chronos-accent-border);
}
.chronos-day-cell-head {
display: flex;
@ -221,7 +260,7 @@
color: var(--color-content-primary);
}
[data-chronos-day-cell][data-today] [data-chronos-day-number] {
background: var(--color-primary-solid);
background: var(--_chronos-accent);
color: var(--color-content-on-solid);
border-radius: var(--radius-full);
font-weight: var(--font-weight-semibold);
@ -254,14 +293,14 @@
font-size: var(--font-size-xs);
cursor: pointer;
text-align: start;
transition: filter var(--duration-fast) var(--ease-default);
transition: background-color var(--duration-fast) var(--ease-default);
}
[data-chronos-event-chip]:hover {
filter: brightness(0.97);
background-image: linear-gradient(var(--state-hover), var(--state-hover));
}
[data-chronos-event-chip]:focus-visible {
outline: none;
box-shadow: var(--chronos-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* Continuation shapes: a bar that runs past the week boundary ends in an arrow
point (continues-after) and the next week resumes with a matching notch
@ -306,9 +345,19 @@
}
.chronos-chip-time {
flex: 0 0 auto;
opacity: 0.75;
opacity: var(--opacity-subtle);
font-variant-numeric: tabular-nums;
}
/* Default event-chip tooltip body (inside the system Tooltip.Content surface). */
.chronos-tooltip {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
}
.chronos-tooltip-meta {
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
/* Drag preview — the event's chip, lifted off the surface while dragging.
Size comes inline from the source chip, so keep border-box to match exactly. */
.chronos-drag-preview {
@ -352,7 +401,7 @@
.chronos-legend-swatch {
inline-size: var(--space-2-5);
block-size: var(--space-2-5);
border-radius: var(--radius-xs);
border-radius: var(--radius-sm);
}
/* ── Editor field layout (controls are framework Dialog/Field/DateField/…) ─── */
@ -365,25 +414,10 @@
inset-inline-end: var(--dialog-content-padding-md);
}
/* View mode = readonly. Field/date/time pickers use their `readonly` prop (values
stay legible). Select + ToggleGroup have no `readonly`, so they're `disabled`;
strip the disabled dimming here so they read like the rest, while staying
non-interactive (and still announced as disabled to AT). */
.chronos-editor-fields[data-view] [data-select][data-disabled],
.chronos-editor-fields[data-view] [data-select-trigger][data-disabled] {
opacity: 1;
}
.chronos-editor-fields[data-view] [data-select-trigger][data-disabled] {
border-color: var(--select-trigger-border);
background: var(--select-trigger-bg);
color: var(--select-trigger-color);
cursor: default;
}
.chronos-when[data-view] [data-toggle-group][data-disabled],
.chronos-when[data-view] [data-toggle][data-disabled] {
opacity: 1;
cursor: default;
}
/* View mode uses the controls' own `readonly` (Field / date-time pickers +
Select / ToggleGroup now all support it) — legible, not dimmed, non-interactive.
The `[data-view]` disabled-stripping workaround was retired once Select +
ToggleGroup gained `readonly` (P7). Only the segment-flatten rule remains. */
/* The date/time field's readonly mode paints each segment with a per-segment bg
affordance (`--date-field-segment-readonly-bg`). In an all-readonly view dialog
that reads as noisy chips — flatten to plain text. (Only matches in view mode;
@ -531,7 +565,7 @@
justify-content: space-between;
}
.chronos-field-label {
font-family: var(--font-ui);
font-family: var(--style-label-font-family);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
color: var(--color-content-secondary);
@ -626,7 +660,7 @@
justify-content: center;
min-inline-size: var(--space-6);
block-size: var(--space-6);
background: var(--color-primary-solid);
background: var(--_chronos-accent);
color: var(--color-content-on-solid);
border-radius: var(--radius-full);
font-weight: var(--font-weight-semibold);
@ -686,7 +720,7 @@
.chronos-tg-slot[data-dragover],
.chronos-tg-alldaycol[data-dragover] {
background: var(--_chronos-dragover-bg);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--color-primary-border);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--_chronos-accent-border);
}
/* During an active drag, event chips must not intercept the pointer — the drop
hit-test (elementFromPoint) has to reach the slots / cells beneath them. */
@ -706,7 +740,7 @@
position: absolute;
inset-inline: 0;
block-size: 0;
border-block-start: var(--border-width) solid var(--color-primary-solid);
border-block-start: var(--border-width) solid var(--_chronos-accent);
z-index: 2;
pointer-events: none;
}
@ -717,7 +751,7 @@
inset-inline-start: calc(var(--space-1) * -1);
inline-size: var(--space-2);
block-size: var(--space-2);
background: var(--color-primary-solid);
background: var(--_chronos-accent);
border-radius: var(--radius-full);
}
@ -914,7 +948,7 @@
cursor: pointer;
}
.chronos-agenda-item:hover {
background: var(--color-surface-overlay);
background-image: linear-gradient(var(--state-hover), var(--state-hover));
}
.chronos-agenda-dot {
flex: 0 0 auto;

@ -4,10 +4,17 @@
import ChronosView from './chronos-view.svelte';
import type { ChronosProps } from './types';
let { size = 'md', color = 'primary', editor, children, ...rest }: ChronosProps = $props();
let {
size = 'md',
color = 'primary',
editor,
eventTooltip,
children,
...rest
}: ChronosProps = $props();
</script>
<Chronos.Provider {...rest} data-size={size} data-color={color}>
<ChronosView {editor} />
<ChronosView {editor} {eventTooltip} />
{@render children?.()}
</Chronos.Provider>

@ -1,7 +1,8 @@
import type { Snippet } from 'svelte';
import type {
ChronosProps as SomaChronosProps,
ChronosEditorContext
ChronosEditorContext,
ChronosTooltipContext
} from '$soma/components/chronos';
import type { ColorRole, Size } from '$uix/eidos/lib/types';
@ -20,4 +21,9 @@ export type ChronosProps = SomaChronosProps & {
* calendar engine (command/history + callbacks).
*/
editor?: Snippet<[ChronosEditorContext]>;
/**
* Custom tooltip body shown when hovering an event chip. Receives the resolved
* event + labels + color; without it Chronos renders a default (title · time · type).
*/
eventTooltip?: Snippet<[ChronosTooltipContext]>;
};

@ -118,6 +118,12 @@
cursor: not-allowed;
}
/* Read-only: value stays legible (no disabled dimming), but the trigger is not
interactive — the provider gates open/select; here we just soften the cursor. */
[data-select][data-readonly] [data-select-trigger] {
cursor: default;
}
/* Invalid state — mirror field.css conventions. Border uses the risk palette
token; on focus we still keep the risk border (not the accent) so users
keep seeing the warning while interacting. */

@ -102,6 +102,11 @@
pointer-events: none;
}
/* Read-only: items stay legible (no disabled dimming) but non-interactive. */
[data-toggle-group][data-readonly] [data-toggle-group-item] {
cursor: default;
}
/* Per-color palette (THM-2). The group's `data-color` sets the shared
* `--palette-*` layer on the group; it inherits to each item, and this rule
* routes it into Toggle's `--toggle-palette-*` chain — so an item paints in

@ -78,6 +78,7 @@ export const selectMorfo = {
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-type', values: ['single', 'multiple'], value: v.propRef('type') }
],
@ -116,6 +117,12 @@ export const selectMorfo = {
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'required' }
},
{
attr: 'aria-readonly',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'readonly' }
}
],
keyboard: [

@ -64,9 +64,18 @@ export const toggleGroupMorfo = {
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
},
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' }
],
aria: [{ attr: 'aria-orientation', value: v.propRef('orientation') }]
aria: [
{ attr: 'aria-orientation', value: v.propRef('orientation') },
{
attr: 'aria-readonly',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'readonly' }
}
]
},
{
name: 'Item',
@ -92,6 +101,7 @@ export const toggleGroupMorfo = {
{ attr: 'data-state', values: ['on', 'off'], value: v.stateRef('on') },
{ attr: 'data-value' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],

@ -4,11 +4,9 @@ import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* Chronos sema pack. NOTE: not yet registered in `sema/components/index.ts`
* (that barrel is outside the component and edits there need explicit sign-off
* — see SPEC §13). Sema is ornamental (sound/haptic default off), so the
* component is fully functional without registration; wiring the export is a
* follow-up.
* Chronos sema pack. Registered in `sema/components/index.ts`. Sema is
* ornamental (sound/haptic default off), so these cascade tunings only surface
* when the app opts into the sound/haptic channels.
*/
export const chronosSema: Sema = {
name: 'chronos',

@ -6,6 +6,7 @@ import { Soma } from '../../core/soma.svelte';
import {
type DateValue,
type WeekStartsOn,
getCachedDateFormat,
getLocalTimeZone,
startOfMonth,
endOfMonth,
@ -156,7 +157,7 @@ export class ChronosProvider {
}
readonly headingValue = $derived.by(() =>
new Intl.DateTimeFormat(this.effectiveLocale, { month: 'long', year: 'numeric' }).format(
getCachedDateFormat(this.effectiveLocale, { month: 'long', year: 'numeric' }).format(
this.calendar.anchor.toDate(getLocalTimeZone())
)
);
@ -170,7 +171,7 @@ export class ChronosProvider {
/** Weekday header labels, respecting the resolved first-day-of-week. */
readonly weekdays = $derived.by(() => {
const row = this.calendar.weeks[0] ?? [];
const fmt = new Intl.DateTimeFormat(this.effectiveLocale, { weekday: 'short' });
const fmt = getCachedDateFormat(this.effectiveLocale, { weekday: 'short' });
return row.map((d) => fmt.format(d.toDate(getLocalTimeZone())));
});
@ -183,19 +184,19 @@ export class ChronosProvider {
});
formatDayNumber(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { day: 'numeric' }).format(
return getCachedDateFormat(this.effectiveLocale, { day: 'numeric' }).format(
date.toDate(getLocalTimeZone())
);
}
dayAriaLabel(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { dateStyle: 'full' }).format(
return getCachedDateFormat(this.effectiveLocale, { dateStyle: 'full' }).format(
date.toDate(getLocalTimeZone())
);
}
eventAriaLabel(event: ChronosEvent): string {
const when = new Intl.DateTimeFormat(this.effectiveLocale, { dateStyle: 'medium' }).format(
const when = getCachedDateFormat(this.effectiveLocale, { dateStyle: 'medium' }).format(
event.start.toDate(getLocalTimeZone())
);
return `${event.title || '(untitled)'}, ${when}`;
@ -230,7 +231,7 @@ export class ChronosProvider {
/** Localized hour labels 0..23 for the time-grid rail. */
readonly hourLabels = $derived.by(() =>
Array.from({ length: 24 }, (_, h) =>
new Intl.DateTimeFormat(this.effectiveLocale, { hour: 'numeric' }).format(
getCachedDateFormat(this.effectiveLocale, { hour: 'numeric' }).format(
new Date(2020, 0, 1, h, 0)
)
)
@ -239,7 +240,7 @@ export class ChronosProvider {
/** Header for a time-grid day column: weekday + day number. */
dayColumnLabel(day: DateValue): { weekday: string; day: number } {
return {
weekday: new Intl.DateTimeFormat(this.effectiveLocale, { weekday: 'short' }).format(
weekday: getCachedDateFormat(this.effectiveLocale, { weekday: 'short' }).format(
day.toDate(getLocalTimeZone())
),
day: day.day
@ -249,7 +250,7 @@ export class ChronosProvider {
eventTimeLabel(event: ChronosEvent): string {
// Empty for all-day AND split (date-only start carries no single time).
if (event.allDay || !('hour' in event.start)) return '';
return new Intl.DateTimeFormat(this.effectiveLocale, {
return getCachedDateFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
}).format(event.start.toDate(getLocalTimeZone()));
@ -265,7 +266,7 @@ export class ChronosProvider {
eventRangeLabel(event: ChronosEvent): string {
if (event.allDay) return 'All day';
const f = (h: number, m: number) =>
new Intl.DateTimeFormat(this.effectiveLocale, {
getCachedDateFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
}).format(new Date(2020, 0, 1, h, m));
@ -275,7 +276,7 @@ export class ChronosProvider {
.join(', ');
}
const tz = getLocalTimeZone();
const fmt = new Intl.DateTimeFormat(this.effectiveLocale, {
const fmt = getCachedDateFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
});
@ -292,7 +293,7 @@ export class ChronosProvider {
readonly subtitle = $derived.by(() => {
const tz = getLocalTimeZone();
const f = (d: DateValue, o: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(this.effectiveLocale, o).format(d.toDate(tz));
getCachedDateFormat(this.effectiveLocale, o).format(d.toDate(tz));
if (this.view === 'day') return f(this.calendar.anchor, { weekday: 'long' });
const range: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' };
if (this.view === 'week') {
@ -610,9 +611,15 @@ export class ChronosProvider {
// also hides Save in view mode / disables it while invalid).
if (!this.draftEditable || this.draftError) return;
const mode = this.editing?.mode ?? 'create';
this.calendar.dispatch({ type: 'create', event: draft });
if (mode === 'create') this.opts.onEventCreate?.(draft);
else this.opts.onEventUpdate?.(draft);
if (mode === 'create') {
this.calendar.dispatch({ type: 'create', event: draft });
this.opts.onEventCreate?.(draft);
} else {
// Edit → `update` (merges by id, honors the engine's `editable === false`
// guard). `create` would upsert-replace, bypassing that guard.
this.calendar.dispatch({ type: 'update', id: draft.id, patch: draft });
this.opts.onEventUpdate?.(draft);
}
void this.runtime.trigger('commit-save', target ? { fallbackTarget: target } : undefined);
this.closeEditor();
}

@ -21,6 +21,7 @@
value = $bindable([]),
open = $bindable(false),
disabled = false,
readonly = false,
required = false,
invalid = false,
name = '',
@ -58,6 +59,7 @@
),
selectionMode: readableActive(() => selectionMode),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),

@ -50,6 +50,7 @@ function createSelectOpts() {
value: state<string[]>([]),
selectionMode: state<SelectSelectionMode>('single'),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
name: state('choice'),
@ -92,6 +93,25 @@ describe('SelectProvider', () => {
dom.dispose();
});
it('readonly gates open and selection', () => {
const { dom, timers } = installSomaHarness();
const opts = createSelectOpts();
opts.open.current = false;
opts.readonly.current = true;
const { result: provider, cleanup } = withEffectRoot(() => SelectProvider.create(opts));
expect(provider.isReadonly).toBe(true);
provider.handleOpen();
expect(opts.open.current).toBe(false); // gated — can't open
provider.selectItem('a');
expect(opts.value.current).toEqual([]); // gated — can't change value
cleanup();
timers.dispose();
dom.dispose();
});
it('toggles values without closing in multiple-select mode', () => {
const { dom, timers } = installSomaHarness();
const opts = createSelectOpts();

@ -55,6 +55,7 @@ interface SelectOpts
ActiveProps<{
selectionMode: SelectSelectionMode;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string;
@ -122,6 +123,7 @@ export class SelectProvider {
},
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.isReadonly,
required: () => this.opts.required.current,
invalid: () => this.opts.invalid.current,
type: () => this.opts.selectionMode.current,
@ -158,6 +160,9 @@ export class SelectProvider {
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
/** Read-only: value shown but not changeable (gated in handleOpen + selectItem). */
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
isSelected(value: string): boolean {
return this.selectedSet.has(value);
}
@ -167,7 +172,7 @@ export class SelectProvider {
}
selectItem(value: string, target?: HTMLElement) {
if (this.opts.disabled.current) return;
if (this.opts.disabled.current || this.isReadonly) return;
const result = computeListSelection({
current: this.opts.value.current,
@ -188,7 +193,7 @@ export class SelectProvider {
}
handleOpen() {
if (this.opts.disabled.current) return;
if (this.opts.disabled.current || this.isReadonly) return;
this.opts.open.current = true;
// emerge.open — the popup appeared. Content isn't mounted yet, so fall
// back to the trigger for the perceptual stamp.

@ -37,6 +37,8 @@ export type SelectProps = WithChild<{
onOpenChangeComplete?: OnChangeFn<boolean>;
/** @default false */
disabled?: boolean;
/** Read-only: value shown but not changeable; stays legible (unlike `disabled`). @default false */
readonly?: boolean;
/** @default false */
required?: boolean;
/** Marks the trigger as having an invalid value. Emits `data-invalid` and `aria-invalid`. @default false */

@ -24,6 +24,7 @@
value = $bindable([]),
onValueChange = () => {},
disabled = false,
readonly = false,
orientation = 'horizontal',
loop = true,
dir,
@ -66,6 +67,7 @@
}
),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')

@ -62,6 +62,7 @@ function groupOpts(root = createRoot()) {
enabledSelections: state<SelectionLimit | undefined>(undefined),
whenFull: state<'reject' | 'replace-oldest'>('reject'),
disabled: state(false),
readonly: state(false),
orientation: state<'horizontal' | 'vertical'>('horizontal'),
loop: state(true),
dir: state<'ltr' | 'rtl'>('ltr')
@ -131,6 +132,23 @@ describe('ToggleGroupProvider', () => {
dom.dispose();
});
it('readonly blocks toggling but keeps the value legible', () => {
const { dom } = installSomaHarness();
const opts = groupOpts();
opts.value.current = ['bold'];
opts.readonly.current = true;
const { result: provider, cleanup } = withEffectRoot(() => ToggleGroupProvider.create(opts));
expect(provider.isReadonly).toBe(true);
// A pick is gated — the value stays as-is (unlike disabled, still legible in the DOM).
provider.toggleItem('italic');
expect(opts.value.current).toEqual(['bold']);
cleanup();
dom.dispose();
});
it('with deselectable=false, re-pressing the active item keeps it selected', () => {
const { dom } = installSomaHarness();
const opts = groupOpts();

@ -35,6 +35,7 @@ interface ToggleGroupOpts
/** Behaviour when picking past a multi cap. @default 'reject' */
whenFull: SelectionFullBehavior;
disabled: boolean;
readonly: boolean;
orientation: Orientation;
loop: boolean;
dir: Direction;
@ -63,7 +64,8 @@ export class ToggleGroupProvider {
this.runtime = this.soma.runtime(toggleGroupMorfo, {
props: {
orientation: () => this.opts.orientation.current,
disabled: () => this.opts.disabled.current
disabled: () => this.opts.disabled.current,
readonly: () => this.isReadonly
}
});
this.runtimePart = this.runtime.part('provider', {
@ -78,6 +80,9 @@ export class ToggleGroupProvider {
/** Lifted pressed set (A31): per-item `isItemPressed` does O(1) `.has()`, not O(N) `.includes()`. */
readonly pressedSet = $derived.by(() => new Set(this.opts.value.current));
/** Read-only: legible but non-interactive (gated in item onclick + toggleItem). */
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
isItemPressed(value: string): boolean {
return this.pressedSet.has(value);
}
@ -112,7 +117,7 @@ export class ToggleGroupProvider {
* `commit-block` when the pick was rejected by the limit (at-max / below-min).
*/
toggleItem(value: string, itemEl?: HTMLElement) {
if (this.opts.disabled.current) return;
if (this.opts.disabled.current || this.isReadonly) return;
const result = resolveSelection(
this.opts.value.current,
@ -180,7 +185,8 @@ export class ToggleGroupItemProvider {
},
props: {
disabled: () => this.isDisabled,
orientation: () => this.provider.opts.orientation.current
orientation: () => this.provider.opts.orientation.current,
readonly: () => this.provider.isReadonly
},
owner: this,
syncAttrs: true
@ -214,7 +220,7 @@ export class ToggleGroupItemProvider {
});
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
if (this.isDisabled || this.provider.isReadonly) return;
// Selection + the perceptual event (commit-toggle on change, commit-block
// on a limit rejection) are centralised in `toggleItem`. Pass the clicked
// button so the cascade selector matches this exact item (the runtime

@ -39,6 +39,8 @@ export type ToggleGroupProps = WithChild<{
onValueChange?: OnChangeFn<string[]>;
/** Disables all items. @default false */
disabled?: boolean;
/** Read-only: items show state but can't be toggled; stays legible (unlike `disabled`). @default false */
readonly?: boolean;
/** Orientation for keyboard navigation (arrow keys). @default 'horizontal' */
orientation?: Orientation;
/** Wrap keyboard navigation at boundaries. @default true */

Loading…
Cancel
Save

Powered by TurnKey Linux.