feat(direction): `dir` pasa a ser prop en 4 de los 7 que no la exponian

Grupo B del censo (9.17). No usaban otro mecanismo: usaban el MISMO con el
primer eslabon ausente —leian `soma.prefs.getDir()` porque no habia prop que
consultar—, que es lo que el contrato prescribe sin prop. Lo que se subsana es
la ASIMETRIA de superficie: 42 componentes dejaban fijar la direccion de su
subarbol y estos no.

Hechos aqui: `tag-group` · `grid-list` · `tree-grid` · `drawer`.

El patron, identico en los cuatro y ya probado en 9.16:
  types.ts    `dir?: Direction` documentado con la cadena canonica
  wrapper     `dir: activeDir(() => dir, soma)`
  provider    `resolvedDir = opts.dir.current ?? 'ltr'` (solo el fallback)
  atributo    el CRUDO (`opts.dir.current`), omitido si nadie afirmo

⚠️ `drawer` merece cuidado: ya exponia `direction` (el BORDE al que se pega:
top/right/bottom/left/start/end). Ahora convive con `dir` (la direccion de
LECTURA, ltr/rtl), que es justo lo que resuelve `start`/`end` a un lado fisico.
Ambas quedan documentadas apuntandose la una a la otra para que nadie las
confunda.

Cinco tests apoyaban su expectativa en el mecanismo viejo (el harness devolvia
una direccion por `prefs.getDir()` y esperaban verla resuelta dentro del
provider). Actualizados: el provider recibe la direccion YA resuelta, que es lo
que el wrapper le entrega. El del drawer gana ademas la comprobacion de que un
borde FISICO (`left`) ignora la direccion de lectura.

`check` = 74 = linea base (medido con el arbol limpio; los 2 errores extra que
aparecen en el conteo son de `media-player`, tocado por otra sesion y ajeno a
este trabajo). 15/15.

QUEDAN 3 del grupo B: `float-panel`, `virtual-grid`, `virtual-list`.

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

@ -4,11 +4,14 @@
writableActive
} from '$libs/reactive';
import { createId } from '$active-uix/id';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { DrawerProvider } from '../drawer-provider.svelte';
import type { DrawerProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
id = createId(uid, 'drawer'),
@ -16,6 +19,7 @@
onOpenChange = () => {},
onOpenChangeComplete = () => {},
direction = 'bottom',
dir,
variant = 'overlay',
disabled = false,
dismissible = true,
@ -60,6 +64,7 @@
),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
direction: readableActive(() => direction),
dir: activeDir(() => dir, soma),
variant: readableActive(() => variant),
disabled: readableActive(() => disabled),
dismissible: readableActive(() => dismissible),

@ -60,6 +60,10 @@ function createDrawerOpts() {
handleOnly: state(false),
dragToDismiss: state(false),
direction: state<DrawerDirection>('bottom'),
// Reading direction, distinct from `direction` (the edge). The wrapper
// folds `prop → prefs` before the provider sees it; here the provider is
// built directly, so this IS the already-resolved value.
dir: state<'ltr' | 'rtl' | undefined>('ltr'),
snapPoints: state<DrawerSnapPoint[] | undefined>(undefined),
snapToSequential: state(false),
closeThreshold: state(0.25),
@ -99,14 +103,20 @@ describe('DrawerProvider', () => {
document.body.innerHTML = '';
});
it('resolves logical directions from the active UI direction preference', () => {
it('resolves logical directions from the reading direction', () => {
const { dom } = installSomaHarness('rtl');
const opts = createDrawerOpts();
opts.direction.current = 'start';
// The chain `prop → prefs → 'ltr'` lives in the WRAPPER (`activeDir`), so
// by here the preference is already folded into `opts.dir`.
opts.dir.current = 'rtl';
const { result: provider, cleanup } = withEffectRoot(() => DrawerProvider.create(opts));
expect(provider.resolvedSide).toBe('right');
// …and a physical edge ignores the reading direction entirely.
opts.direction.current = 'left';
expect(provider.resolvedSide).toBe('left');
cleanup();
dom.dispose();

@ -177,13 +177,17 @@ export class DrawerProvider {
private contentRef = state<HTMLElement | null>(null);
private overlayRef = state<HTMLElement | null>(null);
readonly resolvedSide = $derived.by((): DrawerSide => {
// 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);
});
/**
* Reading direction for this part's OWN math. Always concrete — `undefined`
* and `'ltr'` are indistinguishable to `resolveDirection`. The chain
* `prop → soma.prefs.getDir() → 'ltr'` runs in the WRAPPER (`activeDir`),
* so this only supplies the final fallback.
*/
readonly resolvedDir = $derived.by((): Direction => this.opts.dir.current ?? 'ltr');
readonly resolvedSide = $derived.by(
(): DrawerSide => resolveDirection(this.opts.direction.current, this.resolvedDir)
);
/**
* Modal-equivalent boolean derived from `variant`. Drives focus trap,

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type { DismissalBehavior } from '../../layers/dismissal.svelte';
@ -63,6 +63,12 @@ export type DrawerActiveFields = {
handleOnly: boolean;
dragToDismiss: boolean;
direction: DrawerDirection;
/**
* READING direction (`ltr` / `rtl`) — not to be confused with `direction`
* above, which is the EDGE the drawer attaches to. This is what resolves
* that edge's logical values (`start` / `end`) into a physical side.
*/
dir: Direction | undefined;
snapPoints: DrawerSnapPoint[] | undefined;
snapToSequential: boolean;
closeThreshold: number;
@ -107,6 +113,13 @@ export type DrawerProps = {
* @default 'bottom'
*/
direction?: DrawerDirection;
/**
* READING direction for this subtree — NOT `direction` above, which is the
* edge the drawer attaches to. This is what resolves that edge's logical
* values (`start` / `end`) into a physical side. Canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference.
*/
dir?: Direction;
/**
* Presentation mode. See {@link DrawerVariant} for the three values:
* - `'overlay'` (default): modal with backdrop + focus trap.

@ -3,12 +3,15 @@
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { GridListProvider } from '../grid-list-provider.svelte';
import type { GridListProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -20,6 +23,7 @@
typeahead = true,
typeaheadTimeout = 500,
disabled = false,
dir,
readonly = false,
required = false,
invalid = false,
@ -49,6 +53,7 @@
typeahead: readableActive(() => typeahead),
typeaheadTimeout: readableActive(() => typeaheadTimeout),
disabled: readableActive(() => disabled),
dir: activeDir(() => dir, soma),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),

@ -89,7 +89,10 @@ function gridListOpts(root = createRoot()) {
name: state<string | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined),
ariaLabelledby: state<string | undefined>(undefined),
onValueChange: state<((value: string[]) => void) | undefined>(undefined)
onValueChange: state<((value: string[]) => void) | undefined>(undefined),
// The wrapper folds `prop → prefs` before the provider sees it; here the
// provider is built directly, so this IS the already-resolved value.
dir: state<'ltr' | 'rtl' | undefined>('ltr')
};
}

@ -34,6 +34,7 @@ interface GridListOpts
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
dir: Direction | undefined;
}> {}
export class GridListProvider {
@ -107,7 +108,15 @@ export class GridListProvider {
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
readonly resolvedDir: Active<Direction> = readableActive(() => this.soma.prefs.getDir() ?? 'ltr');
/**
* Direction for this part's OWN math. Always concrete — `undefined` and
* `'ltr'` are indistinguishable to every `=== 'rtl'` test. The chain
* `prop → soma.prefs.getDir() → 'ltr'` runs in the WRAPPER (`activeDir`),
* so this only supplies the final fallback.
*/
readonly resolvedDir: Active<Direction> = readableActive(
() => this.opts.dir.current ?? 'ltr'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { WithChild, Without, OnChangeFn , Direction } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveButtonAttributes
@ -59,6 +59,14 @@ export type GridListProps = WithChild<
// Flags — OR-merged with enclosing `Field.Provider`
/** @default false */
disabled?: boolean;
/**
* Text direction for this subtree. Resolved via the canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/** @default false */
readonly?: boolean;
/** @default false */

@ -3,12 +3,15 @@
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 { TagGroupProvider } from '../tag-group-provider.svelte';
import type { TagGroupProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -19,6 +22,7 @@
onRemove = () => {},
selectionMode = 'none',
disabled = false,
dir,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
@ -42,6 +46,7 @@
items: readableActive(() => items),
selectionMode: readableActive(() => selectionMode),
disabled: readableActive(() => disabled),
dir: activeDir(() => dir, soma),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),
onValueChange: readableActive(() => onValueChange),

@ -81,7 +81,10 @@ function tagGroupOpts(root = document.createElement('div')) {
ariaLabel: state<string | undefined>(undefined),
ariaLabelledby: state<string | undefined>(undefined),
onValueChange: state<((value: string[]) => void) | undefined>(undefined),
onRemove: state<((value: string) => void) | undefined>(undefined)
onRemove: state<((value: string) => void) | undefined>(undefined),
// The wrapper folds `prop → prefs` before the provider sees it; here the
// provider is built directly, so this IS the already-resolved value.
dir: state<'ltr' | 'rtl' | undefined>('ltr')
};
}

@ -33,6 +33,7 @@ interface TagGroupOpts
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
onRemove: ((value: string) => void) | undefined;
dir: Direction | undefined;
}> {}
export class TagGroupProvider {
@ -77,7 +78,13 @@ export class TagGroupProvider {
});
}
readonly resolvedDir: Active<Direction> = readableActive(() => this.soma.prefs.getDir() ?? 'ltr');
/**
* Direction for this part's OWN math (arrow keys). Always concrete —
* `undefined` and `'ltr'` are indistinguishable to every `=== 'rtl'` test.
* The chain `prop → soma.prefs.getDir() → 'ltr'` runs in the WRAPPER
* (`activeDir`), so this only supplies the final fallback.
*/
readonly resolvedDir: Active<Direction> = readableActive(() => this.opts.dir.current ?? 'ltr');
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
if (this.opts.ariaLabelledby.current || this.labelId.current) return undefined;
@ -239,7 +246,11 @@ export class TagGroupProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
...this.runtimePart.props,
// The RAW assertion, never the resolved value: omitted when nobody
// asserted a direction, so the element inherits instead of forcing
// `ltr`. `resolvedDir` is the other half, for the maths.
dir: this.opts.dir.current
} as const)
);
}

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveButtonAttributes,
@ -57,6 +57,13 @@ export type TagGroupProps = WithChild<
/** @default false */
disabled?: boolean;
/**
* Text direction for this subtree. Resolved via the canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, the group follows the
* preference and the attribute is not stamped, so it inherits.
*/
dir?: Direction;
/**
* Accessible name for the group. @default translated `'Tags'`
*/

@ -3,12 +3,15 @@
readableActive,
writableActive
} from '$libs/reactive';
import { activeDir } from '../../../direction';
import { Soma } from '../../../core/soma.svelte';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { TreeGridProvider } from '../tree-grid-provider.svelte';
import type { TreeGridProps } from '../types';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
@ -22,6 +25,7 @@
typeahead = true,
typeaheadTimeout = 500,
disabled = false,
dir,
readonly = false,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
@ -55,6 +59,7 @@
typeahead: readableActive(() => typeahead),
typeaheadTimeout: readableActive(() => typeaheadTimeout),
disabled: readableActive(() => disabled),
dir: activeDir(() => dir, soma),
readonly: readableActive(() => readonly),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),

@ -76,7 +76,10 @@ function createTreeGridOpts(root: HTMLElement = document.createElement('div')) {
ariaLabel: state<string | undefined>(undefined),
ariaLabelledby: state<string | undefined>(undefined),
onValueChange: state<((value: string[]) => void) | undefined>(undefined),
onExpandedChange: state<((value: string[]) => void) | undefined>(undefined)
onExpandedChange: state<((value: string[]) => void) | undefined>(undefined),
// The wrapper folds `prop → prefs` before the provider sees it; here the
// provider is built directly, so this IS the already-resolved value.
dir: state<'ltr' | 'rtl' | undefined>('ltr')
};
}

@ -40,6 +40,7 @@ interface TreeGridOpts
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
onExpandedChange: OnChangeFn<string[]> | undefined;
dir: Direction | undefined;
}> {}
export class TreeGridProvider {
@ -91,7 +92,13 @@ export class TreeGridProvider {
});
}
readonly resolvedDir: Active<Direction> = readableActive(() => this.soma.prefs.getDir() ?? 'ltr');
/**
* Direction for this part's OWN math. Always concrete — `undefined` and
* `'ltr'` are indistinguishable to every `=== 'rtl'` test. The chain
* `prop → soma.prefs.getDir() → 'ltr'` runs in the WRAPPER (`activeDir`),
* so this only supplies the final fallback.
*/
readonly resolvedDir: Active<Direction> = readableActive(() => this.opts.dir.current ?? 'ltr');
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
/** Selection mode. */
@ -61,6 +61,14 @@ export type TreeGridProps = WithChild<
/** @default false */
disabled?: boolean;
/**
* Text direction for this subtree. Resolved via the canonical chain
* `prop → soma.prefs.getDir() → 'ltr'`; omitted, it follows the preference
* and the attribute is not stamped, so the element inherits.
*/
dir?: Direction;
/** @default false */
readonly?: boolean;

Loading…
Cancel
Save

Powered by TurnKey Linux.