From 013ceac57490c6634c1900bde26c9e935bfd56f3 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 2 Aug 2026 03:23:40 +0200 Subject: [PATCH] fix(direction): getDir() no era reactivo, y 33 componentes estampaban su valor congelado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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 --- src/arts/prefs/active-prefs.svelte.ts | 31 +- src/arts/prefs/dom-projection.ts | 24 +- .../active-prefs-reactivity.svelte.test.ts | 96 +++ src/uix/active-uix/prefs.ts | 22 +- .../active-uix/test/prefs-view.svelte.test.ts | 49 ++ .../eidos/components/accordion/accordion.css | 5 +- src/uix/eidos/components/slider/slider.css | 10 +- .../accordion/accordion-provider.svelte.ts | 14 +- .../accordion/components/accordion.svelte | 9 +- .../calendar/calendar-provider.svelte.ts | 14 +- .../calendar/components/calendar.svelte | 3 +- .../carousel/carousel-provider.svelte.ts | 19 +- .../carousel/components/carousel.svelte | 8 +- .../color-field-provider.svelte.ts | 13 +- .../color-field/components/color-field.svelte | 10 +- .../color-picker-provider.svelte.ts | 12 +- .../components/color-picker.svelte | 10 +- .../combobox/combobox-provider.svelte.ts | 23 +- .../components/combobox-content.svelte | 9 +- .../combobox/components/combobox.svelte | 3 +- .../command/command-provider.svelte.ts | 14 +- .../command/components/command.svelte | 3 +- .../components/context-menu-content.svelte | 8 +- .../context-menu-sub-content.svelte | 8 +- .../components/context-menu.svelte | 8 +- .../context-menu-provider.svelte.ts | 35 +- .../css-field/css-field-provider.svelte.ts | 1 + .../date-field/components/date-field.svelte | 17 +- .../date-field/date-field-provider.svelte.ts | 22 +- .../date-picker/components/date-picker.svelte | 5 +- .../date-picker-provider.svelte.ts | 11 +- .../components/date-range-field.svelte | 34 +- .../date-range-field-provider.svelte.ts | 11 +- .../components/date-range-picker.svelte | 5 +- .../date-range-picker-provider.svelte.ts | 11 +- .../drawer/drawer-provider.svelte.ts | 10 +- .../components/dropdown-menu-content.svelte | 11 +- .../dropdown-menu-sub-content.svelte | 21 +- .../components/dropdown-menu.svelte | 8 +- .../dropdown-menu-provider.svelte.ts | 35 +- .../components/emoji-picker.svelte | 3 +- .../emoji-picker-provider.svelte.ts | 9 + .../components/link-preview-content.svelte | 8 +- .../link-preview-provider.svelte.ts | 12 +- .../listbox/listbox-provider.svelte.ts | 24 +- .../menubar/components/menubar-panel.svelte | 5 +- .../menubar/components/menubar.svelte | 8 +- .../menubar/menubar-provider.svelte.ts | 26 +- .../month-grid/components/month-grid.svelte | 8 +- .../month-grid/month-grid-provider.svelte.ts | 26 +- .../natural-time-picker-provider.svelte.ts | 11 +- .../navigation-menu-provider.svelte.ts | 17 +- .../number-field-provider.svelte.ts | 1 + .../popover/components/popover-content.svelte | 9 +- .../popover/popover-provider.svelte.ts | 5 +- .../radio-group/components/radio-group.svelte | 8 +- .../radio-group-provider.svelte.ts | 22 +- .../components/range-calendar.svelte | 8 +- .../range-calendar-provider.svelte.ts | 24 +- .../rating-group-provider.svelte.ts | 13 +- .../scroll-area/components/scroll-area.svelte | 8 +- .../scroll-area-provider.svelte.ts | 11 +- .../select/components/select-content.svelte | 9 +- .../select/components/select.svelte | 3 +- .../select/select-provider.svelte.ts | 25 +- .../slider/components/slider.svelte | 3 +- .../slider/slider-provider.svelte.ts | 27 +- .../splitter/components/splitter.svelte | 9 +- .../splitter/splitter-provider.svelte.ts | 13 +- .../stepper/components/stepper.svelte | 9 +- .../stepper/stepper-provider.svelte.ts | 14 +- .../components/table/components/table.svelte | 8 +- .../components/table/table-provider.svelte.ts | 12 +- .../components/tabs/components/tabs.svelte | 9 +- .../components/tabs/tabs-provider.svelte.ts | 14 +- .../time-field/components/time-field.svelte | 17 +- .../time-field/time-field-provider.svelte.ts | 16 +- .../time-picker/components/time-picker.svelte | 5 +- .../time-picker-provider.svelte.ts | 11 +- .../components/time-range-field.svelte | 34 +- .../time-range-field-provider.svelte.ts | 11 +- .../components/time-range-picker.svelte | 34 +- .../time-range-picker-provider.svelte.ts | 11 +- .../components/toast/toast-provider.svelte.ts | 1 + .../components/toggle-group.svelte | 9 +- .../toggle-group-provider.svelte.ts | 14 +- .../toolbar/components/toolbar.svelte | 8 +- .../toolbar/toolbar-provider.svelte.ts | 14 +- .../tooltip/components/tooltip-content.svelte | 9 +- .../tooltip/tooltip-provider.svelte.ts | 11 +- .../tree-view/components/tree-view.svelte | 9 +- .../tree-view/tree-view-provider.svelte.ts | 19 +- .../year-grid/components/year-grid.svelte | 8 +- .../year-grid/year-grid-provider.svelte.ts | 28 +- src/uix/soma/direction.ts | 43 ++ .../soma/layers/floating/floating.svelte.ts | 9 +- web/routes/uix/+layout@.svelte | 66 +- .../uix/components/carousel/+page.svelte | 594 ++++++++++++++---- web/routes/uix/components/slider/+page.svelte | 36 +- 99 files changed, 1557 insertions(+), 543 deletions(-) create mode 100644 src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts create mode 100644 src/uix/active-uix/test/prefs-view.svelte.test.ts create mode 100644 src/uix/soma/direction.ts diff --git a/src/arts/prefs/active-prefs.svelte.ts b/src/arts/prefs/active-prefs.svelte.ts index 7ede07aed..8f0b5cba1 100644 --- a/src/arts/prefs/active-prefs.svelte.ts +++ b/src/arts/prefs/active-prefs.svelte.ts @@ -185,8 +185,30 @@ export function createActivePrefs( const pendingCell = $state(false); const lastErrorCell = $state(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>( + 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 = { @@ -215,7 +237,7 @@ export function createActivePrefs( const dimensionMembers: Record> = {}; 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( function makeDimension( engine: ReturnType, - key: string + key: string, + changeCounts: Record ): ActivePrefsDimension { 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)[key]; }, set(value: unknown) { diff --git a/src/arts/prefs/dom-projection.ts b/src/arts/prefs/dom-projection.ts index c78a12765..7245dde49 100644 --- a/src/arts/prefs/dom-projection.ts +++ b/src/arts/prefs/dom-projection.ts @@ -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 = {}; - 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; diff --git a/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts b/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts new file mode 100644 index 000000000..a5353bf4e --- /dev/null +++ b/src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts @@ -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..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(); + }); +}); diff --git a/src/uix/active-uix/prefs.ts b/src/uix/active-uix/prefs.ts index 4562db496..e5703293a 100644 --- a/src/uix/active-uix/prefs.ts +++ b/src/uix/active-uix/prefs.ts @@ -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 `` 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 ( prefs: unknown, key: string @@ -36,8 +48,8 @@ export function connectLangsToPrefs( export function createActiveUixPrefsView(prefs: ActivePrefs): ActiveUixPrefsView { return { - getDir(): Direction { - return readActiveUixPrefsSlot(prefs, 'direction')?.get() ?? DEFAULT_DIR; + getDir(): Direction | undefined { + return readActiveUixPrefsSlot(prefs, 'direction')?.get(); }, onPreferenceChange(handler: () => void): () => void { const detachers = [ diff --git a/src/uix/active-uix/test/prefs-view.svelte.test.ts b/src/uix/active-uix/test/prefs-view.svelte.test.ts new file mode 100644 index 000000000..deeaff33e --- /dev/null +++ b/src/uix/active-uix/test/prefs-view.svelte.test.ts @@ -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 `` + * 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(); + }); +}); diff --git a/src/uix/eidos/components/accordion/accordion.css b/src/uix/eidos/components/accordion/accordion.css index dbdcad1c2..1d75b9bd5 100644 --- a/src/uix/eidos/components/accordion/accordion.css +++ b/src/uix/eidos/components/accordion/accordion.css @@ -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), diff --git a/src/uix/eidos/components/slider/slider.css b/src/uix/eidos/components/slider/slider.css index 082a68561..fd7ba451d 100644 --- a/src/uix/eidos/components/slider/slider.css +++ b/src/uix/eidos/components/slider/slider.css @@ -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] { diff --git a/src/uix/soma/components/accordion/accordion-provider.svelte.ts b/src/uix/soma/components/accordion/accordion-provider.svelte.ts index e2fd50306..27d5dba3c 100644 --- a/src/uix/soma/components/accordion/accordion-provider.svelte.ts +++ b/src/uix/soma/components/accordion/accordion-provider.svelte.ts @@ -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(() => 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); diff --git a/src/uix/soma/components/accordion/components/accordion.svelte b/src/uix/soma/components/accordion/components/accordion.svelte index 56946fb5c..e65ae53cd 100644 --- a/src/uix/soma/components/accordion/components/accordion.svelte +++ b/src/uix/soma/components/accordion/components/accordion.svelte @@ -1,15 +1,12 @@ diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index 83d7f490c..e7db01a47 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -46,11 +46,20 @@ interface ContextMenuOpts ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ - dir: Direction; + dir: Direction | undefined; onOpenChangeComplete: OnChangeFn; }> {} 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; collisionPadding: number | Partial>; 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) { diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.ts index 88cf2631e..c42b0abd3 100644 --- a/src/uix/soma/components/css-field/css-field-provider.svelte.ts +++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts @@ -135,6 +135,7 @@ interface CssFieldOpts }> {} export class CssFieldProvider { + readonly opts: CssFieldOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; diff --git a/src/uix/soma/components/date-field/components/date-field.svelte b/src/uix/soma/components/date-field/components/date-field.svelte index 6cd8b111b..9e66f1366 100644 --- a/src/uix/soma/components/date-field/components/date-field.svelte +++ b/src/uix/soma/components/date-field/components/date-field.svelte @@ -1,20 +1,13 @@ diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index f095e6b57..4f0f32c4f 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -51,12 +51,21 @@ interface MenuOpts ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ - dir: Direction; + dir: Direction | undefined; accessibleWhenDisabled: boolean; onOpenChangeComplete: OnChangeFn; }> {} 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; collisionPadding: number | Partial>; 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) { diff --git a/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte b/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte index 40756cfb3..93244625c 100644 --- a/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte +++ b/src/uix/soma/components/emoji-picker/components/emoji-picker.svelte @@ -1,5 +1,6 @@