fix(direction): getDir() no era reactivo, y 33 componentes estampaban su valor congelado

El slider no respondia al cambio rtl/ltr. Resultaron ser dos defectos
independientes, y el segundo era del ecosistema entero.

1) EL SLIDER. slider.css emparejaba un inset LOGICO con un transform
FISICO en las tres reglas verticales (inset-inline-start: 50% +
translateX(-50%)). Los transforms no se voltean, asi que en RTL el rail y
el relleno quedaban fuera de eje un ancho entero. Medido en Chrome:
raiz/pulgar/ticks en x=961, rail y relleno en x=955. El Tick se libraba
porque ya usaba margin-inline-start — ese era el idioma correcto del
propio fichero y es el que aplico. Mismo defecto en accordion.css:
text-align: left en el trigger, que era el UNICO text-align fisico de
todo eidos; ahora quedan cero.

2) LA RAIZ. makeDimension().get() leia engine.snapshot() esquivando la
celda $state, asi que prefs.<dim>.get() — y con ella el
soma.prefs.getDir() que la guia manda usar — era una lectura sin
tracking. Los ~64 componentes que resuelven su direccion desde prefs la
congelaban al montarse. La proyeccion DOM se salvaba porque usa
onChange: misma dimension, dos caminos de lectura, solo uno reactivo.

Arreglado con un contador de cambios POR dimension, bombeado desde el
effectiveDiff que el motor ya calcula, de modo que cubre tambien el
cambio por DERIVACION (direction siguiendo a language) y no solo el
intent. Un contador por clave y no una celda global: con una sola celda,
tocar motion despertaria las 64 derivaciones de direccion. El test
negativo lo clava.

3) VALOR NO ES AFIRMACION. 33 de 37 componentes estampaban dir en su raiz
con un valor siempre concreto, asi que una app que ponga html dir=rtl sin
registrar la preferencia se encontraba 33 islas volteadas del reves.
Investigadas las cinco librerias de referencia: MUI y react-aria no
estampan nunca, Radix estampa siempre (sus mantenedores arrastran
discussions/1405 por esto), y Zag estampa prop("dir") — presente si
alguien lo pidio, ausente si no. Adoptado el modelo de Zag pero uniforme:
el atributo NUNCA tiene defecto. La herencia la hace el navegador por
AUSENCIA de atributo, sin ninguna lectura del DOM, asi que la cadena
prop -> prefs -> 'ltr' se queda intacta.

4) HOMOGENEIDAD. Habia DIEZ formas distintas de resolver lo mismo en los
wrappers y CINCO de declarar el tipo, con un cuarto escalon semantico
—heredar del menu padre— enterrado en dos expresiones sueltas.
Normalizado a activeDir(getter, soma) en los 36, un solo tipo
Direction | undefined, y el escalon de los submenus declarado en la
llamada, donde se ve.

5) LA SHELL. El toggle de direccion pasa de 2 a 3 estados: auto hace
clearIntent, que es lo unico que deja ganar al derive. Y entra el arabe
en el catalogo de idiomas, porque sin un idioma RTL la derivacion no era
disparable. Con las dos cosas, elegir arabe voltea la direccion sin tocar
el control — la cadena documentada funcionando de punta a punta por
primera vez.

Verificado: check 74 = linea base exacta, cero nuevos. 602/602 tests en
101 ficheros. En Chrome real: el topbar mueve al slider y al accordion en
vivo, y el arabe voltea por derivacion.

QUEDA: 48 demos siguen clavando dir='ltr'; el interrogante desplazado del
accordion necesita dir="ltr" lang="en" en el contenedor de la demo (no es
un fallo nuestro, es bidi correcto sobre texto ingles en parrafo RTL);
html lang no sigue al idioma; smoke y perm:check sin ejecutar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 3ceda5b593
commit 013ceac574

@ -185,8 +185,30 @@ export function createActivePrefs<S extends PrefsSchema>(
const pendingCell = $state(false);
const lastErrorCell = $state<unknown>(null);
// Svelte only re-runs a reaction that has READ a `$state`. `get()` reads a
// plain object off the engine, so without this nothing is ever tracked and
// every reader freezes at the value it saw first. Each dimension owns a
// counter, bumped when that dimension actually changes; `get()` reads its
// own counter so the caller subscribes to THAT dimension and no other.
// The number itself means nothing — reading it is the whole point.
//
// Bumped from `effectiveDiff`, which the engine computes by diffing the
// resolved `effective` views. So a dimension that changed by derivation
// (direction following language) bumps too, not only one set by intent.
//
// One counter per key, not one shared cell: a shared cell would wake every
// dimension's readers on any commit — a `motion` change would re-run every
// `direction` reader in the app. The engine stays the only source of the
// VALUE; the counter carries only the "something changed" signal.
const changeCounts = $state<Record<string, number>>(
Object.fromEntries(Object.keys(options.schema).map((key) => [key, 0]))
);
const detachCommit = engine.subscribe((event) => {
snapshotCell = event.next;
for (const key of Object.keys(event.effectiveDiff)) {
changeCounts[key] = (changeCounts[key] ?? 0) + 1;
}
});
const state: ActivePrefsState<S> = {
@ -215,7 +237,7 @@ export function createActivePrefs<S extends PrefsSchema>(
const dimensionMembers: Record<string, ActivePrefsDimension<unknown, unknown>> = {};
for (const key of Object.keys(options.schema)) {
dimensionMembers[key] = makeDimension(engine, key);
dimensionMembers[key] = makeDimension(engine, key, changeCounts);
}
const base = {
@ -243,10 +265,15 @@ export function createActivePrefs<S extends PrefsSchema>(
function makeDimension(
engine: ReturnType<typeof createEnginePrefs>,
key: string
key: string,
changeCounts: Record<string, number>
): ActivePrefsDimension<unknown, unknown> {
return {
get() {
// Read the counter first so the caller subscribes to this dimension;
// the value still comes from the engine. Outside a reaction this is
// a plain property read and costs nothing.
void changeCounts[key];
return (engine.snapshot().effective as Record<string, unknown>)[key];
},
set(value: unknown) {

@ -1,14 +1,11 @@
import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import type { Direction } from '$libs/direction';
import type { DomAttrValue } from '$adom';
import type { HapticEffective } from '$libs/haptic';
import type { MotionEffective } from '$libs/motion';
import type { SoundEffective } from '$libs/sound';
import {
readActivePrefsSlot,
type ActivePrefs,
type ActivePrefsSlot
} from './active-prefs.svelte';
import { readActivePrefsSlot, type ActivePrefs, type ActivePrefsSlot } from './active-prefs.svelte';
export type ActivePrefsDomTarget = HTMLElement | (() => HTMLElement | null | undefined);
@ -50,10 +47,19 @@ export function createActivePrefsDomProjection(
if (!target) return;
const attrs: Record<string, DomAttrValue> = {};
readSlotIntoAttr(slots.direction, attrs, PREFS_DOM_ATTRS.DIR, managedAttrs);
readSlotIntoAttr(slots.motion, attrs, PREFS_DOM_ATTRS.MOTION, managedAttrs);
readSlotIntoAttr(slots.sound, attrs, PREFS_DOM_ATTRS.SOUND, managedAttrs);
readSlotIntoAttr(slots.haptic, attrs, PREFS_DOM_ATTRS.HAPTIC, managedAttrs);
// Reading a dimension subscribes the reader to it. This projection is
// already driven by its own `onChange` subscriptions below, so it must
// not ALSO become a reactive dependency of whoever happens to be on the
// stack: `apply()` runs synchronously inside the engine commit, and the
// app triggers that commit from its own `$effect` (setIntent). Tracking
// here would leave that effect subscribed to the very cell it just
// wrote, so every preference change would run it twice.
untrack(() => {
readSlotIntoAttr(slots.direction, attrs, PREFS_DOM_ATTRS.DIR, managedAttrs);
readSlotIntoAttr(slots.motion, attrs, PREFS_DOM_ATTRS.MOTION, managedAttrs);
readSlotIntoAttr(slots.sound, attrs, PREFS_DOM_ATTRS.SOUND, managedAttrs);
readSlotIntoAttr(slots.haptic, attrs, PREFS_DOM_ATTRS.HAPTIC, managedAttrs);
});
if (Object.keys(attrs).length === 0) return;

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { flushSync } from 'svelte';
import { directionDimension, enumDimension, localeDimension } from '$prefs';
import { createActivePrefs } from '../active-prefs.svelte.ts';
/**
* Reactivity contract of the dimension surface (`prefs.<key>.get()`).
*
* This file MUST stay `*.svelte.test.ts`: it only runs under the `client`
* project (vite.config.ts:93). The `server` project compiles `$state` away
* entirely — Svelte's server transform rewrites `let x = $state(v)` to a plain
* `let x = v` — so a `.test.ts` here would pass no matter what the code does.
*
* Why this exists: `getDir()` (src/uix/active-uix/prefs.ts:39) reads a
* dimension through this surface, and ~64 soma component wrappers resolve
* their direction as `dir ?? soma?.prefs.getDir() ?? 'ltr'`. If that read is
* untracked, every one of them freezes its direction at mount.
*/
const schema = {
language: localeDimension({ catalog: ['es-ES', 'ar-EG'], default: 'es-ES' }),
direction: directionDimension(),
accent: enumDimension(['blue', 'green'] as const, { default: 'blue' })
};
describe('createActivePrefs — dimension reads are reactive', () => {
it('re-runs a reaction when the dimension it reads commits', () => {
const prefs = createActivePrefs({ schema });
const seen: string[] = [];
const stop = $effect.root(() => {
$effect(() => {
seen.push(prefs.direction.get() as string);
});
flushSync();
prefs.direction.set('rtl');
flushSync();
});
expect(seen).toEqual(['ltr', 'rtl']);
stop();
prefs.dispose();
});
it('does NOT re-run a reaction when an unrelated dimension commits', () => {
const prefs = createActivePrefs({ schema });
let runs = 0;
const stop = $effect.root(() => {
$effect(() => {
void prefs.direction.get();
runs++;
});
flushSync();
prefs.accent.set('green');
flushSync();
});
// Tracking must be per-dimension: a commit on `accent` shares the same
// engine snapshot, so a single global cell would wake every direction
// reader in the app (~64 component roots).
expect(runs).toBe(1);
stop();
prefs.dispose();
});
it('re-runs when the dimension changes by DERIVATION, not only by intent', () => {
const prefs = createActivePrefs({ schema });
const seen: string[] = [];
const stop = $effect.root(() => {
$effect(() => {
seen.push(prefs.direction.get() as string);
});
flushSync();
// `direction` derives from `language` (dimensions/direction.ts:36-40);
// no intent is ever set on `direction` itself here.
prefs.language.set('ar-EG');
flushSync();
});
// The derivation itself is sound — the engine rebuilds `effective`
// through the derive hooks on every commit. What is missing is the
// propagation: nothing re-runs the reaction that read it.
expect(prefs.direction.get()).toBe('rtl');
expect(seen).toEqual(['ltr', 'rtl']);
stop();
prefs.dispose();
});
});

@ -3,12 +3,24 @@ import type { Direction } from '$libs/direction';
import type { LocaleSource } from '$libs/locale';
export interface ActiveUixPrefsView {
getDir(): Direction;
/**
* Effective direction, or `undefined` when the app registered no
* `direction` dimension.
*
* The `undefined` is load-bearing, not laziness. A component uses this
* for two different purposes: its own logic (arrow keys, pointer sign,
* placement flip) needs a concrete value and defaults to `'ltr'`; the
* `dir` ATTRIBUTE it writes to the DOM must only appear when someone
* actually asserted a direction. Collapsing the two here — returning
* `'ltr'` for "nobody said" — is what makes a component stamp
* `dir="ltr"` onto a page whose `<html>` says `rtl`, flipping its own
* subtree back. Absent attribute means "inherit", which is the right
* answer and costs no DOM read.
*/
getDir(): Direction | undefined;
onPreferenceChange(handler: () => void): () => void;
}
const DEFAULT_DIR: Direction = 'ltr';
export const readActiveUixPrefsSlot = readActivePrefsSlot as <T>(
prefs: unknown,
key: string
@ -36,8 +48,8 @@ export function connectLangsToPrefs(
export function createActiveUixPrefsView(prefs: ActivePrefs): ActiveUixPrefsView {
return {
getDir(): Direction {
return readActiveUixPrefsSlot<Direction>(prefs, 'direction')?.get() ?? DEFAULT_DIR;
getDir(): Direction | undefined {
return readActiveUixPrefsSlot<Direction>(prefs, 'direction')?.get();
},
onPreferenceChange(handler: () => void): () => void {
const detachers = [

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { booleanDimension, createActivePrefs, directionDimension, localeDimension } from '$prefs';
import { createActiveUixPrefsView } from '../prefs.ts';
/**
* `getDir()` distinguishes "nobody asserted a direction" (`undefined`) from
* "the direction is ltr" (`'ltr'`). Components collapse it to a concrete value
* for their own math, but the `dir` ATTRIBUTE they write must only appear in
* the first case — otherwise a component dropped into a page whose `<html>`
* says `rtl` stamps `dir="ltr"` on its own root and flips its subtree back.
*
* `*.svelte.test.ts` because `createActivePrefs` is rune-backed: the `server`
* vitest project compiles `$state` away and would pass regardless.
*/
describe('createActiveUixPrefsView — getDir distinguishes absent from ltr', () => {
it('returns undefined when the app registered no `direction` dimension', () => {
const prefs = createActivePrefs({
schema: { sidebarCollapsed: booleanDimension({ default: false }) }
});
expect(createActiveUixPrefsView(prefs).getDir()).toBeUndefined();
prefs.dispose();
});
it('returns the effective value when the dimension IS registered', () => {
const prefs = createActivePrefs({
schema: {
language: localeDimension({ catalog: ['es-ES', 'ar-EG'], default: 'es-ES' }),
direction: directionDimension()
}
});
const view = createActiveUixPrefsView(prefs);
expect(view.getDir()).toBe('ltr');
prefs.direction.set('rtl');
expect(view.getDir()).toBe('rtl');
// Derivation counts as an assertion too: the app declared a language,
// and direction follows from it.
prefs.direction.clear();
prefs.language.set('ar-EG');
expect(view.getDir()).toBe('rtl');
prefs.dispose();
});
});

@ -112,7 +112,10 @@
font-size: var(--_accordion-trigger-font-size);
font-weight: var(--accordion-trigger-font-weight);
line-height: var(--accordion-trigger-line-height);
text-align: left;
/* Logical, not `left`: a button defaults to `center`, so this rule exists to
pin the label to the reading start. `left` does not flip under RTL — the
bidi reordering happens but the block stays against the wrong edge. */
text-align: start;
cursor: pointer;
transition:
background var(--accordion-trigger-transition-duration) var(--accordion-trigger-transition-ease),

@ -92,11 +92,15 @@
transform: translateY(-50%);
}
/* Centring on the inline axis is a LOGICAL offset (`margin-inline-start`), never
`translateX(-50%)`: transforms are physical, so pairing one with
`inset-inline-start` centres in LTR and lands a full track-width off-axis in
RTL. Same idiom the Tick already used below. */
[data-slider][data-orientation='vertical']::before {
inset-block: 0;
inset-inline-start: 50%;
inline-size: var(--_slider-track-size);
transform: translateX(-50%);
margin-inline-start: calc(var(--_slider-track-size) / -2);
}
[data-slider-range] {
@ -114,7 +118,7 @@
[data-slider-range][data-orientation='vertical'] {
inset-inline-start: 50%;
inline-size: var(--_slider-track-size);
transform: translateX(-50%);
margin-inline-start: calc(var(--_slider-track-size) / -2);
}
/* Secondary track (buffer / preloaded region) — same geometry as the range,
@ -134,7 +138,7 @@
[data-slider-secondary-range][data-orientation='vertical'] {
inset-inline-start: 50%;
inline-size: var(--_slider-track-size);
transform: translateX(-50%);
margin-inline-start: calc(var(--_slider-track-size) / -2);
}
[data-slider-thumb] {

@ -26,11 +26,21 @@ interface AccordionOpts
disabled: boolean;
orientation: Orientation;
loop: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
region: boolean;
}> {}
export class AccordionProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: AccordionOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma = Soma.require();
@ -332,7 +342,7 @@ export class AccordionTriggerProvider {
const root = this.item.provider;
const orientation = root.opts.orientation.current;
const dir = root.opts.dir.current;
const dir = root.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const triggers = root.getTriggers();
const currentIndex = triggers.indexOf(e.currentTarget as HTMLButtonElement);

@ -1,15 +1,12 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { AccordionProvider } from '../accordion-provider.svelte';
import type { AccordionProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -48,7 +45,7 @@
disabled: readableActive(() => disabled),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
region: readableActive(() => region)
});

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive';
@ -85,7 +86,7 @@ export interface CalendarOpts
weekNumberFormat: 'iso' | 'simple';
announceNavigation: boolean;
initialFocus: boolean;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
locale: string;
}> {
onValueChange?: OnChangeFn<DateValue | DateValue[] | undefined>;
@ -116,6 +117,15 @@ function sameDateArray(a: DateValue[], b: DateValue[]): boolean {
}
export class CalendarProvider implements CalendarNav {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: CalendarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -469,7 +479,7 @@ export class CalendarProvider implements CalendarNav {
*/
handleDayKeydown(e: SomaKeyboardEvent<HTMLElement>, date: DateValue): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
this.resolvedDir,
'horizontal'
);

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CalendarProvider, resolveInitialCalendarPlaceholder } from '../calendar-provider.svelte';
@ -93,7 +94,7 @@
showWeekNumbers: readableActive(() => showWeekNumbers),
weekNumberFormat: readableActive(() => weekNumberFormat),
announceNavigation: readableActive(() => announceNavigation),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
locale: readableActive(() => locale ?? '')
});

@ -42,13 +42,22 @@ interface CarouselOpts
stopOnMouseEnter: boolean;
stopOnLastSnap: boolean;
disabled: boolean;
dir: Direction;
dir: Direction | undefined;
locale: string | undefined;
ariaLabel: string | undefined;
onIndexChange: OnChangeFn<number> | undefined;
}> {}
export class CarouselProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: CarouselOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -257,7 +266,7 @@ export class CarouselProvider {
const v = this.translate;
const horizontal = this.opts.orientation.current === 'horizontal';
if (!horizontal) return `translate3d(0, ${v}px, 0)`;
const flip = this.opts.dir.current === 'rtl' ? -1 : 1;
const flip = this.resolvedDir === 'rtl' ? -1 : 1;
return `translate3d(${v * flip}px, 0, 0)`;
});
@ -351,7 +360,7 @@ export class CarouselProvider {
readonly onkeydown = (e: SomaKeyboardEvent<HTMLDivElement>) => {
if (this.isDisabled) return;
const { nextKey, prevKey } = getDirectionalKeys(
this.opts.dir.current,
this.resolvedDir,
this.opts.orientation.current
);
if (e.key === nextKey) {
@ -410,7 +419,7 @@ export class CarouselProvider {
// - Vertical: drag up (negative Y) = next, drag down = prev.
// - Horizontal LTR: drag left (negative X) = next, drag right = prev.
// - Horizontal RTL: mirrored — drag right = next, drag left = prev.
const isRtlHorizontal = horizontal && this.opts.dir.current === 'rtl';
const isRtlHorizontal = horizontal && this.resolvedDir === 'rtl';
const direction = isRtlHorizontal ? -offset : offset;
if (direction < 0) this.next();
else this.prev();
@ -453,7 +462,7 @@ export class CarouselProvider {
// data-* below are contract-only in the morfo (declared with `values`,
// no resolvable `value`), so renderProps doesn't emit them — soma owns them.
'data-orientation': this.opts.orientation.current,
'data-dir': this.opts.dir.current,
'data-dir': this.resolvedDir,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.gesture.isDragging),
'data-autoplay': boolToEmptyStrOrUndef(this.opts.autoplay.current && !this.paused),

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CarouselProvider } from '../carousel-provider.svelte';
@ -72,7 +70,7 @@
stopOnMouseEnter: readableActive(() => stopOnMouseEnter),
stopOnLastSnap: readableActive(() => stopOnLastSnap),
disabled: readableActive(() => disabled),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
locale: readableActive(() => locale),
ariaLabel: readableActive(() => ariaLabel)
});

@ -114,10 +114,19 @@ export interface ColorFieldOpts
required: boolean;
invalid: boolean;
errorMessageId: string | undefined;
dir: Direction;
dir: Direction | undefined;
}> {}
export class ColorFieldProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ColorFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -589,7 +598,7 @@ abstract class BaseColorSegmentProvider {
}
protected get directionalKeys() {
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
return getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
}
handleArrowUp(): void {

@ -1,9 +1,7 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -92,9 +90,7 @@
required: readableActive(() => required),
invalid: readableActive(() => invalid),
errorMessageId: readableActive(() => errorMessageId),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
)
dir: activeDir(() => _dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -156,10 +156,19 @@ export interface ColorPickerOpts
invalid: boolean;
errorMessageId: string | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
}> {}
export class ColorPickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ColorPickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -1082,7 +1091,6 @@ export function channelGradient(
// `channelGradient` above.
// ────────────────────────────────────────────────────────────────────────────
// ── Transparency grid ───────────────────────────────────────────────────────
export interface ColorPickerTransparencyGridOpts

@ -1,9 +1,7 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -144,9 +142,7 @@
}
);
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
const resolvedDir = activeDir(() => _dir, soma);
// ── ColorPicker provider (shared state + channel setters) ────────────────
const picker = ColorPickerProvider.create({

@ -53,11 +53,20 @@ interface ComboboxOpts
loop: boolean;
deselectable: boolean;
allowCustomValue: boolean;
dir: Direction;
dir: Direction | undefined;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
export class ComboboxProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ComboboxOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -482,6 +491,7 @@ export class ComboboxInputProvider {
interface ComboboxTriggerOpts extends WithRefOpts {}
export class ComboboxTriggerProvider {
readonly opts: ComboboxTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ComboboxTriggerOpts) {
@ -542,12 +552,21 @@ interface ComboboxContentOpts
escapeKeydownBehavior: DismissalBehavior;
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
customAnchor: HTMLElement | null;
}> {}
export class ComboboxContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ComboboxContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ComboboxContentOpts) {

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -10,7 +8,6 @@
import { ComboboxContentProvider } from '../combobox-provider.svelte';
import type { ComboboxContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -62,7 +59,7 @@
escapeKeydownBehavior: readableActive(() => escapeKeydownBehavior),
onInteractOutside: readableActive(() => onInteractOutside),
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style),
customAnchor: readableActive(() => customAnchor)
});

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ComboboxProvider } from '../combobox-provider.svelte';
@ -67,7 +68,7 @@
loop: readableActive(() => loop),
deselectable: readableActive(() => deselectable),
allowCustomValue: readableActive(() => allowCustomValue),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { boolToEmptyStrOrUndef } from '$adom';
import {
readableActive,
@ -85,7 +86,7 @@ interface CommandOpts
vimBindings: boolean;
columns: number;
disablePointerSelection: boolean;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
}> {
/** Provider-level select event — fires for ANY item (after its own
* onSelect). A host (Command.Dialog) hooks it to close on select. */
@ -93,6 +94,15 @@ interface CommandOpts
}
export class CommandProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: CommandOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -454,7 +464,7 @@ export class CommandInputProvider {
const vim = this.provider.opts.vimBindings.current;
// In RTL, ArrowLeft moves forward and ArrowRight moves backward.
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.provider.opts.dir.current,
this.provider.resolvedDir,
'horizontal'
);

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CommandProvider } from '../command-provider.svelte';
@ -58,7 +59,7 @@
vimBindings: readableActive(() => vimBindings),
columns: readableActive(() => columns),
disablePointerSelection: readableActive(() => disablePointerSelection),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
onSelect: readableActive(() => onSelect)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -74,7 +72,7 @@
onInteractOutside: readableActive(() => onInteractOutside),
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
onFocusOutside: readableActive(() => onFocusOutside),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -46,7 +44,7 @@
collisionBoundary: readableActive(() => collisionBoundary),
collisionPadding: readableActive(() => collisionPadding),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { createId } from '$active-uix/id';
import { ContextMenuProvider } from '../context-menu-provider.svelte';
import type { ContextMenuProps } from '../types';
@ -30,7 +28,7 @@
}
),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
</script>

@ -46,11 +46,20 @@ interface ContextMenuOpts
ProviderOpts,
StateProps<{ open: boolean }>,
ActiveProps<{
dir: Direction;
dir: Direction | undefined;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
export class ContextMenuProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ContextMenuOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -228,11 +237,20 @@ interface ContextMenuContentOpts
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior;
onFocusOutside: (e: FocusEvent) => void;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {}
export class ContextMenuContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ContextMenuContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ContextMenuContentOpts) {
@ -338,7 +356,7 @@ export class ContextMenuContentProvider {
const items = this.provider.getItems(container);
const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null;
const currentIndex = active ? items.indexOf(active) : -1;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
let targetIndex = -1;
@ -962,11 +980,20 @@ interface ContextMenuSubContentOpts
collisionBoundary: Arrayable<Boundary>;
collisionPadding: number | Partial<Record<Side, number>>;
loop: boolean;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {}
export class ContextMenuSubContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ContextMenuSubContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: ContextMenuSubContentOpts) {

@ -135,6 +135,7 @@ interface CssFieldOpts
}> {}
export class CssFieldProvider {
readonly opts: CssFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,20 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte';
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { DateFieldProvider } from '../date-field-provider.svelte';
import type { DateFieldProviderProps } from '../types';
import {
CalendarDate,
getLocalTimeZone,
today,
type DateValue
} from '$libs/days';
import { CalendarDate, getLocalTimeZone, today, type DateValue } from '$libs/days';
import { Soma } from '../../../core/soma.svelte';
const uid = $props.id();
@ -104,9 +97,7 @@
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
),
dir: activeDir(() => _dir, soma),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
validate: readableActive(() => validate),

@ -180,7 +180,7 @@ export interface DateFieldOpts
granularity: Granularity | undefined;
hourCycle: HourCycle | undefined;
locale: string;
dir: Direction;
dir: Direction | undefined;
hideTimeZone: boolean;
required: boolean;
errorMessageId: string | undefined;
@ -201,6 +201,15 @@ export interface DateFieldOpts
}
export class DateFieldProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: DateFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly runtime: SomaRuntime;
@ -624,12 +633,11 @@ export class DateFieldProvider {
// literals to a single one between any two visible parts. Time
// segments (hour/minute/second/dayPeriod) and their separators
// are passed through untouched.
const isDateOnly = (part: string) =>
part === 'day' || part === 'month' || part === 'year';
const isDateOnly = (part: string) => part === 'day' || part === 'month' || part === 'year';
const flat = arr.filter((seg) =>
isDateOnly(seg.part) ? visible.has(seg.part as EditableSegmentPart) : true
);
const out: typeof arr[number][] = [];
const out: (typeof arr)[number][] = [];
let prevWasDate = false;
for (const seg of flat) {
if (seg.part === 'literal') {
@ -1104,7 +1112,7 @@ abstract class BaseNumericSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
return getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
}
/**
@ -1564,7 +1572,7 @@ class DateFieldDayPeriodSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
return getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {
@ -1684,7 +1692,7 @@ class DateFieldTimeZoneSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
return getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {

@ -1,5 +1,6 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
@ -110,9 +111,7 @@
);
const resolvedLocale = readableActive(() => locale ?? soma?.langs.getLocale() ?? '');
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
const resolvedDir = activeDir(() => dir, soma);
// ── DatePicker context (shared source of truth) ───────────────────────────
const state = DatePickerProvider.create({

@ -89,7 +89,7 @@ interface DatePickerOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
// Calendar options
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
@ -120,6 +120,15 @@ interface DatePickerOpts
* open flags through a single context.
*/
export class DatePickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: DatePickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,10 +1,7 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive,
state
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive, state } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -81,20 +78,17 @@
);
// Sync start / end → external `value`, firing onValueChange only when complete.
watch(
[() => startValue.current, () => endValue.current],
([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const range: DateRange = { start: s, end: e };
value = range;
onValueChange(range);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
watch([() => startValue.current, () => endValue.current], ([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const range: DateRange = { start: s, end: e };
value = range;
onValueChange(range);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
);
});
const state_ = DateRangeFieldProvider.create({
id: readableActive(() => id),
@ -135,9 +129,7 @@
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
),
dir: activeDir(() => _dir, soma),
errorMessageId: readableActive(() => errorMessageId)
});

@ -62,7 +62,7 @@ interface DateRangeFieldOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
errorMessageId: string | undefined;
}> {}
@ -72,6 +72,15 @@ interface DateRangeFieldOpts
* wrappers each drive one endpoint via its own DateField subtree.
*/
export class DateRangeFieldProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: DateRangeFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,5 +1,6 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive, state } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
@ -143,9 +144,7 @@
}
);
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
const resolvedDir = activeDir(() => dir, soma);
// ── Shared state via picker context ───────────────────────────────────────
const pickerState = DateRangePickerProvider.create({

@ -83,7 +83,7 @@ interface DateRangePickerOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions['weekday'];
@ -108,6 +108,15 @@ interface DateRangePickerOpts
* `DateRangePicker.Calendar` creates a `RangeCalendarProvider`.
*/
export class DateRangePickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: DateRangePickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -178,7 +178,10 @@ export class DrawerProvider {
private overlayRef = state<HTMLElement | null>(null);
readonly resolvedSide = $derived.by((): DrawerSide => {
const dir = this.soma.prefs.getDir();
// Own math, so the concrete default applies. `getDir()` is undefined when
// the app registered no `direction` dimension; that is only meaningful
// for the DOM attribute, and the drawer stamps none.
const dir = this.soma.prefs.getDir() ?? 'ltr';
return resolveDirection(this.opts.direction.current, dir);
});
@ -305,10 +308,7 @@ export class DrawerProvider {
return this.triggerClose(DISMISS_CAUSES[action], opts);
}
private async triggerClose(
cause: DismissCause,
opts: { message?: string } = {}
): Promise<void> {
private async triggerClose(cause: DismissCause, opts: { message?: string } = {}): Promise<void> {
// Resolve target with fall-throughs (mirrors DialogProvider): the
// runtime-registered Content ref takes priority over the
// triggerNode when contentRef state isn't separately set.

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -10,7 +8,6 @@
import { MenuContentProvider, MenuProvider } from '../dropdown-menu-provider.svelte';
import type { MenuContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
const parentMenu = MenuProvider.get();
@ -79,9 +76,7 @@
onFocusOutside: readableActive(() => onFocusOutside),
// Prefer an explicit prop → the parent Menu provider's dir (so wrapper
// components like Menubar can propagate `dir` down) → Soma → 'ltr'.
dir: readableActive(
() => dir ?? parentMenu?.opts.dir.current ?? soma?.prefs.getDir() ?? 'ltr'
),
dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma),
style: readableActive(() => style),
customAnchor: readableActive(() => customAnchor)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -33,13 +31,12 @@
...restProps
}: MenuSubContentProps = $props();
// Resolve the effective text direction: explicit prop → parent Menu →
// Soma → 'ltr'. Submenus default to the logical "end" side, which in RTL
// means the visual left. Explicit `side` prop always wins.
const effectiveDir = $derived(
dir ?? parentMenu?.opts.dir.current ?? soma?.prefs.getDir() ?? 'ltr'
);
const effectiveSide = $derived(side ?? (effectiveDir === 'rtl' ? 'left' : 'right'));
// A submenu consults its parent Menu before prefs — that extra step is
// declared here, at the call site, instead of being buried in an inline
// chain. Submenus default to the logical "end" side, which in RTL means the
// visual left; an explicit `side` prop always wins.
const assertedDir = activeDir(() => dir ?? parentMenu?.opts.dir.current, soma);
const effectiveSide = $derived(side ?? (assertedDir.current === 'rtl' ? 'left' : 'right'));
const state = MenuSubContentProvider.create({
id: readableActive(() => id),
@ -55,7 +52,7 @@
collisionBoundary: readableActive(() => collisionBoundary),
collisionPadding: readableActive(() => collisionPadding),
loop: readableActive(() => loop),
dir: readableActive(() => effectiveDir),
dir: assertedDir,
style: readableActive(() => style)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { createId } from '$active-uix/id';
import { MenuProvider } from '../dropdown-menu-provider.svelte';
import type { MenuProps } from '../types';
@ -32,7 +30,7 @@
}
),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
accessibleWhenDisabled: readableActive(() => accessibleWhenDisabled)
});
</script>

@ -51,12 +51,21 @@ interface MenuOpts
ProviderOpts,
StateProps<{ open: boolean }>,
ActiveProps<{
dir: Direction;
dir: Direction | undefined;
accessibleWhenDisabled: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
export class MenuProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MenuOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -284,12 +293,21 @@ interface MenuContentOpts
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior | undefined;
onFocusOutside: (e: FocusEvent) => void;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
customAnchor: HTMLElement | null;
}> {}
export class MenuContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MenuContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuContentOpts) {
@ -439,7 +457,7 @@ export class MenuContentProvider {
const items = this.provider.getItems(container);
const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null;
const currentIndex = active ? items.indexOf(active) : -1;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
let targetIndex = -1;
@ -1192,11 +1210,20 @@ interface MenuSubContentOpts
collisionBoundary: Arrayable<Boundary>;
collisionPadding: number | Partial<Record<Side, number>>;
loop: boolean;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {}
export class MenuSubContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MenuSubContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuSubContentOpts) {

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -62,7 +63,7 @@
showRecents: readableActive(() => showRecents),
maxRecents: readableActive(() => maxRecents),
closeOnSelect: readableActive(() => closeOnSelect),
dir: readableActive(() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl')
dir: activeDir(() => dir, soma)
});
// The composed Popover — owns open/close, focus trap/return, portal.

@ -43,6 +43,15 @@ interface EmojiPickerOpts
}> {}
export class EmojiPickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: EmojiPickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -66,7 +64,7 @@
onInteractOutside: readableActive(() => onInteractOutside),
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
onFocusOutside: readableActive(() => onFocusOutside),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style)
});

@ -178,6 +178,7 @@ export class LinkPreviewProvider {
interface LinkPreviewTriggerOpts extends WithRefOpts {}
export class LinkPreviewTriggerProvider {
readonly opts: LinkPreviewTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: LinkPreviewTriggerOpts) {
@ -252,11 +253,20 @@ interface LinkPreviewContentOpts
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior;
onFocusOutside: (e: FocusEvent) => void;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {}
export class LinkPreviewContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: LinkPreviewContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: LinkPreviewContentOpts) {

@ -45,6 +45,7 @@ interface ListboxOpts
}> {}
export class ListboxProvider {
readonly opts: ListboxOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -115,8 +116,19 @@ export class ListboxProvider {
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
readonly resolvedDir: Active<Direction> = readableActive(
() => this.opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr'
/** Concrete direction for this component's OWN math (arrow keys). */
readonly resolvedDir: Active<Direction> = readableActive(() => this.assertedDir.current ?? 'ltr');
/**
* What someone actually ASSERTED — `undefined` when neither the prop nor a
* registered `direction` preference said anything. This, not `resolvedDir`,
* is what the `dir` attribute stamps: an omitted attribute inherits, while
* a defaulted `'ltr'` would force this subtree back to LTR inside an RTL
* page. Unlike the other components, listbox resolves in the provider
* rather than in its wrapper, so the split lives here.
*/
readonly assertedDir: Active<Direction | undefined> = readableActive(
() => this.opts.dir.current ?? this.soma.prefs.getDir()
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
@ -194,11 +206,7 @@ export class ListboxProvider {
// item element by data-value so the cascade matches this
// specific item (multiple items registered under same kebab).
const isNowSelected = next.includes(value);
const eventName = isNowSelected
? 'commit-select'
: wasSelected
? 'commit-unselect'
: null;
const eventName = isNowSelected ? 'commit-select' : wasSelected ? 'commit-unselect' : null;
if (eventName) {
const itemEl = this.opts.ref?.current?.querySelector<HTMLElement>(
`[${attrs.item}][data-value="${CSS.escape(value)}"]`
@ -358,7 +366,7 @@ export class ListboxProvider {
// readonly/required/invalid, default aria-label) resolved against this
// part's registered sources — no longer re-declared here.
...this.runtimePart.renderProps(),
dir: this.resolvedDir.current,
dir: this.assertedDir.current,
// Soma override: honours explicit ariaLabel prop + suppresses when
// aria-labelledby is set (overrides the morfo's default translation;
// undefined correctly removes it). aria-labelledby is not morfo-modeled.

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -58,9 +59,7 @@
onInteractOutside: readableActive(() => onInteractOutside),
onFocusOutside: readableActive(() => onFocusOutside),
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
dir: readableActive(
() => dir ?? menu?.provider.opts.dir.current ?? soma?.prefs.getDir() ?? 'ltr'
),
dir: activeDir(() => dir ?? menu?.provider.opts.dir.current, soma),
style: readableActive(() => style as string | StyleProperties | null | undefined),
customAnchor: readableActive(() => customAnchor)
});

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { MenubarProvider } from '../menubar-provider.svelte';
@ -37,7 +35,7 @@
onValueChange(v);
}
),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
loop: readableActive(() => loop)
});

@ -40,11 +40,20 @@ interface MenubarOpts
WithRefOpts,
StateProps<{ value: string }>,
ActiveProps<{
dir: Direction;
dir: Direction | undefined;
loop: boolean;
}> {}
export class MenubarProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MenubarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -316,7 +325,7 @@ export class MenubarMenuProvider {
return;
}
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
if (e.key !== nextKey && e.key !== prevKey) return;
@ -440,7 +449,7 @@ export class MenubarTriggerProvider {
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.opts.disabled.current) return;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
const myValue = this.menu.opts.value.current;
@ -523,7 +532,7 @@ interface MenubarPanelOpts
onInteractOutside: (e: PointerEvent) => void;
onFocusOutside: (e: FocusEvent) => void;
interactOutsideBehavior: DismissalBehavior | undefined;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
customAnchor: HTMLElement | null;
}> {}
@ -544,6 +553,15 @@ interface MenubarPanelOpts
* open coordination work unchanged for both surface kinds.
*/
export class MenubarPanelContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MenubarPanelOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenubarPanelOpts) {

@ -1,11 +1,9 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import {
MonthGridProvider,
resolveInitialMonthPlaceholder
} from '../month-grid-provider.svelte';
import { MonthGridProvider, resolveInitialMonthPlaceholder } from '../month-grid-provider.svelte';
import type { MonthGridProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
@ -56,7 +54,7 @@
maxValue: readableActive(() => maxValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
locale: readableActive(() => locale ?? ''),
monthFormat: readableActive(() => monthFormat),
ariaLabel: readableActive(() => ariaLabel)

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { boolToEmptyStrOrUndef } from '$adom';
import { state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
@ -57,7 +58,7 @@ export interface MonthGridOpts
maxValue: MonthValue | undefined;
disabled: boolean;
readonly: boolean;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
locale: string;
monthFormat: 'long' | 'short' | 'narrow';
ariaLabel: string | undefined;
@ -66,6 +67,15 @@ export interface MonthGridOpts
}
export class MonthGridProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: MonthGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -127,9 +137,7 @@ export class MonthGridProvider {
readonly fullLabel = $derived.by(() => {
const base =
this.opts.ariaLabel.current ??
this.soma.langs.ts(MONTH_GRID_LANGS.LABEL) ??
'Month grid';
this.opts.ariaLabel.current ?? this.soma.langs.ts(MONTH_GRID_LANGS.LABEL) ?? 'Month grid';
return `${base}, ${this.headingValue}`;
});
@ -166,7 +174,10 @@ export class MonthGridProvider {
private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next;
void this.runtime.trigger('shift-navigate-step', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(
'shift-navigate-step',
target ? { fallbackTarget: target } : undefined
);
}
prevPage(target?: HTMLElement): void {
@ -193,7 +204,7 @@ export class MonthGridProvider {
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>, month: number): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
this.resolvedDir,
'horizontal'
);
@ -455,8 +466,7 @@ export class MonthGridGridProvider {
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface MonthGridCellOpts
extends WithRefOpts, ActiveProps<{ month: number }> {}
export interface MonthGridCellOpts extends WithRefOpts, ActiveProps<{ month: number }> {}
export class MonthGridCellProvider {
readonly opts: MonthGridCellOpts;

@ -49,7 +49,7 @@ export interface NaturalTimePickerOpts
/** Whether the moment chips render icon-only (with a tooltip). */
chipsIconOnly: boolean;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
disabled: boolean;
readonly: boolean;
required: boolean;
@ -64,6 +64,15 @@ export interface NaturalTimePickerOpts
* class only edits the working value it is handed.
*/
export class NaturalTimePickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: NaturalTimePickerOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;

@ -114,8 +114,21 @@ export class NavigationMenuProvider {
});
}
/** Concrete direction for this component's OWN math (arrow keys, placement). */
readonly resolvedDir: Active<Direction> = readableActive(
() => this.opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr'
() => this.assertedDir.current ?? 'ltr'
);
/**
* What someone actually ASSERTED — `undefined` when neither the prop nor a
* registered `direction` preference said anything. This, not `resolvedDir`,
* is what the `dir` attribute stamps: an omitted attribute inherits, while
* a defaulted `'ltr'` would force this subtree back to LTR inside an RTL
* page. Like listbox, this component resolves in the provider rather than
* in its wrapper, so the split lives here.
*/
readonly assertedDir: Active<Direction | undefined> = readableActive(
() => this.opts.dir.current ?? this.soma.prefs.getDir()
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
@ -367,7 +380,7 @@ export class NavigationMenuProvider {
// dir is soma-resolved (prop → prefs → 'ltr'); the morfo doesn't model
// it. data-orientation is a morfo enum without a value source, so soma
// supplies the value.
dir: this.resolvedDir.current,
dir: this.assertedDir.current,
'data-orientation': this.opts.orientation.current
} as const)
);

@ -148,6 +148,7 @@ interface NumberFieldOpts
}> {}
export class NumberFieldProvider {
readonly opts: NumberFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -10,7 +8,6 @@
import { PopoverContentProvider } from '../popover-provider.svelte';
import type { PopoverContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -84,7 +81,7 @@
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
onFocusOutside: readableActive(() => onFocusOutside),
onPlaced: readableActive(() => onPlaced),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style)
});

@ -475,7 +475,10 @@ interface PopoverContentOpts extends ActiveProps<{
interactOutsideBehavior: DismissalBehavior | undefined;
onFocusOutside: (e: FocusEvent) => void;
onPlaced: () => void;
dir: Direction;
/** `undefined` = nobody asserted a direction. Forwarded to the Floating
* layer, which stamps it on the portalled wrapper — omitted means inherit
* from the portal host instead of forcing `ltr`. */
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {
id: Active<string>;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -51,7 +49,7 @@
invalid: readableActive(() => invalid),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
name: readableActive(() => name),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby)

@ -26,13 +26,23 @@ interface RadioGroupOpts
invalid: boolean;
orientation: Orientation;
loop: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
name: string;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
}> {}
export class RadioGroupProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: RadioGroupOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<RadioGroupProvider>('RadioGroup');
@ -115,8 +125,8 @@ export class RadioGroupProvider {
undefined
);
readonly resolvedAriaLabel = $derived.by(
() => (this.resolvedAriaLabelledby ? undefined : this.opts.ariaLabel.current || undefined)
readonly resolvedAriaLabel = $derived.by(() =>
this.resolvedAriaLabelledby ? undefined : this.opts.ariaLabel.current || undefined
);
selectItem(value: string, itemEl?: HTMLElement) {
@ -226,9 +236,7 @@ export class RadioItemProvider {
return this.provider.isFallbackTabStop(this.opts.ref.current);
});
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.isDisabled
);
readonly isDisabled = $derived.by(() => this.opts.disabled.current || this.provider.isDisabled);
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled || this.provider.isReadonly) return;
@ -239,7 +247,7 @@ export class RadioItemProvider {
if (this.isDisabled || this.provider.isReadonly) return;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const items = this.provider.getItems();
const currentIndex = items.indexOf(e.currentTarget as HTMLElement);

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import {
@ -97,7 +95,7 @@
showWeekNumbers: readableActive(() => showWeekNumbers),
weekNumberFormat: readableActive(() => weekNumberFormat),
announceNavigation: readableActive(() => announceNavigation),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
locale: readableActive(() => locale ?? '')
});

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive';
@ -83,7 +84,7 @@ export interface RangeCalendarOpts
weekNumberFormat: 'iso' | 'simple';
announceNavigation: boolean;
initialFocus: boolean;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
locale: string;
}> {
onValueChange?: OnChangeFn<DateRange>;
@ -104,6 +105,15 @@ export function resolveInitialRangeCalendarPlaceholder(
}
export class RangeCalendarProvider implements CalendarNav {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: RangeCalendarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -414,7 +424,10 @@ export class RangeCalendarProvider implements CalendarNav {
this.anchor = date;
this.previewDate = undefined;
this.opts.value.current = { start: date, end: undefined };
void this.runtime.trigger('commit-select-start', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(
'commit-select-start',
target ? { fallbackTarget: target } : undefined
);
}
private clearSelection(target?: HTMLElement): void {
@ -470,7 +483,10 @@ export class RangeCalendarProvider implements CalendarNav {
this.opts.value.current = { start, end };
this.anchor = undefined;
this.previewDate = undefined;
void this.runtime.trigger('commit-select-range', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(
'commit-select-range',
target ? { fallbackTarget: target } : undefined
);
}
/** Called by Day on pointermove when the user is mid-range. */
@ -554,7 +570,7 @@ export class RangeCalendarProvider implements CalendarNav {
handleDayKeydown(e: SomaKeyboardEvent<HTMLElement>, date: DateValue): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
this.resolvedDir,
'horizontal'
);

@ -45,7 +45,7 @@ interface RatingGroupOpts
required: boolean;
invalid: boolean;
orientation: Orientation;
dir: Direction;
dir: Direction | undefined;
name: string | undefined;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
@ -54,6 +54,15 @@ interface RatingGroupOpts
}> {}
export class RatingGroupProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: RatingGroupOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -245,7 +254,7 @@ export class RatingGroupProvider {
const key = e.key;
// APG slider pattern: horizontal increment flips under RTL, vertical
// UP/DOWN are absolute (UP always increments).
const { nextKey, prevKey } = getDirectionalKeys(this.opts.dir.current, 'horizontal');
const { nextKey, prevKey } = getDirectionalKeys(this.resolvedDir, 'horizontal');
const isIncrement = key === nextKey || key === KEYS.ARROW_UP;
const isDecrement = key === prevKey || key === KEYS.ARROW_DOWN;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -31,7 +29,7 @@
),
type: readableActive(() => type),
scrollHideDelay: readableActive(() => scrollHideDelay),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -18,10 +18,19 @@ interface ScrollAreaOpts
ActiveProps<{
type: ScrollAreaType;
scrollHideDelay: number;
dir: Direction;
dir: Direction | undefined;
}> {}
export class ScrollAreaProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: ScrollAreaOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -10,7 +8,6 @@
import { SelectContentProvider } from '../select-provider.svelte';
import type { SelectContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -63,7 +60,7 @@
escapeKeydownBehavior: readableActive(() => escapeKeydownBehavior),
onInteractOutside: readableActive(() => onInteractOutside),
interactOutsideBehavior: readableActive(() => interactOutsideBehavior),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style)
});

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SelectProvider } from '../select-provider.svelte';
@ -66,7 +67,7 @@
loop: readableActive(() => loop),
deselectable: readableActive(() => deselectable),
scrollAlignment: readableActive(() => scrollAlignment),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});

@ -62,11 +62,20 @@ interface SelectOpts
loop: boolean;
deselectable: boolean;
scrollAlignment: ScrollAlignment;
dir: Direction;
dir: Direction | undefined;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
export class SelectProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: SelectOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -321,7 +330,7 @@ export class SelectTriggerProvider {
}
} else {
// Open: navigate items via virtual focus (aria-activedescendant)
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
const container = this.provider.contentRef.current;
if (!container) return;
@ -407,6 +416,7 @@ export class SelectTriggerProvider {
interface SelectValueOpts extends WithRefOpts, ActiveProps<{ placeholder: string }> {}
export class SelectValueProvider {
readonly opts: SelectValueOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: SelectValueOpts) {
@ -461,11 +471,20 @@ interface SelectContentOpts
escapeKeydownBehavior: DismissalBehavior;
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior;
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
}> {}
export class SelectContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: SelectContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: SelectContentOpts) {

@ -1,5 +1,6 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -55,7 +56,7 @@
orientation: readableActive(() => orientation),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
name: readableActive(() => name),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),

@ -51,7 +51,8 @@ interface SliderOpts
orientation: Orientation;
disabled: boolean;
readonly?: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
name: string;
thumbLabels: string[] | undefined;
valueText?: (value: number, index: number) => string;
@ -82,6 +83,18 @@ export class SliderProvider {
activeThumbIndex = $state<number | null>(null);
/**
* Direction for the component's OWN math — pointer percent, thumb anchor,
* arrow keys. Always concrete: `undefined` and `'ltr'` are indistinguishable
* to every `=== 'rtl'` test, so defaulting here changes no behaviour.
*
* The DOM attribute is the other half and is deliberately NOT this value:
* `props` stamps `opts.dir.current` raw, so the attribute is omitted when
* nobody asserted a direction and the element inherits instead of forcing
* `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
@ -163,7 +176,7 @@ export class SliderProvider {
const rect = el.getBoundingClientRect();
const isHorizontal = this.opts.orientation.current === 'horizontal';
const isRtl = this.opts.dir.current === 'rtl';
const isRtl = this.resolvedDir === 'rtl';
let percent: number;
if (isHorizontal) {
@ -422,7 +435,7 @@ export class SliderRangeProvider {
readonly rangeStyle = $derived.by(() => {
const values = this.provider.opts.value.current;
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const isRtl = this.provider.opts.dir.current === 'rtl';
const isRtl = this.provider.resolvedDir === 'rtl';
const min = Math.min(...values);
const max = Math.max(...values);
const startPct = this.provider.getPercent(
@ -481,7 +494,7 @@ export class SliderSecondaryRangeProvider {
readonly rangeStyle = $derived.by(() => {
const secondary = this.provider.opts.secondaryValue?.current;
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const isRtl = this.provider.opts.dir.current === 'rtl';
const isRtl = this.provider.resolvedDir === 'rtl';
const endPct = secondary === undefined ? 0 : this.provider.getPercent(secondary);
if (isHorizontal) {
@ -553,7 +566,7 @@ export class SliderThumbProvider {
const index = this.opts.index.current;
let delta = 0;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
// For the horizontal axis, use getDirectionalKeys to handle RTL.
// Vertical axis always maps UP=increase, DOWN=decrease (not RTL-dependent).
const { nextKey: increaseHKey, prevKey: decreaseHKey } = getDirectionalKeys(dir, 'horizontal');
@ -587,7 +600,7 @@ export class SliderThumbProvider {
readonly thumbStyle = $derived.by(() => {
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const isRtl = this.provider.opts.dir.current === 'rtl';
const isRtl = this.provider.resolvedDir === 'rtl';
if (isHorizontal) {
// LTR anchors with `left: X%`, so the thumb must shift LEFT by half
// its own width to center-align its centre with the anchor
@ -670,7 +683,7 @@ export class SliderTickProvider {
readonly props = $derived.by(() => {
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const isRtl = this.provider.opts.dir.current === 'rtl';
const isRtl = this.provider.resolvedDir === 'rtl';
const side = isHorizontal && isRtl ? 'right' : 'left';
const tx = isHorizontal && isRtl ? '50%' : '-50%';
return this.runtimePart.assert({

@ -1,15 +1,12 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { SplitterProvider } from '../splitter-provider.svelte';
import type { SplitterProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -36,7 +33,7 @@
),
orientation: readableActive(() => orientation),
disabled: readableActive(() => disabled),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
keyboardStep: readableActive(() => keyboardStep),
sizes: writableActive(
() => sizes,

@ -59,13 +59,22 @@ interface SplitterOpts
ActiveProps<{
orientation: Orientation;
disabled: boolean;
dir: Direction;
dir: Direction | undefined;
keyboardStep: number;
onSizesChange: (sizes: number[]) => void;
onResizeEnd: (sizes: number[]) => void;
}> {}
export class SplitterProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: SplitterOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -661,7 +670,7 @@ export class SplitterResizeTriggerProvider {
if (this.isDisabled) return;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const step = this.provider.opts.keyboardStep.current;

@ -1,15 +1,12 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { StepperProvider } from '../stepper-provider.svelte';
import type { StepperProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -40,7 +37,7 @@
}
),
orientation: readableActive(() => orientation),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
linear: readableActive(() => linear)
});

@ -18,11 +18,21 @@ interface StepperOpts
StateProps<{ value: number }>,
ActiveProps<{
orientation: Orientation;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
linear: boolean;
}> {}
export class StepperProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: StepperOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -293,7 +303,7 @@ export class StepperTriggerProvider {
if (this.isDisabled) return;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const triggers = this.provider.getRegisteredTriggers();
const currentIndex = triggers.indexOf(e.currentTarget as HTMLElement);

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -31,7 +29,7 @@
),
table: readableActive(() => table),
label: readableActive(() => label),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -10,7 +10,6 @@ import type { TableInstance, TableHeader, TableRow, TableCell } from '$libs/data
import { tableMorfo } from '../../../morfo/components/table';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
@ -47,10 +46,19 @@ interface TableProviderOpts
ActiveProps<{
table: TableInstance;
label: string | undefined;
dir: Direction;
dir: Direction | undefined;
}> {}
export class TableProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TableProviderOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,15 +1,12 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { TabsProvider } from '../tabs-provider.svelte';
import type { TabsProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -47,7 +44,7 @@
activationMode: readableActive(() => activationMode),
disabled: readableActive(() => disabled),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -31,11 +31,21 @@ interface TabsOpts
activationMode: ActivationMode;
disabled: boolean;
loop: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
onFocusChange: OnChangeFn<string>;
}> {}
export class TabsProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: TabsOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<TabsProvider>('Tabs');
@ -278,7 +288,7 @@ export class TabsTriggerProvider {
const p = this.provider;
const orientation = p.opts.orientation.current;
const dir = p.opts.dir.current;
const dir = p.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const triggers = p.getTriggers();
const currentIndex = triggers.indexOf(e.currentTarget as HTMLButtonElement);

@ -1,18 +1,11 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import {
Time,
today,
getLocalTimeZone,
type TimeValue
} from '$libs/days';
import { Time, today, getLocalTimeZone, type TimeValue } from '$libs/days';
import { TimeFieldProvider } from '../time-field-provider.svelte';
import type { TimeFieldProviderProps } from '../types';
@ -100,9 +93,7 @@
required: readableActive(() => required),
errorMessageId: readableActive(() => errorMessageId),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
),
dir: activeDir(() => _dir, soma),
hourCycle: readableActive(() => hourCycle)
});

@ -1,4 +1,5 @@
import { untrack } from 'svelte';
import type { Direction } from '../../types';
import { watch } from '$libs/reactive';
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
@ -104,11 +105,20 @@ export interface TimeFieldOpts
required: boolean;
errorMessageId: string | undefined;
locale: string;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
hourCycle: HourCycle | undefined;
}> {}
export class TimeFieldProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TimeFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly runtime: SomaRuntime;
@ -691,7 +701,7 @@ export abstract class BaseTimeNumericSegmentProvider {
}
protected get directionalKeys() {
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
return getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
}
handleArrowUp(): void {
@ -982,7 +992,7 @@ export class TimeFieldDayPeriodSegmentProvider {
return;
}
if (isSegmentNavigationKey(e.key)) {
const { nextKey, prevKey } = getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
const { nextKey, prevKey } = getDirectionalKeys(this.provider.resolvedDir, 'horizontal');
handleSegmentNavigation(
this.provider.soma.dom,
e as unknown as KeyboardEvent,

@ -1,5 +1,6 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
@ -87,9 +88,7 @@
}
);
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
const resolvedDir = activeDir(() => dir, soma);
const state = TimePickerProvider.create({
id: readableActive(() => id),

@ -54,7 +54,7 @@ export interface TimePickerOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
hourStep: number;
minuteStep: number;
secondStep: number;
@ -69,6 +69,15 @@ export interface TimePickerOpts
* underlying time.
*/
export class TimePickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TimePickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,10 +1,7 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive,
state
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive, state } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -75,20 +72,17 @@
);
// Sync start / end → external `value`, firing onValueChange only when complete.
watch(
[() => startValue.current, () => endValue.current],
([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const range: TimeRange = { start: s as never, end: e as never };
value = range;
onValueChange(range);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
watch([() => startValue.current, () => endValue.current], ([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const range: TimeRange = { start: s as never, end: e as never };
value = range;
onValueChange(range);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
);
});
const state_ = TimeRangeFieldProvider.create({
id: readableActive(() => id),
@ -128,9 +122,7 @@
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
),
dir: activeDir(() => _dir, soma),
errorMessageId: readableActive(() => errorMessageId)
});

@ -46,7 +46,7 @@ interface TimeRangeFieldOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
errorMessageId: string | undefined;
}> {}
@ -56,6 +56,15 @@ interface TimeRangeFieldOpts
* wrappers each drive one endpoint via its own TimeField subtree.
*/
export class TimeRangeFieldProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TimeRangeFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,10 +1,7 @@
<script lang="ts">
import { watch } from '$libs/reactive';
import {
readableActive,
writableActive,
state
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { readableActive, writableActive, state } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -84,20 +81,17 @@
);
// Sync start / end → external `value` + fire callbacks.
watch(
[() => startValue.current, () => endValue.current],
([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const next: TimeRange = { start: s as never, end: e as never };
value = next;
onValueChange(next);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
watch([() => startValue.current, () => endValue.current], ([s, e]) => {
onStartValueChange(s);
onEndValueChange(e);
if (s && e) {
const next: TimeRange = { start: s as never, end: e as never };
value = next;
onValueChange(next);
} else if (!s && !e) {
value = { start: undefined, end: undefined } as never;
}
);
});
const sharedValue = writableActive(
() => value ?? ({ start: undefined, end: undefined } as TimeRange),
@ -126,9 +120,7 @@
}
);
const resolvedDir = readableActive<'ltr' | 'rtl'>(
() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
const resolvedDir = activeDir(() => dir, soma);
// ── Shared state via picker context ──────────────────────────────────────
const state_ = TimeRangePickerProvider.create({

@ -55,7 +55,7 @@ export interface TimeRangePickerOpts
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
locale: string | undefined;
dir: Direction;
dir: Direction | undefined;
hourStep: number;
minuteStep: number;
secondStep: number;
@ -74,6 +74,15 @@ export interface TimeRangePickerOpts
* `<PickerShell.Footer/Clear/Cancel/Close>` parts work out of the box.
*/
export class TimeRangePickerProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TimeRangePickerOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;

@ -1,4 +1,5 @@
import { untrack } from 'svelte';
import type { Direction } from '../../types';
import { context } from '../../provider';
import { readableActive, state, type Active, type ActiveProps, type State } from '$libs/reactive';

@ -1,16 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { ToggleGroupProvider } from '../toggle-group-provider.svelte';
import type { ToggleGroupProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -70,7 +67,7 @@
readonly: readableActive(() => readonly),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -38,10 +38,20 @@ interface ToggleGroupOpts
readonly: boolean;
orientation: Orientation;
loop: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
}> {}
export class ToggleGroupProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: ToggleGroupOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -232,7 +242,7 @@ export class ToggleGroupItemProvider {
if (this.isDisabled) return;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const dir = this.provider.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const items = this.provider.getItems();
const currentIndex = items.indexOf(e.currentTarget as HTMLElement);

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
@ -31,7 +29,7 @@
),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr')
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -20,11 +20,21 @@ interface ToolbarOpts
ActiveProps<{
orientation: Orientation;
loop: boolean;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
}> {}
export class ToolbarProvider {
readonly opts: ToolbarOpts;
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
@ -88,7 +98,7 @@ export class ToolbarProvider {
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
const orientation = this.opts.orientation.current;
const dir = this.opts.dir.current;
const dir = this.resolvedDir;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const items = this.getItems();
if (items.length === 0) return;

@ -1,8 +1,6 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
@ -10,7 +8,6 @@
import { TooltipContentProvider } from '../tooltip-provider.svelte';
import type { TooltipContentProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -59,7 +56,7 @@
hideWhenDetached: readableActive(() => hideWhenDetached),
updatePositionStrategy: readableActive(() => updatePositionStrategy),
strategy: readableActive(() => strategy),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
style: readableActive(() => style),
onEscapeKeydown: readableActive(() => onEscapeKeydown),
escapeKeydownBehavior: readableActive(() => escapeKeydownBehavior)

@ -391,7 +391,7 @@ interface TooltipContentOpts
hideWhenDetached: boolean;
updatePositionStrategy: 'optimized' | 'always';
strategy: 'fixed' | 'absolute';
dir: Direction;
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
ariaLabel: string | undefined;
onEscapeKeydown: (e: KeyboardEvent) => void;
@ -399,6 +399,15 @@ interface TooltipContentOpts
}> {}
export class TooltipContentProvider {
/**
* Direction for this part's OWN math (arrow keys, placement flip). Always
* concrete — `undefined` and `'ltr'` are indistinguishable to every
* `=== 'rtl'` test. The DOM attribute is the other half: what gets stamped
* is the raw `opts.dir.current`, so it is omitted when nobody asserted a
* direction and the element inherits instead of forcing `ltr`.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: TooltipContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TooltipContentOpts) {

@ -1,15 +1,12 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Soma } from '../../../core/soma.svelte';
import { TreeViewProvider } from '../tree-view-provider.svelte';
import type { TreeViewProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
@ -52,7 +49,7 @@
}
),
selectionMode: readableActive(() => selectionMode),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
expandOnClick: readableActive(() => expandOnClick),
typeahead: readableActive(() => typeahead),
ariaLabel: readableActive(() => ariaLabel),

@ -25,7 +25,8 @@ interface TreeViewOpts
}>,
ActiveProps<{
selectionMode: SelectionMode;
dir: Direction;
/** `undefined` = nobody asserted a direction. See `resolvedDir`. */
dir: Direction | undefined;
expandOnClick: boolean;
typeahead: boolean;
ariaLabel: string | undefined;
@ -33,6 +34,15 @@ interface TreeViewOpts
}> {}
export class TreeViewProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
readonly opts: TreeViewOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -234,7 +244,7 @@ export class TreeViewProvider {
const activeEl = this.soma.dom.activeElement(this.opts.ref.current) as HTMLElement | null;
const currentIndex = activeEl ? items.indexOf(activeEl) : -1;
const dir = this.opts.dir.current;
const dir = this.resolvedDir;
const { nextKey: expandKey, prevKey: collapseKey } = getDirectionalKeys(dir, 'horizontal');
switch (e.key) {
@ -417,7 +427,10 @@ export class TreeViewBranchControlProvider {
// Pass the branch element so the commit-select event stamps on
// the actual branch (the click landed on the control, but the
// semantic target is the branch — same value).
this.provider.select(this.branch.opts.value.current, this.branch.opts.ref?.current ?? undefined);
this.provider.select(
this.branch.opts.value.current,
this.branch.opts.ref?.current ?? undefined
);
};
readonly props = $derived.by(() =>

@ -1,11 +1,9 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { activeDir } from '../../../direction';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import {
YearGridProvider,
resolveInitialYearPlaceholder
} from '../year-grid-provider.svelte';
import { YearGridProvider, resolveInitialYearPlaceholder } from '../year-grid-provider.svelte';
import type { YearGridProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
@ -57,7 +55,7 @@
maxValue: readableActive(() => maxValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
dir: activeDir(() => dir, soma),
locale: readableActive(() => locale ?? ''),
decadeSize: readableActive(() => decadeSize),
pageAligned: readableActive(() => pageAligned),

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { boolToEmptyStrOrUndef } from '$adom';
import { state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
@ -43,7 +44,7 @@ export interface YearGridOpts
maxValue: YearValue | undefined;
disabled: boolean;
readonly: boolean;
dir: 'ltr' | 'rtl';
dir: Direction | undefined;
locale: string;
decadeSize: number;
/**
@ -60,6 +61,15 @@ export interface YearGridOpts
}
export class YearGridProvider {
/**
* Direction for this component's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The DOM attribute is the other half: `props` stamps `opts.dir.current`
* raw, so it is omitted when nobody asserted a direction and the element
* inherits instead of forcing `ltr` onto an RTL page.
*/
readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr');
readonly opts: YearGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -109,7 +119,7 @@ export class YearGridProvider {
const placeholder = this.opts.placeholder.current.year;
if (this.opts.pageAligned.current === false) return placeholder;
const size = this.decadeSize;
return placeholder - ((placeholder % size + size) % size);
return placeholder - (((placeholder % size) + size) % size);
});
readonly pageEnd = $derived.by(() => this.pageStart + this.decadeSize - 1);
@ -124,9 +134,7 @@ export class YearGridProvider {
readonly fullLabel = $derived.by(() => {
const base =
this.opts.ariaLabel.current ??
this.soma.langs.ts(YEAR_GRID_LANGS.LABEL) ??
'Year grid';
this.opts.ariaLabel.current ?? this.soma.langs.ts(YEAR_GRID_LANGS.LABEL) ?? 'Year grid';
return `${base}, ${this.headingValue}`;
});
@ -156,7 +164,10 @@ export class YearGridProvider {
private setPlaceholder(next: YearPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next;
void this.runtime.trigger('shift-navigate-step', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(
'shift-navigate-step',
target ? { fallbackTarget: target } : undefined
);
}
prevPage(target?: HTMLElement): void {
@ -182,7 +193,7 @@ export class YearGridProvider {
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>, year: number): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
this.resolvedDir,
'horizontal'
);
@ -424,8 +435,7 @@ export class YearGridGridProvider {
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface YearGridCellOpts
extends WithRefOpts, ActiveProps<{ year: number }> {}
export interface YearGridCellOpts extends WithRefOpts, ActiveProps<{ year: number }> {}
export class YearGridCellProvider {
readonly opts: YearGridCellOpts;

@ -0,0 +1,43 @@
import { readableActive, type Active } from '$libs/reactive';
import type { Soma } from './core/soma.svelte';
import type { Direction } from './types';
/**
* The ONE way a component resolves its text direction.
*
* Before this existed the catalogue had ten spellings of these three lines —
* some with a `?? 'ltr'` tail, some with an `as 'ltr' | 'rtl'` cast, some
* resolving in the wrapper and some inside the provider, and two menu
* components quietly carrying an extra inheritance step nobody could see. A
* framework that sells homogeneity cannot afford ten shapes for its most
* cross-cutting axis.
*
* ## Why the result can be `undefined`
*
* `undefined` means **nobody asserted a direction** — neither the consumer via
* the prop nor the app via a registered `direction` preference. That is NOT
* the same as `'ltr'`, and collapsing the two is the bug this signature
* exists to prevent:
*
* - A component's own math (arrow keys, pointer sign, placement flip) wants a
* concrete value. Providers default it once, in `resolvedDir`.
* - The `dir` ATTRIBUTE a component writes to the DOM must only appear when
* someone asserted something. Stamping a defaulted `'ltr'` is what makes a
* component force its subtree back to LTR inside an `<html dir="rtl">` page.
* An omitted attribute inherits — for free, with no DOM read.
*
* ## Extra steps are declared, not buried
*
* A component that legitimately consults something else first passes it in the
* getter, where it is visible at the call site:
*
* ```ts
* dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma)
* ```
*/
export function activeDir(
dir: () => Direction | undefined,
soma: Soma | undefined
): Active<Direction | undefined> {
return readableActive(() => dir() ?? soma?.prefs.getDir());
}

@ -75,7 +75,14 @@ export interface FloatingContentOpts extends ActiveProps<{
updatePositionStrategy: 'optimized' | 'always';
strategy: 'fixed' | 'absolute';
onPlaced: () => void;
dir: Direction;
/**
* `undefined` = nobody asserted a direction. Kept distinct from `'ltr'`
* because this layer both USES the value (placement flip) and STAMPS it on
* the floating wrapper. A portalled wrapper that omits the attribute
* inherits from the portal host — which is where the prefs projection
* lives — instead of forcing `ltr` onto an RTL page.
*/
dir: Direction | undefined;
style: StyleProperties | null | undefined | string;
enabled: boolean;
customAnchor: string | HTMLElement | null | Measurable;

@ -1,7 +1,11 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
import {
createActivePrefsDomProjection,
readActivePrefsSlot,
standardPrefsDimensions
} from '$prefs';
import { page } from '$app/state';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
@ -93,13 +97,17 @@
let { children } = $props();
type Language = 'es' | 'en' | 'fr' | 'de';
type Language = 'es' | 'en' | 'fr' | 'de' | 'ar';
type Currency = 'EUR' | 'USD' | 'GBP' | 'JPY';
type UnitSystem = 'metric' | 'imperial';
type NumberFormat = 'es-ES' | 'en-US' | 'en-GB' | 'de-DE';
const docsPrefsSchema = standardPrefsDimensions({
languages: ['es', 'en', 'fr', 'de'] satisfies readonly Language[],
// `ar` is here to make the language→direction derivation exercisable:
// picking it flips `prefs.direction` to rtl with no direction intent set.
// Untranslated keys fall back to the default locale (engine-langs.ts:51-56),
// so it degrades to Spanish copy rather than breaking.
languages: ['es', 'en', 'fr', 'de', 'ar'] satisfies readonly Language[],
locales: ['es-ES', 'en-US', 'en-GB', 'de-DE'] satisfies readonly NumberFormat[],
currencies: ['EUR', 'USD', 'GBP', 'JPY'] satisfies readonly Currency[],
defaults: {
@ -459,7 +467,7 @@
{ slug: '/uix/components/float', label: 'Float' },
{ slug: '/uix/components/scroll-area', label: 'ScrollArea' },
{ slug: '/uix/components/separator', label: 'Separator' },
{ slug: '/uix/components/splitter', label: 'Splitter' } ,
{ slug: '/uix/components/splitter', label: 'Splitter' },
{ slug: '/uix/components/sticky', label: 'Sticky' }
]
}
@ -553,7 +561,16 @@
// engine's channels once those expose runtime setEnabled APIs; today
// they store preference + reflect it in the UI.
let dir = $state<'ltr' | 'rtl'>('ltr');
// Three states, not two. `auto` CLEARS the direction intent so the built-in
// `directionDimension` can derive it from `language` — the chain the
// architecture documents ("if the user sets no intent, it derives from
// prefs.language"). With a two-state toggle the shell set an intent from the
// first frame, which permanently masked that derivation: picking Arabic
// would have changed nothing.
let dirMode = $state<'auto' | 'ltr' | 'rtl'>('auto');
/** Effective direction after prefs resolves intent → derivation → default. */
const dirSlot = readActivePrefsSlot<'ltr' | 'rtl'>(uix.prefs, 'direction');
const effectiveDir = $derived(dirSlot?.get() ?? 'ltr');
let language = $state<Language>('es');
let currency = $state<Currency>('EUR');
let units = $state<UnitSystem>('metric');
@ -580,7 +597,8 @@
es: 'Español',
en: 'English',
fr: 'Français',
de: 'Deutsch'
de: 'Deutsch',
ar: 'العربية'
};
const CURRENCY_LABELS: Record<Currency, string> = {
EUR: 'Euro',
@ -603,7 +621,7 @@
if (!stored) return;
try {
const p = JSON.parse(stored);
if (p.dir === 'ltr' || p.dir === 'rtl') dir = p.dir;
if (p.dir === 'auto' || p.dir === 'ltr' || p.dir === 'rtl') dirMode = p.dir;
if (p.language) language = p.language;
if (p.currency) currency = p.currency;
if (p.units) units = p.units;
@ -623,7 +641,7 @@
$effect(() => {
if (typeof localStorage === 'undefined') return;
const blob = {
dir,
dir: dirMode,
language,
currency,
units,
@ -640,7 +658,10 @@
$effect(() => {
uix.prefs.setIntent('language', language);
uix.prefs.setIntent('direction', dir);
// `auto` must CLEAR, not set: intent beats derivation, so setting any
// value here would stop `direction` from following `language`.
if (dirMode === 'auto') uix.prefs.clearIntent('direction');
else uix.prefs.setIntent('direction', dirMode);
uix.prefs.setIntent('locale', numberFormat);
uix.prefs.setIntent('currency', currency);
uix.prefs.setIntent('unitSystem', units);
@ -724,15 +745,34 @@
<!-- DIR: simple toggle. RTL flips the entire document direction
so all eidos components (which use logical properties)
reverse correctly. -->
<!-- Instrumented for `perm:check`: this is the ONLY control in the
shell that commits a preference, so without it the permutation
runner exercises component state transitions but never a prefs
commit — and the reactive path from `uix.prefs` into the ~64
components that resolve `dir` from it stays untested.
Index 90 so it runs AFTER each demo's own steps (0..N).
Two consequences a future reader should know: the runner shares
one browser context across routes, so this flip persists via
localStorage and routes alternate ltr/rtl through a run; and
because the scan is page-wide, EVERY route under this layout now
carries a step, so the runner no longer skips uninstrumented
demos. Both are deliberate — morfo contracts are direction
agnostic, and a prefs commit on every route is the coverage
this instrumentation exists for. -->
<button
type="button"
data-uix-icon-btn
title={dir === 'ltr' ? 'Switch to RTL' : 'Switch to LTR'}
data-perm-step="90"
data-perm-label="prefs: flip direction"
data-perm-settle="600"
title="Direction: {dirMode}{dirMode === 'auto'
? ` (from language → ${effectiveDir})`
: ''} — click to cycle auto → ltr → rtl"
aria-label="Toggle direction"
aria-pressed={dir === 'rtl'}
onclick={() => (dir = dir === 'ltr' ? 'rtl' : 'ltr')}
onclick={() =>
(dirMode = dirMode === 'auto' ? 'ltr' : dirMode === 'ltr' ? 'rtl' : 'auto')}
>
{#if dir === 'ltr'}
{#if effectiveDir === 'ltr'}
<svg
viewBox="0 0 24 24"
fill="none"

@ -52,7 +52,12 @@
// direction. `locale` is forwarded for downstream lang-aware behavior
// (announcer text, future label formatting).
type Direction = 'ltr' | 'rtl';
let dir = $state<Direction>('ltr');
// `auto` passes no `dir` prop at all, so the component resolves the
// documented chain `prop -> soma.prefs.getDir() -> 'ltr'` and follows the
// shell's direction switch. That is what a real consumer gets, so it is
// the default here; picking ltr/rtl shows the prop overriding prefs.
let dirMode = $state<'auto' | Direction>('auto');
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
let locale = $state('en-US');
let size = $state<CarouselSize>('md');
@ -104,7 +109,11 @@
}
const slides = [
{ tone: 'var(--color-primary-element)', label: 'Aurora · primary element', sub: 'slide 1 of 4' },
{
tone: 'var(--color-primary-element)',
label: 'Aurora · primary element',
sub: 'slide 1 of 4'
},
{ tone: 'var(--color-affirm-element)', label: 'Meadow · affirm element', sub: 'slide 2 of 4' },
{ tone: 'var(--color-risk-element)', label: 'Ember · risk element', sub: 'slide 3 of 4' },
{ tone: 'var(--color-fulfill-element)', label: 'Tide · fulfill element', sub: 'slide 4 of 4' }
@ -137,7 +146,7 @@
autoplay && stopOnMouseEnter && ' stopOnMouseEnter',
autoplay && stopOnLastSnap && ' stopOnLastSnap',
disabled && ' disabled',
dir !== 'ltr' && ` dir="${dir}"`,
dirMode !== 'auto' && ` dir="${dir}"`,
locale !== 'en-US' && ` locale="${locale}"`,
'>',
' {#snippet children({ api, isPlaying })}',
@ -176,7 +185,9 @@
slidesToScroll !== 1 && ` slidesToScroll={${slidesToScroll}}`,
align !== 'start' && ` align="${align}"`,
gap !== 0 && ` gap="${gap}rem"`,
orientation === 'vertical' && verticalBlockSize !== 16 && ` verticalBlockSize="${verticalBlockSize}rem"`,
orientation === 'vertical' &&
verticalBlockSize !== 16 &&
` verticalBlockSize="${verticalBlockSize}rem"`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
@ -192,7 +203,7 @@
autoplay && stopOnMouseEnter && ' stopOnMouseEnter',
autoplay && stopOnLastSnap && ' stopOnLastSnap',
disabled && ' disabled',
dir && ` dir="${dir}"`,
dirMode !== 'auto' && ` dir="${dir}"`,
!showIndicators && ' showIndicators={false}',
!showControls && ' showControls={false}',
'>',
@ -222,11 +233,10 @@
<div data-uix-eyebrow>Media · Carousel</div>
<h1 data-uix-page-title>Carousel</h1>
<p data-uix-page-lede>
Horizontally or vertically scrollable rail of slides with keyboard,
button, indicator, drag and autoplay navigation. One slide per page.
Pattern follows the WAI-ARIA carousel
pattern with <code>role="region"</code> + <code>aria-roledescription="carousel"</code>;
indicators map 1:1 with slides as a <code>tablist</code>.
Horizontally or vertically scrollable rail of slides with keyboard, button, indicator, drag
and autoplay navigation. One slide per page. Pattern follows the WAI-ARIA carousel pattern
with <code>role="region"</code> + <code>aria-roledescription="carousel"</code>; indicators map
1:1 with slides as a <code>tablist</code>.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
@ -243,13 +253,18 @@
<a href={carouselMorfo.apg} target="_blank" rel="noreferrer">carousel ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>index</span> {index}
<span data-uix-meta-key>index</span>
{index}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<div
data-uix-stage-area
bind:this={stageRef}
style="padding: var(--uix-space-3); display: flex; justify-content: center;"
>
<div style="inline-size: min(40rem, 100%);">
<Carousel
bind:index
@ -280,7 +295,7 @@
{showControls}
>
{#snippet children(snippet: CarouselProviderSnippetProps)}
{(api = snippet.api, isPlaying = snippet.isPlaying, '')}
{((api = snippet.api), (isPlaying = snippet.isPlaying), '')}
<Carousel.Viewport>
<Carousel.ItemGroup>
{#each slides as slide, i (slide.label)}
@ -299,8 +314,14 @@
user-select: none;
"
>
<strong style="font-size: var(--font-size-lg); margin-block-end: var(--space-2);">{slide.label}</strong>
<span style="font-size: var(--font-size-sm); color: var(--color-content-secondary);">{slide.sub}</span>
<strong
style="font-size: var(--font-size-lg); margin-block-end: var(--space-2);"
>{slide.label}</strong
>
<span
style="font-size: var(--font-size-sm); color: var(--color-content-secondary);"
>{slide.sub}</span
>
</div>
</Carousel.Item>
{/each}
@ -329,12 +350,17 @@
<span>arrow keys · drag · click indicators</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>index</span> {index}
<span data-uix-stage-trace-key>index</span>
{index}
</span>
</div>
</div>
@ -352,7 +378,9 @@
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -360,10 +388,10 @@
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Soma owns the index, autoplay timer, drag gesture, keyboard nav
and orientation. Eidos adds chrome (variant / size / color) and
renders the prev/next triggers as eidos <code>&lt;Button iconOnly variant="ghost"&gt;</code> via
the canonical Button consumer pattern.
Soma owns the index, autoplay timer, drag gesture, keyboard nav and orientation. Eidos adds
chrome (variant / size / color) and renders the prev/next triggers as eidos <code
>&lt;Button iconOnly variant="ghost"&gt;</code
> via the canonical Button consumer pattern.
</p>
<div data-uix-subsection-head>
@ -374,7 +402,11 @@
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['horizontal', 'vertical'] as o (o)}
<button data-uix-chip data-active={orientation === o} onclick={() => (orientation = o as 'horizontal' | 'vertical')}>{o}</button>
<button
data-uix-chip
data-active={orientation === o}
onclick={() => (orientation = o as 'horizontal' | 'vertical')}>{o}</button
>
{/each}
</span>
</label>
@ -382,7 +414,11 @@
<span data-uix-control-label>slidesPerView</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2, 3, 4] as n (n)}
<button data-uix-chip data-active={slidesPerView === n} onclick={() => (slidesPerView = n)}>{n}</button>
<button
data-uix-chip
data-active={slidesPerView === n}
onclick={() => (slidesPerView = n)}>{n}</button
>
{/each}
</span>
</label>
@ -390,7 +426,11 @@
<span data-uix-control-label>slidesToScroll</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2, 3] as n (n)}
<button data-uix-chip data-active={slidesToScroll === n} onclick={() => (slidesToScroll = n)}>{n}</button>
<button
data-uix-chip
data-active={slidesToScroll === n}
onclick={() => (slidesToScroll = n)}>{n}</button
>
{/each}
</span>
</label>
@ -398,7 +438,11 @@
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each ['start', 'center', 'end'] as a (a)}
<button data-uix-chip data-active={align === a} onclick={() => (align = a as CarouselAlign)}>{a}</button>
<button
data-uix-chip
data-active={align === a}
onclick={() => (align = a as CarouselAlign)}>{a}</button
>
{/each}
</span>
</label>
@ -409,7 +453,14 @@
</label>
<label data-uix-control>
<span data-uix-control-label>verticalBlockSize (rem)</span>
<input type="range" min="8" max="32" step="1" bind:value={verticalBlockSize} disabled={orientation !== 'vertical'} />
<input
type="range"
min="8"
max="32"
step="1"
bind:value={verticalBlockSize}
disabled={orientation !== 'vertical'}
/>
<span data-uix-control-label-aux>{verticalBlockSize}</span>
</label>
<label data-uix-control>
@ -441,7 +492,14 @@
</label>
<label data-uix-control>
<span data-uix-control-label>dragThreshold (0–1)</span>
<input type="range" min="0.05" max="0.6" step="0.05" bind:value={dragThreshold} disabled={!dragEnabled} />
<input
type="range"
min="0.05"
max="0.6"
step="0.05"
bind:value={dragThreshold}
disabled={!dragEnabled}
/>
<span data-uix-control-label-aux>{dragThreshold.toFixed(2)}</span>
</label>
</div>
@ -459,7 +517,14 @@
</label>
<label data-uix-control>
<span data-uix-control-label>autoplayDelay (ms)</span>
<input type="range" min="1000" max="8000" step="500" bind:value={autoplayDelay} disabled={!autoplay} />
<input
type="range"
min="1000"
max="8000"
step="500"
bind:value={autoplayDelay}
disabled={!autoplay}
/>
<span data-uix-control-label-aux>{autoplayDelay}</span>
</label>
<label data-uix-control>
@ -508,7 +573,9 @@
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> imperative API
<span style="margin-inline-start: auto; color: var(--color-content-secondary); font-size: var(--font-size-xs);">
<span
style="margin-inline-start: auto; color: var(--color-content-secondary); font-size: var(--font-size-xs);"
>
exposed via the <code>api</code> snippet prop — drive the carousel from external buttons
</span>
</div>
@ -516,9 +583,15 @@
<button data-uix-chip onclick={() => api?.scrollPrev()}>scrollPrev()</button>
<button data-uix-chip onclick={() => api?.scrollNext()}>scrollNext()</button>
<button data-uix-chip onclick={() => api?.scrollTo(0)}>scrollTo(0)</button>
<button data-uix-chip onclick={() => api?.scrollTo(slides.length - 1)}>scrollTo(last)</button>
<button data-uix-chip onclick={() => api?.play()} disabled={!autoplay || isPlaying}>play()</button>
<button data-uix-chip onclick={() => api?.pause()} disabled={!autoplay || !isPlaying}>pause()</button>
<button data-uix-chip onclick={() => api?.scrollTo(slides.length - 1)}
>scrollTo(last)</button
>
<button data-uix-chip onclick={() => api?.play()} disabled={!autoplay || isPlaying}
>play()</button
>
<button data-uix-chip onclick={() => api?.pause()} disabled={!autoplay || !isPlaying}
>pause()</button
>
<button data-uix-chip onclick={() => api?.reset()} disabled={!autoplay}>reset()</button>
<span data-uix-control-label-aux>isPlaying = {isPlaying}</span>
</div>
@ -536,10 +609,17 @@
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-control-label
>direction <span data-uix-control-hint>auto = no prop, follows uix.prefs.direction</span
></span
>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as value (value)}
<button data-uix-chip data-active={dir === value} onclick={() => (dir = value as Direction)}>{value}</button>
{#each ['auto', 'ltr', 'rtl'] as value (value)}
<button
data-uix-chip
data-active={dirMode === value}
onclick={() => (dirMode = value as typeof dirMode)}>{value}</button
>
{/each}
</span>
</label>
@ -553,7 +633,11 @@
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as CarouselVariant)}>{v}</button>
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v as CarouselVariant)}>{v}</button
>
{/each}
</span>
</label>
@ -561,7 +645,11 @@
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as CarouselSize)}>{s}</button>
<button
data-uix-chip
data-active={size === s}
onclick={() => (size = s as CarouselSize)}>{s}</button
>
{/each}
</span>
</label>
@ -569,7 +657,11 @@
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as CarouselColor)}>{c}</button>
<button
data-uix-chip
data-active={color === c}
onclick={() => (color = c as CarouselColor)}>{c}</button
>
{/each}
</span>
</label>
@ -612,48 +704,153 @@
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>8 parts: Provider, Viewport, ItemGroup, Item, PrevTrigger, NextTrigger, IndicatorGroup, Indicator.</p>
<p data-uix-section-desc>
8 parts: Provider, Viewport, ItemGroup, Item, PrevTrigger, NextTrigger, IndicatorGroup,
Indicator.
</p>
<div data-uix-subsection-head>Provider (Carousel)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">index <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">number</td><td class="default">0</td><td>Current slide index (0-based). Pass without bind: for the initial index.</td></tr>
<tr><td class="name">onIndexChange</td><td class="type">(i: number) =&gt; void</td><td class="default empty">—</td><td>Called on every index change.</td></tr>
<tr><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td class="default">'horizontal'</td><td>Scroll axis.</td></tr>
<tr><td class="name">slidesPerView</td><td class="type">number</td><td class="default">1</td><td>Slides visible at once (each Item basis = 100% / slidesPerView).</td></tr>
<tr><td class="name">slidesToScroll</td><td class="type">number</td><td class="default">1</td><td>Slides advanced per next() / prev().</td></tr>
<tr><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td class="default">'start'</td><td>Alignment of active slide(s) within viewport.</td></tr>
<tr><td class="name">gap</td><td class="type">string (CSS length)</td><td class="default">'0px'</td><td>Inline-axis gap between slides.</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>Wrap at edges (also wraps autoplay).</td></tr>
<tr><td class="name">dragEnabled</td><td class="type">boolean</td><td class="default">true</td><td>Allow pointer drag to scrub.</td></tr>
<tr><td class="name">dragThreshold</td><td class="type">number (0–1)</td><td class="default">0.25</td><td>Release fraction past which next slide commits.</td></tr>
<tr><td class="name">autoplay</td><td class="type">boolean</td><td class="default">false</td><td>Enable timer-driven advance.</td></tr>
<tr><td class="name">autoplayDelay</td><td class="type">number (ms)</td><td class="default">4000</td><td>Interval in milliseconds.</td></tr>
<tr><td class="name">playOnInit</td><td class="type">boolean</td><td class="default">true</td><td>Start the autoplay timer on mount.</td></tr>
<tr><td class="name">pauseOnHover</td><td class="type">boolean</td><td class="default">true</td><td>Pause autoplay while hovered.</td></tr>
<tr><td class="name">stopOnInteraction</td><td class="type">boolean</td><td class="default">false</td><td>Latch-stop after user clicks prev/next/indicator or drags.</td></tr>
<tr><td class="name">stopOnFocusIn</td><td class="type">boolean</td><td class="default">true</td><td>Pause while focus is inside root.</td></tr>
<tr><td class="name">stopOnMouseEnter</td><td class="type">boolean</td><td class="default">false</td><td>Pause while cursor over root (Embla parity).</td></tr>
<tr><td class="name">stopOnLastSnap</td><td class="type">boolean</td><td class="default">false</td><td>Latch-stop when last snap reached (loop=false only).</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable all navigation.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name (falls back to lang ref).</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Container chrome.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Cascades to indicator + trigger size.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Indicator + focus accent.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Stretch root inline-size to container.</td></tr>
<tr><td class="name">verticalBlockSize <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string (CSS length)</td><td class="default">'16rem'</td><td>Viewport block-size in vertical orientation.</td></tr>
<tr
><td class="name">index <span data-uix-tag data-kind="bindable">bindable</span></td
><td class="type">number</td><td class="default">0</td><td
>Current slide index (0-based). Pass without bind: for the initial index.</td
></tr
>
<tr
><td class="name">onIndexChange</td><td class="type">(i: number) =&gt; void</td><td
class="default empty">—</td
><td>Called on every index change.</td></tr
>
<tr
><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td
class="default">'horizontal'</td
><td>Scroll axis.</td></tr
>
<tr
><td class="name">slidesPerView</td><td class="type">number</td><td class="default"
>1</td
><td>Slides visible at once (each Item basis = 100% / slidesPerView).</td></tr
>
<tr
><td class="name">slidesToScroll</td><td class="type">number</td><td class="default"
>1</td
><td>Slides advanced per next() / prev().</td></tr
>
<tr
><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td
class="default">'start'</td
><td>Alignment of active slide(s) within viewport.</td></tr
>
<tr
><td class="name">gap</td><td class="type">string (CSS length)</td><td class="default"
>'0px'</td
><td>Inline-axis gap between slides.</td></tr
>
<tr
><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td
><td>Wrap at edges (also wraps autoplay).</td></tr
>
<tr
><td class="name">dragEnabled</td><td class="type">boolean</td><td class="default"
>true</td
><td>Allow pointer drag to scrub.</td></tr
>
<tr
><td class="name">dragThreshold</td><td class="type">number (0–1)</td><td
class="default">0.25</td
><td>Release fraction past which next slide commits.</td></tr
>
<tr
><td class="name">autoplay</td><td class="type">boolean</td><td class="default"
>false</td
><td>Enable timer-driven advance.</td></tr
>
<tr
><td class="name">autoplayDelay</td><td class="type">number (ms)</td><td
class="default">4000</td
><td>Interval in milliseconds.</td></tr
>
<tr
><td class="name">playOnInit</td><td class="type">boolean</td><td class="default"
>true</td
><td>Start the autoplay timer on mount.</td></tr
>
<tr
><td class="name">pauseOnHover</td><td class="type">boolean</td><td class="default"
>true</td
><td>Pause autoplay while hovered.</td></tr
>
<tr
><td class="name">stopOnInteraction</td><td class="type">boolean</td><td
class="default">false</td
><td>Latch-stop after user clicks prev/next/indicator or drags.</td></tr
>
<tr
><td class="name">stopOnFocusIn</td><td class="type">boolean</td><td class="default"
>true</td
><td>Pause while focus is inside root.</td></tr
>
<tr
><td class="name">stopOnMouseEnter</td><td class="type">boolean</td><td
class="default">false</td
><td>Pause while cursor over root (Embla parity).</td></tr
>
<tr
><td class="name">stopOnLastSnap</td><td class="type">boolean</td><td class="default"
>false</td
><td>Latch-stop when last snap reached (loop=false only).</td></tr
>
<tr
><td class="name">disabled</td><td class="type">boolean</td><td class="default"
>false</td
><td>Disable all navigation.</td></tr
>
<tr
><td class="name">aria-label</td><td class="type">string</td><td class="default empty"
>—</td
><td>Override accessible name (falls back to lang ref).</td></tr
>
<tr
><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td
><td class="default">'surface'</td><td>Container chrome.</td></tr
>
<tr
><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td
><td class="default">'md'</td><td>Cascades to indicator + trigger size.</td></tr
>
<tr
><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ColorRole</td
><td class="default">'primary'</td><td>Indicator + focus accent.</td></tr
>
<tr
><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">boolean</td
><td class="default">true</td><td>Stretch root inline-size to container.</td></tr
>
<tr
><td class="name"
>verticalBlockSize <span data-uix-tag data-kind="eidos">eidos</span></td
><td class="type">string (CSS length)</td><td class="default">'16rem'</td><td
>Viewport block-size in vertical orientation.</td
></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Viewport · ItemGroup</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>Viewport</code> is the <code>overflow: hidden</code> clipping
box. <code>ItemGroup</code> is the translatable strip whose
<code>transform</code> the provider drives. Both are pass-through —
no eidos-specific props.
<code>Viewport</code> is the <code>overflow: hidden</code> clipping box.
<code>ItemGroup</code>
is the translatable strip whose
<code>transform</code> the provider drives. Both are pass-through — no eidos-specific props.
</p>
<div data-uix-subsection-head>Item</div>
@ -661,8 +858,20 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">index</td><td class="type">number</td><td class="default empty">required</td><td>0-based slide index. Used for <code>aria-roledescription</code> + indicator wiring.</td></tr>
<tr><td class="name">children</td><td class="type">Snippet&lt;[{`{ index, isActive }`}]&gt;</td><td class="default empty">—</td><td>Slide body. Receives active flag for per-slide effects.</td></tr>
<tr
><td class="name">index</td><td class="type">number</td><td class="default empty"
>required</td
><td
>0-based slide index. Used for <code>aria-roledescription</code> + indicator wiring.</td
></tr
>
<tr
><td class="name">children</td><td class="type"
>Snippet&lt;[{`{ index, isActive }`}]&gt;</td
><td class="default empty">—</td><td
>Slide body. Receives active flag for per-slide effects.</td
></tr
>
</tbody>
</table>
</div>
@ -672,14 +881,49 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td><td class="default">'ghost'</td><td>Button variant.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">cascades from Carousel</td><td>Button size override.</td></tr>
<tr><td class="name">rounded <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ButtonRounded</td><td class="default">'full'</td><td>Corner radius.</td></tr>
<tr><td class="name">intent <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Intent</td><td class="default empty">—</td><td>Evaluative intent.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default empty">—</td><td>Hierarchical color (under intent='neutral').</td></tr>
<tr><td class="name">iconOnly <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Square aspect, label sr-only.</td></tr>
<tr><td class="name">icon <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Snippet</td><td class="default">chevron</td><td>Override default arrow glyph.</td></tr>
<tr><td class="name">child <span data-uix-tag data-kind="soma">soma</span></td><td class="type">Snippet</td><td class="default empty">—</td><td>Bypass Button entirely; render custom element.</td></tr>
<tr
><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td
><td class="default">'ghost'</td><td>Button variant.</td></tr
>
<tr
><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td
><td class="default">cascades from Carousel</td><td>Button size override.</td></tr
>
<tr
><td class="name">rounded <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ButtonRounded</td
><td class="default">'full'</td><td>Corner radius.</td></tr
>
<tr
><td class="name">intent <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">Intent</td
><td class="default empty">—</td><td>Evaluative intent.</td></tr
>
<tr
><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ColorRole</td
><td class="default empty">—</td><td>Hierarchical color (under intent='neutral').</td
></tr
>
<tr
><td class="name">iconOnly <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">boolean</td
><td class="default">true</td><td>Square aspect, label sr-only.</td></tr
>
<tr
><td class="name">icon <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">Snippet</td
><td class="default">chevron</td><td>Override default arrow glyph.</td></tr
>
<tr
><td class="name">child <span data-uix-tag data-kind="soma">soma</span></td><td
class="type">Snippet</td
><td class="default empty">—</td><td
>Bypass Button entirely; render custom element.</td
></tr
>
</tbody>
</table>
</div>
@ -689,9 +933,22 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">index (Indicator)</td><td class="type">number</td><td class="default empty">required</td><td>0-based slide this indicator navigates to.</td></tr>
<tr><td class="name">aria-label (Indicator)</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name; falls back to "Go to slide {`{n}`}".</td></tr>
<tr><td class="name">children (Indicator)</td><td class="type">Snippet&lt;[{`{ index, isActive }`}]&gt;</td><td class="default empty">—</td><td>Custom indicator body (default is a dot).</td></tr>
<tr
><td class="name">index (Indicator)</td><td class="type">number</td><td
class="default empty">required</td
><td>0-based slide this indicator navigates to.</td></tr
>
<tr
><td class="name">aria-label (Indicator)</td><td class="type">string</td><td
class="default empty">—</td
><td>Override accessible name; falls back to "Go to slide {`{n}`}".</td></tr
>
<tr
><td class="name">children (Indicator)</td><td class="type"
>Snippet&lt;[{`{ index, isActive }`}]&gt;</td
><td class="default empty">—</td><td>Custom indicator body (default is a dot).</td
></tr
>
</tbody>
</table>
</div>
@ -712,8 +969,17 @@
<tbody>
<tr><td class="name">name</td><td class="type">"{carouselMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{carouselMorfo.kebab}"</td></tr>
<tr><td class="name">apg</td><td><a href={carouselMorfo.apg} target="_blank" rel="noreferrer">{carouselMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{carouselMorfo.parts.length}</td></tr>
<tr
><td class="name">apg</td><td
><a href={carouselMorfo.apg} target="_blank" rel="noreferrer">{carouselMorfo.apg}</a
></td
></tr
>
<tr
><td class="name">parts.length</td><td class="default"
>{carouselMorfo.parts.length}</td
></tr
>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
@ -722,7 +988,13 @@
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<thead
><tr
><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th
>States</th
><th>Optional</th></tr
></thead
>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
@ -742,12 +1014,23 @@
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<thead
><tr
><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th
>target</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
{@const intentStr =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}`
: '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
@ -771,7 +1054,12 @@
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<thead
><tr
><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
@ -786,7 +1074,8 @@
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-carousel]') ?? stageRef) as HTMLElement | null;
const target = (stageRef?.querySelector('[data-carousel]') ??
stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
@ -794,8 +1083,8 @@
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
}}>▶ play</button
>
</td>
</tr>
{/each}
@ -808,20 +1097,58 @@
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Selectors at <code>src/uix/eidos/components/carousel/carousel.css</code>.</p>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/carousel/carousel.css</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-carousel]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Root — flex column, gap, font.</td></tr>
<tr><td class="name">[data-carousel][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Surface / outline / ghost chrome around the rail.</td></tr>
<tr><td class="name">[data-carousel][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Indicator dot diameter + gap.</td></tr>
<tr><td class="name">[data-carousel][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Active-indicator accent + focus ring.</td></tr>
<tr><td class="name">[data-carousel-viewport]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Clipping box (overflow hidden, inline soma styles).</td></tr>
<tr><td class="name">[data-carousel-item-group][data-dragging]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Transform transition off while dragging.</td></tr>
<tr><td class="name">[data-carousel-item]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Slide — 100% basis along axis.</td></tr>
<tr><td class="name">[data-carousel-prev-trigger] · [data-carousel-next-trigger]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Absolute-positioned over viewport edges; vertical flips to top/bottom.</td></tr>
<tr><td class="name">[data-carousel-indicator][data-state='active']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Active indicator: accent fill + 2.25× inline-size stretch.</td></tr>
<tr
><td class="name">[data-carousel]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Root — flex column, gap, font.</td></tr
>
<tr
><td class="name">[data-carousel][data-variant]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Surface / outline / ghost chrome around the rail.</td></tr
>
<tr
><td class="name">[data-carousel][data-size]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Indicator dot diameter + gap.</td></tr
>
<tr
><td class="name">[data-carousel][data-color]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Active-indicator accent + focus ring.</td></tr
>
<tr
><td class="name">[data-carousel-viewport]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Clipping box (overflow hidden, inline soma styles).</td></tr
>
<tr
><td class="name">[data-carousel-item-group][data-dragging]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Transform transition off while dragging.</td></tr
>
<tr
><td class="name">[data-carousel-item]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Slide — 100% basis along axis.</td></tr
>
<tr
><td class="name">[data-carousel-prev-trigger] · [data-carousel-next-trigger]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Absolute-positioned over viewport edges; vertical flips to top/bottom.</td></tr
>
<tr
><td class="name">[data-carousel-indicator][data-state='active']</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Active indicator: accent fill + 2.25× inline-size stretch.</td></tr
>
</tbody>
</table>
</div>
@ -832,9 +1159,10 @@
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Follows the APG <a href={carouselMorfo.apg} target="_blank" rel="noreferrer">carousel pattern</a>.
The root is <code>role="region"</code> with <code>aria-roledescription="carousel"</code> +
an accessible name. Each slide is <code>role="group"</code> with
Follows the APG <a href={carouselMorfo.apg} target="_blank" rel="noreferrer"
>carousel pattern</a
>. The root is <code>role="region"</code> with <code>aria-roledescription="carousel"</code>
+ an accessible name. Each slide is <code>role="group"</code> with
<code>aria-roledescription="slide"</code>. Indicators form a
<code>role="tablist"</code> with <code>role="tab"</code> children.
</p>
@ -844,12 +1172,27 @@
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowDown</span></td><td>Next slide.</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowUp</span></td><td>Previous slide.</td></tr>
<tr
><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowDown</span></td
><td>Next slide.</td></tr
>
<tr
><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowUp</span></td><td
>Previous slide.</td
></tr
>
<tr><td><span data-uix-kbd>Home</span></td><td>First slide.</td></tr>
<tr><td><span data-uix-kbd>End</span></td><td>Last slide.</td></tr>
<tr><td><span data-uix-kbd>Tab</span></td><td>Move focus through prev / next / indicators / inner slide controls.</td></tr>
<tr><td>pointer drag</td><td>Scrub between slides; commits when past <code>dragThreshold</code>.</td></tr>
<tr
><td><span data-uix-kbd>Tab</span></td><td
>Move focus through prev / next / indicators / inner slide controls.</td
></tr
>
<tr
><td>pointer drag</td><td
>Scrub between slides; commits when past <code>dragThreshold</code>.</td
></tr
>
</tbody>
</table>
</div>
@ -860,13 +1203,36 @@
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"region"</td></tr>
<tr><td class="name">provider</td><td>aria-roledescription</td><td class="type">"carousel"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">localized "Carousel"</td></tr>
<tr
><td class="name">provider</td><td>aria-roledescription</td><td class="type"
>"carousel"</td
></tr
>
<tr
><td class="name">provider</td><td>aria-label</td><td class="type"
>localized "Carousel"</td
></tr
>
<tr><td class="name">item</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">item</td><td>aria-roledescription</td><td class="type">"slide"</td></tr>
<tr><td class="name">prev-trigger / next-trigger</td><td>type / aria-label</td><td class="type">"button" / "Previous slide" | "Next slide"</td></tr>
<tr><td class="name">indicator-group</td><td>role / aria-orientation</td><td class="type">"tablist" / orientation</td></tr>
<tr><td class="name">indicator</td><td>role / aria-selected</td><td class="type">"tab" / "true|false"</td></tr>
<tr
><td class="name">item</td><td>aria-roledescription</td><td class="type">"slide"</td
></tr
>
<tr
><td class="name">prev-trigger / next-trigger</td><td>type / aria-label</td><td
class="type">"button" / "Previous slide" | "Next slide"</td
></tr
>
<tr
><td class="name">indicator-group</td><td>role / aria-orientation</td><td class="type"
>"tablist" / orientation</td
></tr
>
<tr
><td class="name">indicator</td><td>role / aria-selected</td><td class="type"
>"tab" / "true|false"</td
></tr
>
</tbody>
</table>
</div>

@ -19,8 +19,12 @@
let step = $state(1);
let orientation = $state<Orientation>('horizontal');
let disabled = $state(false);
let dir = $state<Direction>('ltr');
let dirMode = $state<'auto' | Direction>('auto');
let size = $state<SliderSize>('md');
// `auto` passes no prop at all, so the Slider resolves the documented chain
// `prop -> soma.prefs.getDir() -> 'ltr'` and follows the shell's direction
// switch. Picking ltr/rtl shows the prop overriding prefs.
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
const compiled = compileMorfo(sliderMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
@ -151,7 +155,11 @@
<span>drag or commit a value to see sema events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
@ -261,6 +269,23 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>direction <span data-uix-control-hint>auto = no prop, follows uix.prefs.direction</span
></span
>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'ltr', 'rtl'] as d}
<button
data-uix-chip
data-active={dirMode === d}
data-perm-step={d === 'rtl' ? 0 : d === 'ltr' ? 1 : undefined}
data-perm-label="dir {d}"
onclick={() => (dirMode = d as typeof dirMode)}>{d}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch
@ -332,8 +357,11 @@
>
<tr
><td class="name">orientation / dir</td><td>soma</td><td class="default"
>horizontal / ltr</td
><td>Layout and keyboard direction</td></tr
>horizontal / prefs.getDir()</td
><td
>Layout and keyboard direction. Omitting <code>dir</code> resolves it from
<code>uix.prefs.direction</code>, then <code>ltr</code>.</td
></tr
>
<tr
><td class="name">thumbLabels</td><td>soma</td><td class="default">—</td><td

Loading…
Cancel
Save

Powered by TurnKey Linux.