diff --git a/src/uix/eidos/components/chronos/README.md b/src/uix/eidos/components/chronos/README.md
index 04f778eca..c1f14ade0 100644
--- a/src/uix/eidos/components/chronos/README.md
+++ b/src/uix/eidos/components/chronos/README.md
@@ -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
-
+
{@render children?.()}
diff --git a/src/uix/eidos/components/chronos/types.ts b/src/uix/eidos/components/chronos/types.ts
index 4a65b2cd3..9de1cf3a1 100644
--- a/src/uix/eidos/components/chronos/types.ts
+++ b/src/uix/eidos/components/chronos/types.ts
@@ -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]>;
};
diff --git a/src/uix/eidos/components/select/select.css b/src/uix/eidos/components/select/select.css
index 3c938b52d..571456f77 100644
--- a/src/uix/eidos/components/select/select.css
+++ b/src/uix/eidos/components/select/select.css
@@ -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. */
diff --git a/src/uix/eidos/components/toggle-group/toggle-group.css b/src/uix/eidos/components/toggle-group/toggle-group.css
index 97a898c6e..981417482 100644
--- a/src/uix/eidos/components/toggle-group/toggle-group.css
+++ b/src/uix/eidos/components/toggle-group/toggle-group.css
@@ -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
diff --git a/src/uix/morfo/components/select.ts b/src/uix/morfo/components/select.ts
index 08f8c708c..5ca4138b4 100644
--- a/src/uix/morfo/components/select.ts
+++ b/src/uix/morfo/components/select.ts
@@ -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: [
diff --git a/src/uix/morfo/components/toggle-group.ts b/src/uix/morfo/components/toggle-group.ts
index 02d9da9ff..141b16ab0 100644
--- a/src/uix/morfo/components/toggle-group.ts
+++ b/src/uix/morfo/components/toggle-group.ts
@@ -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'],
diff --git a/src/uix/sema/components/chronos.ts b/src/uix/sema/components/chronos.ts
index 15ed19150..babb89692 100644
--- a/src/uix/sema/components/chronos.ts
+++ b/src/uix/sema/components/chronos.ts
@@ -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',
diff --git a/src/uix/soma/components/chronos/chronos-provider.svelte.ts b/src/uix/soma/components/chronos/chronos-provider.svelte.ts
index 08dd75ee0..f3b087410 100644
--- a/src/uix/soma/components/chronos/chronos-provider.svelte.ts
+++ b/src/uix/soma/components/chronos/chronos-provider.svelte.ts
@@ -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();
}
diff --git a/src/uix/soma/components/select/components/select.svelte b/src/uix/soma/components/select/components/select.svelte
index 7976c27ef..521f90b8c 100644
--- a/src/uix/soma/components/select/components/select.svelte
+++ b/src/uix/soma/components/select/components/select.svelte
@@ -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),
diff --git a/src/uix/soma/components/select/select-provider.svelte.test.ts b/src/uix/soma/components/select/select-provider.svelte.test.ts
index 6feb433a7..8e4a6be56 100644
--- a/src/uix/soma/components/select/select-provider.svelte.test.ts
+++ b/src/uix/soma/components/select/select-provider.svelte.test.ts
@@ -50,6 +50,7 @@ function createSelectOpts() {
value: state([]),
selectionMode: state('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();
diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts
index 66f701be2..9edb0848e 100644
--- a/src/uix/soma/components/select/select-provider.svelte.ts
+++ b/src/uix/soma/components/select/select-provider.svelte.ts
@@ -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.
diff --git a/src/uix/soma/components/select/types.ts b/src/uix/soma/components/select/types.ts
index 35e68ceae..b00104b8e 100644
--- a/src/uix/soma/components/select/types.ts
+++ b/src/uix/soma/components/select/types.ts
@@ -37,6 +37,8 @@ export type SelectProps = WithChild<{
onOpenChangeComplete?: OnChangeFn;
/** @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 */
diff --git a/src/uix/soma/components/toggle-group/components/toggle-group.svelte b/src/uix/soma/components/toggle-group/components/toggle-group.svelte
index 875fbaa60..e502a4462 100644
--- a/src/uix/soma/components/toggle-group/components/toggle-group.svelte
+++ b/src/uix/soma/components/toggle-group/components/toggle-group.svelte
@@ -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')
diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
index b00ac19d2..e993ed273 100644
--- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
+++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
@@ -62,6 +62,7 @@ function groupOpts(root = createRoot()) {
enabledSelections: state(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();
diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
index a47b8ebc1..48b3f3562 100644
--- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
+++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
@@ -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) => {
- 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
diff --git a/src/uix/soma/components/toggle-group/types.ts b/src/uix/soma/components/toggle-group/types.ts
index 00cc6e4ea..89772280e 100644
--- a/src/uix/soma/components/toggle-group/types.ts
+++ b/src/uix/soma/components/toggle-group/types.ts
@@ -39,6 +39,8 @@ export type ToggleGroupProps = WithChild<{
onValueChange?: OnChangeFn;
/** 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 */