feat(direction): los cuatro que pintaban con :dir() no dejaban afirmarla

feed, nav-tree, sidebar y switch tienen reglas `:dir(rtl)` en su receta pero
no aceptaban `dir`. Funcionaban —`:dir()` lee la direccion HEREDADA, y la
proyeccion de prefs pone `dir` en el `<html>`— pero solo para la pagina
entera: no habia forma de voltear UNO. Ahora corren la cadena canonica
`prop → soma.prefs.getDir() → 'ltr'` con `activeDir` en el envoltorio, como
los otros 49.

Estampan el CRUDO (`opts.dir.current`, no `resolvedDir`) por la razon de
siempre: `:dir()` lee la direccion RESUELTA del elemento, asi que una
afirmacion que no llegue al DOM movería el contrato y dejaria la pintura
atras — el mismo partido-por-la-mitad que costo el arreglo de tree-grid y
float-panel.

`switch` no pudo ir por `OptsFromProps`: ese helper quita el `undefined` del
prop publico y da `Active<'ltr' | 'rtl'>`, cuando «nadie afirmo nada» es
justo el valor que el estampado crudo necesita distinguir. Va aparte, con
`ActiveProps<{ dir: Direction | undefined }>`, y en el envoltorio fuera del
`bindProps` (que toma getters, no Actives).

sidebar tenia ademas un resolutor propio en el motor flotante
(`soma.prefs?.getDir() ?? 'ltr'` a pelo, linea 662) que se saltaba el prop:
ahora consume `resolvedDir`. Ese si necesita valor concreto —el motor espeja
la colocacion del flyout— a diferencia de la receta, que lee `:dir()`.

Verificado: `check` sin errores nuevos en los cuatro (77 total = la base con
los ficheros de media-player, que son de otra sesion). Tests de feed y switch
en verde tras anadir `dir` a sus helpers de opts. En SSR los cuatro estampan
`dir="ltr"`; en Chrome el switch pasa a `dir="rtl"` al girar la preferencia y
la regla dispara —`--_switch-thumb-offset` va de `16px` a `calc(-1 * 16px)`.
La transicion del pulgar no se pudo medir: el panel del navegador estaba
oculto y las transiciones CSS quedan congeladas ahi.

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

@ -1,14 +1,14 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { FeedProvider } from '../feed-provider.svelte';
import type { FeedProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -18,6 +18,7 @@
onLoadMore = () => {},
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
dir,
children,
child,
...restProps
@ -33,7 +34,8 @@
busy: readableActive(() => busy),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),
onLoadMore: readableActive(() => onLoadMore)
onLoadMore: readableActive(() => onLoadMore),
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));

@ -55,6 +55,7 @@ function feedOpts(root = document.createElement('section')) {
return {
id: state('feed-root'),
ref: state<HTMLElement | null>(root),
dir: state<'ltr' | 'rtl' | undefined>(undefined),
totalItems: state<number | undefined>(10),
busy: state(true),
ariaLabel: state<string | undefined>(undefined),

@ -1,7 +1,7 @@
import { context, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { readableActive, state, type Active, type ActiveProps } from '$libs/reactive';
import type { SomaKeyboardEvent } from '../../types';
import type { SomaKeyboardEvent, Direction } from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FEED_LANGS } from './langs';
@ -22,6 +22,7 @@ interface FeedOpts
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onLoadMore: (() => void) | undefined;
dir: Direction | undefined;
}> {}
export class FeedProvider {
@ -148,6 +149,10 @@ export class FeedProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
// The recipe selects with `:dir(rtl)`, which reads the RESOLVED direction,
// so an asserted `dir` must reach the DOM or the paint would lag the prop.
// Raw, not `resolvedDir`: no assertion means no attribute (direction contract).
dir: this.opts.dir.current,
...this.runtimePart.props
} as const)
);

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '../../types';
import type { WithChild, Without, Direction } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
/** Snippet props for `Feed.Provider`. */
@ -32,6 +32,13 @@ export type FeedProps = WithChild<
/** DOM id. Auto-generated if omitted. */
id?: string;
/**
* Reading direction for this subtree. Canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/**
* Total number of items across all pages. Used for each article's
* `aria-setsize`. `undefined` means unknown (AT reads "n of unknown").
@ -59,7 +66,10 @@ export type FeedProps = WithChild<
},
FeedProviderSnippetProps
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string; 'aria-labelledby'?: string; 'aria-busy'?: unknown }>;
Without<
PrimitiveDivAttributes,
{ 'aria-label'?: string; 'aria-labelledby'?: string; 'aria-busy'?: unknown }
>;
// ── Article ────────────────────────────────────────────────────────────────

@ -9,11 +9,14 @@
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { NavTreeProvider } from '../nav-tree-provider.svelte';
import NavTreeNode from './nav-tree-node.svelte';
import type { NavTreeProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -22,6 +25,7 @@
activeHref,
'aria-label': ariaLabel,
badge,
dir,
child,
...restProps
}: NavTreeProps = $props();
@ -35,7 +39,8 @@
nodes: readableActive(() => nodes),
activeHref: readableActive(() => activeHref),
ariaLabel: readableActive(() => ariaLabel),
badge: readableActive(() => badge)
badge: readableActive(() => badge),
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, provider.providerProps));

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import { readableActive, writableActive, type Active, type ActiveProps } from '$libs/reactive';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { untrack, type Snippet } from 'svelte';
@ -23,6 +24,7 @@ interface NavTreeOpts
ariaLabel: string | undefined;
/** How a node's `badge` renders (the eidos passes the canonical `Badge`). */
badge: Snippet<[NavTreeBadgeSnippetProps]> | undefined;
dir: Direction | undefined;
}> {}
/** Processed tree + the lookup maps the selection/expansion logic needs. */
@ -130,7 +132,9 @@ export class NavTreeProvider {
// — a cycle the trail walk would follow). A collision is an authoring
// mistake, so the tree still renders: the duplicate gets a suffixed
// key of its own and the logger names it (accordion's precedent).
const key = parentByKey.has(declared) ? `${declared}#${parentKey ?? 'root'}/${i}` : declared;
const key = parentByKey.has(declared)
? `${declared}#${parentKey ?? 'root'}/${i}`
: declared;
if (key !== declared) {
this.soma.logger?.warn(
'soma',
@ -208,7 +212,13 @@ export class NavTreeProvider {
}
readonly providerProps = $derived.by(() =>
this.providerPart.assert({ ...this.providerPart.props } as const)
this.providerPart.assert({
...this.providerPart.props,
// The recipe selects with `:dir(rtl)`, which reads the RESOLVED direction,
// so an asserted `dir` must reach the DOM or the paint would lag the prop.
// Raw, not `resolvedDir`: no assertion means no attribute (direction contract).
dir: this.opts.dir.current
} as const)
);
readonly listProps = $derived.by(() => this.listPart.renderProps());
}

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { Direction } from '../../types';
import type { HTMLAttributes } from 'svelte/elements';
/**
@ -67,6 +68,12 @@ export interface NavTreeBadgeSnippetProps {
}
export type NavTreeProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/**
* Reading direction for this subtree. Canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/** Bindable ref to the `<nav>`. */
ref?: HTMLElement | null;
id?: string;

@ -8,10 +8,13 @@
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { SidebarProvider } from '../sidebar-provider.svelte';
import type { SidebarProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -25,6 +28,7 @@
collapsible,
side,
mobileBreakpoint,
dir,
children,
child,
...restProps
@ -43,7 +47,8 @@
onOpenChange: readableActive(() => onOpenChange),
collapsible: readableActive(() => collapsible),
side: readableActive(() => side),
mobileBreakpoint: readableActive(() => mobileBreakpoint)
mobileBreakpoint: readableActive(() => mobileBreakpoint),
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, provider.providerProps));

@ -1,4 +1,5 @@
import { context, type WithRefOpts } from '../../provider';
import type { Direction } from '../../types';
import {
readableActive,
state,
@ -38,6 +39,7 @@ interface SidebarOpts
collapsible: SidebarCollapsible | undefined;
side: SidebarSide | undefined;
mobileBreakpoint: Breakpoint | undefined;
dir: Direction | undefined;
}> {}
/**
@ -64,6 +66,10 @@ export class SidebarProvider {
readonly runtime: SomaRuntime;
readonly providerPart: SomaRuntimePart;
/** Final fallback of the direction chain — the flyout engine needs a concrete
* value to mirror its placement, unlike the recipe which reads `:dir()`. */
readonly resolvedDir = $derived.by<Direction>(() => this.opts.dir.current ?? 'ltr');
static readonly ctx = context<SidebarProvider>('Sidebar');
static get(): SidebarProvider | undefined {
return this.ctx.getOr(undefined) as SidebarProvider | undefined;
@ -161,9 +167,7 @@ export class SidebarProvider {
* only on the desktop presentation (on mobile the panel is a Drawer, which
* is either open or absent — there is no rail to collapse to).
*/
readonly iconMode = $derived.by(
() => this.collapsible === 'icon' && !this.open && !this.mobile
);
readonly iconMode = $derived.by(() => this.collapsible === 'icon' && !this.open && !this.mobile);
/** Landmark names — explicit props win, else the localized defaults. */
readonly resolvedLabel: Active<string | undefined> = readableActive(
@ -207,7 +211,13 @@ export class SidebarProvider {
}
readonly providerProps = $derived.by(() =>
this.providerPart.assert({ ...this.providerPart.renderProps() } as const)
this.providerPart.assert({
...this.providerPart.renderProps(),
// The recipe selects with `:dir(rtl)`, which reads the RESOLVED direction,
// so an asserted `dir` must reach the DOM or the paint would lag the prop.
// Raw, not `resolvedDir`: no assertion means no attribute (direction contract).
dir: this.opts.dir.current
} as const)
);
}
@ -659,7 +669,7 @@ export class SidebarMenuSubProvider {
updatePositionStrategy: readableActive(() => 'optimized'),
strategy: readableActive(() => 'fixed'),
onPlaced: readableActive(() => () => {}),
dir: readableActive(() => this.provider.soma.prefs?.getDir() ?? 'ltr'),
dir: readableActive(() => this.provider.resolvedDir),
style: readableActive(() => null),
// Engaged only while the sub is actually SHOWN as a flyout: gating
// on the mode alone kept `autoUpdate` (rAF + scroll/resize
@ -680,7 +690,7 @@ export class SidebarMenuSubProvider {
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
...(this.isFloating && this.floating ? this.floating.props : {}),
...(this.isFloating && this.floating ? this.floating.props : {})
// The morfo declares `Escape → close-sub` on this part, and the item
// handles it for the whole subtree (focus normally sits on the ROW, not
// here): runtime keyboard PLANS resolve their handler from the shared

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { Direction } from '../../types';
import type { HTMLAttributes, HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import type { Breakpoint } from '$adom';
@ -27,6 +28,12 @@ type Base = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
};
export type SidebarProps = Base & {
/**
* Reading direction for this subtree. Canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/**
* Expanded (bindable). Controlled when bound / paired with `onOpenChange`;
* otherwise the provider owns it from `defaultOpen`. This seam exists from

@ -2,10 +2,13 @@
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { SwitchProvider } from '../switch-provider.svelte';
import type { SwitchProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -24,12 +27,13 @@
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
dir,
child,
...restProps
}: SwitchProps = $props();
const state = SwitchProvider.create(
bindProps({
const state = SwitchProvider.create({
...(bindProps({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
checked: { get: () => checked, set: (v) => (checked = v) },
@ -45,8 +49,9 @@
value: () => value,
'aria-label': () => ariaLabel,
'aria-labelledby': () => ariaLabelledby
}) as Parameters<typeof SwitchProvider.create>[0]
);
}) as Omit<Parameters<typeof SwitchProvider.create>[0], 'dir'>),
dir: activeDir(() => dir, soma)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -48,6 +48,7 @@ function switchOpts(id = 'switch-root') {
return {
id: state(id),
ref: state<HTMLElement | null>(document.createElement('button')),
dir: state<'ltr' | 'rtl' | undefined>(undefined),
checked: state(false),
disabled: state(false),
readonly: state(false),

@ -1,7 +1,7 @@
import { context } from '../../provider';
import { type Active, type State } from '$libs/reactive';
import { type Active, type ActiveProps, type State } from '$libs/reactive';
import type { OptsFromProps } from '../../provider';
import type { OnChangeFn, SomaMouseEvent } from '../../types';
import type { Direction, OnChangeFn, SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { FieldProvider } from '../field';
@ -27,7 +27,11 @@ type SwitchOpts = OptsFromProps<
| 'aria-labelledby'
| 'onCheckedChange',
'checked'
>;
> &
// `dir` is declared apart from `OptsFromProps`: that helper strips the
// `undefined` from the public prop, and «no direction asserted» is exactly
// the value the raw stamp needs to distinguish.
ActiveProps<{ dir: Direction | undefined }>;
/**
* Switch root — runtime-direct (no `Provider` base inheritance).
@ -163,6 +167,10 @@ export class SwitchProvider {
readonly props = $derived.by(() => ({
...this.runtimePart.props,
// The recipe selects with `:dir(rtl)`, which reads the RESOLVED direction,
// so an asserted `dir` must reach the DOM or the paint would lag the prop.
// Raw, not `resolvedDir`: no assertion means no attribute (direction contract).
dir: this.opts.dir.current,
disabled: this.isDisabled || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown

@ -1,4 +1,4 @@
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveButtonAttributes, PrimitiveSpanAttributes } from '../../types';
/**
@ -15,6 +15,12 @@ import type { PrimitiveButtonAttributes, PrimitiveSpanAttributes } from '../../t
*/
export type SwitchProps = WithChild<
{
/**
* Reading direction for this subtree. Canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Whether the switch is on. Bindable. @default false */

Loading…
Cancel
Save

Powered by TurnKey Linux.