From e75f85becca6243de9b2075e75f1bac7389ec40d Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 12 Jul 2026 18:47:22 +0200 Subject: [PATCH] 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 --- src/uix/eidos/components/chronos/README.md | 102 ++++++-- .../components/chronos/chronos-view.svelte | 245 ++++++++++++------ .../chronos/chronos-when-editor.svelte | 10 +- src/uix/eidos/components/chronos/chronos.css | 118 ++++++--- .../eidos/components/chronos/chronos.svelte | 11 +- src/uix/eidos/components/chronos/types.ts | 8 +- src/uix/eidos/components/select/select.css | 6 + .../components/toggle-group/toggle-group.css | 5 + src/uix/morfo/components/select.ts | 7 + src/uix/morfo/components/toggle-group.ts | 14 +- src/uix/sema/components/chronos.ts | 8 +- .../chronos/chronos-provider.svelte.ts | 35 ++- .../select/components/select.svelte | 2 + .../select/select-provider.svelte.test.ts | 20 ++ .../select/select-provider.svelte.ts | 9 +- src/uix/soma/components/select/types.ts | 2 + .../components/toggle-group.svelte | 2 + .../toggle-group-provider.svelte.test.ts | 18 ++ .../toggle-group-provider.svelte.ts | 14 +- src/uix/soma/components/toggle-group/types.ts | 2 + 20 files changed, 452 insertions(+), 186 deletions(-) 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 */