Align Soma public wrappers with providers

active-uix
dev 5 months ago
parent b74e363e01
commit 7a85ea59c2

@ -25,11 +25,14 @@ Arranque rapido para mañana:
- Sema queda protegido contra listeners globales nativos y `console.*`.
- `ScrollLock` de Soma inyecta `ActiveDom` hacia `BodyScrollLock`.
- Lo primero para continuar:
1. Hacer un barrido de wrappers publicos `.svelte` frente a providers para
detectar props no reenviadas, bindables incompletos, snippets mal
tipados o defaults divergentes.
1. Cerrado el primer barrido de wrappers publicos `.svelte` frente a
providers. Si se retoma esta linea, continuar con validacion navegador
de rutas `/uix/components/*`, no repetir el barrido estatico salvo que
cambien wrappers.
2. Decidir si el directorio vacio `src/uix/soma/components/avatar/` se borra
o se deja como placeholder documentado. Hoy Avatar es `eidos` scope.
Decidido: no hay accion versionable; solo existe un directorio local
vacio (`components/`) y Git no lo registra. No crear placeholder Soma.
3. Si se sigue con Sium: abordar P0 de integridad (`isPlainObjectRecord`,
`email`, `regex` con flags mutables, doc de `ctx.rootValue`).
4. Mantener fuera de commits los logs sueltos (`.codex-vite-*`,
@ -37,6 +40,27 @@ Arranque rapido para mañana:
Actualizacion 2026-05-16:
- Barrido de wrappers publicos Soma frente a providers cerrado:
- `DateField.Provider` y `TimeField.Provider` vuelven a materializar su
root publico por defecto (`<div {...mergedProps}>`) y aceptan `ref` /
`restProps`, coherente con su morfo (`kind: public`,
`defaultElement: 'div'`).
- `DateField` ya respeta la prop `dir`; la direccion efectiva se pasa al
provider y a la navegacion de segmentos en vez de ignorarse y leer solo
`soma.prefs`.
- `DatePicker`, `DateRangePicker`, `TimePicker` y `TimeRangePicker`
mantienen root virtual: sus props root ya no anuncian `PrimitiveDivAttrs`
ni `child` con `props: {}`.
- `ColorPicker.Area` ya aplica de verdad los overrides locales
`xChannel` / `yChannel`; antes los tipos los exponian pero el wrapper los
descartaba.
- Guardias nuevas en `src/uix/contracts.test.ts` para roots publicos
Date/Time Field y roots virtuales picker.
- Validado: `npx vitest run src/uix/contracts.test.ts` + tests focales de
date/time pickers/fields -> 52 tests OK; `color-picker-provider` -> 5
tests OK; `npm run check` -> 0 errores / 0 warnings; `npm run test` ->
233 archivos, 2251 tests OK.
- Soma queda en estado funcionalmente cubierto a nivel provider/runtime:
`npm run check` -> 0 errores / 0 warnings, `npm run test` -> 233 archivos,
2242 tests OK, y la guardia de `*-provider.svelte.ts` sin test directo

@ -347,6 +347,46 @@ describe('UIX layer contracts', () => {
expect(violations).toEqual([]);
});
it('guards Date/Time field roots from swallowing provider DOM attrs', () => {
for (const dir of ['date-field', 'time-field']) {
const source = readFileSync(
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
'utf8'
);
expect(source, dir).toContain('ref = $bindable(null)');
expect(source, dir).toContain('...restProps');
expect(source, dir).toContain('const mergedProps');
expect(source, dir).toContain('<div {...mergedProps}>');
expect(source, dir).not.toContain('child({ props: {} })');
}
});
it('guards virtual picker roots from advertising DOM attrs', () => {
const roots = [
['date-picker', 'DatePickerProviderProps'],
['date-range-picker', 'DateRangePickerProviderProps'],
['time-picker', 'TimePickerProviderProps'],
['time-range-picker', 'TimeRangePickerProviderProps']
] as const;
for (const [dir, typeName] of roots) {
const types = readFileSync(join(HERE, 'soma', 'components', dir, 'types.ts'), 'utf8');
const wrapper = readFileSync(
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
'utf8'
);
const rootType =
new RegExp(`export type ${typeName}\\s*=\\s*\\{[\\s\\S]*?\\};`).exec(types)?.[0] ?? '';
expect(rootType, dir).toContain('children?: Snippet;');
expect(rootType, dir).not.toContain('WithChild');
expect(rootType, dir).not.toContain('PrimitiveDivAttributes');
expect(wrapper, dir).toContain('{@render children?.()}');
expect(wrapper, dir).not.toContain('child({ props: {} })');
}
});
it('guards Soma public barrels from provider implementation filenames', () => {
const violations = collectPublicSomaComponentDirs()
.map((dir) => join(HERE, 'soma', 'components', dir, 'exports.ts'))

@ -196,7 +196,9 @@ describe('ColorPickerProvider', () => {
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
const area = ColorPickerAreaProvider.create({
id: state('color-picker-area'),
ref: state<HTMLElement | null>(document.createElement('div'))
ref: state<HTMLElement | null>(document.createElement('div')),
xChannel: state<ColorChannel | undefined>(undefined),
yChannel: state<ColorChannel | undefined>(undefined)
});
vi.spyOn(ColorPickerAreaProvider.ctx, 'get').mockReturnValue(area);
const thumb = ColorPickerAreaThumbProvider.create({
@ -227,6 +229,37 @@ describe('ColorPickerProvider', () => {
dom.dispose();
});
it('lets each area override the root channel mapping', () => {
const { dom } = installSomaHarness();
const opts = createColorPickerOpts();
opts.value.current = colorValueFromRgb({ r: 10, g: 20, b: 30, a: 1 });
const { result, cleanup } = withEffectRoot(() => {
const provider = ColorPickerProvider.create(opts);
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
const area = ColorPickerAreaProvider.create({
id: state('color-picker-area'),
ref: state<HTMLElement | null>(document.createElement('div')),
xChannel: state<ColorChannel | undefined>('red'),
yChannel: state<ColorChannel | undefined>('green')
});
return { area };
});
expect(result.area.props).toMatchObject({
'data-x-channel': 'red',
'data-y-channel': 'green'
});
result.area.handlePointerMove(128, 128, rect(0, 0, 255, 255));
expect(Math.round(opts.value.current?.rgb.r ?? 0)).toBe(128);
expect(Math.round(opts.value.current?.rgb.g ?? 0)).toBe(127);
cleanup();
dom.dispose();
});
it('drives a channel slider by pointer and keyboard and commits on pointerup', () => {
const { dom } = installSomaHarness();
const opts = createColorPickerOpts();

@ -528,7 +528,13 @@ import { KEYS } from '../../keyboard';
// ── Area ────────────────────────────────────────────────────────────────────
export interface ColorPickerAreaOpts extends WithRefOpts {}
export interface ColorPickerAreaOpts
extends
WithRefOpts,
ActiveProps<{
xChannel: ColorChannel | undefined;
yChannel: ColorChannel | undefined;
}> {}
export class ColorPickerAreaProvider {
readonly opts: ColorPickerAreaOpts;
@ -562,8 +568,12 @@ export class ColorPickerAreaProvider {
});
}
readonly xChannel: ColorChannel = $derived.by(() => this.provider.opts.areaChannels.current.x);
readonly yChannel: ColorChannel = $derived.by(() => this.provider.opts.areaChannels.current.y);
readonly xChannel: ColorChannel = $derived.by(
() => this.opts.xChannel.current ?? this.provider.opts.areaChannels.current.x
);
readonly yChannel: ColorChannel = $derived.by(
() => this.opts.yChannel.current ?? this.provider.opts.areaChannels.current.y
);
/**
* The `(xChannel, yChannel)` normalized progress (0..1) for the current

@ -13,22 +13,21 @@
let {
ref = $bindable(null),
id = createId(uid, 'color-picker-area'),
xChannel: _x,
yChannel: _y,
xChannel,
yChannel,
children,
child,
...restProps
}: ColorPickerAreaProps = $props();
// Per-instance axis override is not implemented yet — the provider reads
// from root `areaChannels`. Destructured (as `_x` / `_y`) to keep them out
// of `restProps` so they don't leak onto the DOM; kept for forward compat.
const area = ColorPickerAreaProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
),
xChannel: readableActive(() => xChannel),
yChannel: readableActive(() => yChannel)
});
const mergedProps = $derived(mergeProps(restProps, area.props));

@ -20,6 +20,7 @@
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'date-field'),
value = $bindable(),
onValueChange = () => {},
@ -37,9 +38,6 @@
required = false,
errorMessageId,
locale,
// `dir` is consumed by sub-parts through soma.prefs.getDir();
// destructured (as `_dir`) only to keep it out of `restProps`.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
dir: _dir,
hourCycle,
children,
@ -72,6 +70,10 @@
const state = DateFieldProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
@ -97,6 +99,9 @@
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: readableActive<'ltr' | 'rtl'>(
() => (_dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
validate: readableActive(() => validate),
@ -113,5 +118,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
{@render children?.()}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -103,6 +103,7 @@ function createDateFieldOpts() {
granularity: state<Granularity | undefined>(undefined),
hourCycle: state<HourCycle | undefined>(undefined),
locale: state('en-US'),
dir: state<'ltr' | 'rtl'>('ltr'),
hideTimeZone: state(false),
required: state(false),
errorMessageId: state<string | undefined>(undefined)

@ -12,7 +12,13 @@ import {
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type { SomaFocusEvent, SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import type {
SomaFocusEvent,
SomaKeyboardEvent,
SomaMouseEvent,
OnChangeFn,
Direction
} from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { isNumberString } from '../../types/guards';
import { useId } from '../../id';
@ -158,6 +164,7 @@ export interface DateFieldOpts
granularity: Granularity | undefined;
hourCycle: HourCycle | undefined;
locale: string;
dir: Direction;
hideTimeZone: boolean;
required: boolean;
errorMessageId: string | undefined;
@ -711,7 +718,8 @@ export class DateFieldProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
...this.runtimePart.props,
dir: this.opts.dir.current
} as const)
);
}
@ -918,7 +926,7 @@ abstract class BaseNumericSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
}
/**
@ -1378,7 +1386,7 @@ class DateFieldDayPeriodSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {
@ -1498,7 +1506,7 @@ class DateFieldTimeZoneSegmentProvider {
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {

@ -61,8 +61,7 @@
isDateHoliday,
announceNavigation = true,
errorMessageId,
children,
child
children
}: DatePickerProviderProps = $props();
/** Today-based default when the consumer doesn't supply a placeholder. */
@ -137,7 +136,7 @@
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
locale: resolvedLocale,
dir: resolvedDir,
pagedNavigation: readableActive(() => pagedNavigation),
weekStartsOn: readableActive(() => weekStartsOn),
@ -183,12 +182,9 @@
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: resolvedLocale,
dir: resolvedDir,
errorMessageId: readableActive(() => errorMessageId)
});
</script>
{#if child}
{@render child({ props: {} })}
{:else}
{@render children?.()}
{/if}
{@render children?.()}

@ -24,7 +24,7 @@ export type DatePickerCalendarSnippetProps = {
// ── Root provider ──────────────────────────────────────────────────────────
/** Props for the root `DatePicker.Provider`. */
export type DatePickerProviderProps = WithChild<{
export type DatePickerProviderProps = {
/** DOM id. Auto-generated when omitted. */
id?: string;
@ -129,8 +129,8 @@ export type DatePickerProviderProps = WithChild<{
// ── ARIA ──
/** External element id holding the error message (for aria-describedby). */
errorMessageId?: string;
}> &
Omit<PrimitiveDivAttributes, 'placeholder'>;
children?: Snippet;
};
// ── Trigger ────────────────────────────────────────────────────────────────

@ -75,6 +75,7 @@
hideTimeZone: provider.opts.hideTimeZone,
hourCycle: provider.opts.hourCycle,
locale: readableActive(() => provider.opts.locale.current ?? ''),
dir: provider.opts.dir,
errorMessageId: provider.opts.errorMessageId
});

@ -71,8 +71,7 @@
weekNumberFormat = 'iso',
announceNavigation = true,
errorMessageId,
children,
child
children
}: DateRangePickerProviderProps = $props();
/** Today-based default placeholder. */
@ -196,7 +195,7 @@
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: resolvedDir,
pagedNavigation: readableActive(() => pagedNavigation),
weekStartsOn: readableActive(() => weekStartsOn),
@ -253,8 +252,4 @@
});
</script>
{#if child}
{@render child({ props: {} })}
{:else}
{@render children?.()}
{/if}
{@render children?.()}

@ -23,7 +23,7 @@ export type DateRangePickerCalendarSnippetProps = {
};
/** Props for the root `DateRangePicker.Provider`. */
export type DateRangePickerProviderProps = WithChild<{
export type DateRangePickerProviderProps = {
id?: string;
// ── Value ──
@ -109,8 +109,8 @@ export type DateRangePickerProviderProps = WithChild<{
// ── ARIA ──
errorMessageId?: string;
}> &
Omit<PrimitiveDivAttributes, 'placeholder'>;
children?: Snippet;
};
/** Props for `DateRangePicker.Trigger`. */
export type DateRangePickerTriggerProps = WithChild<{

@ -4,6 +4,7 @@
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { Soma } from '../../../core/soma.svelte';
import {
@ -19,6 +20,7 @@
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'time-field'),
value = $bindable(),
onValueChange = () => {},
@ -39,7 +41,8 @@
dir: _dir,
hourCycle,
children,
child
child,
...restProps
}: TimeFieldProviderProps = $props();
/** Default placeholder = current wall-clock time (minute precision). */
@ -59,8 +62,12 @@
}
);
TimeFieldProvider.create({
const state = TimeFieldProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
@ -96,10 +103,14 @@
),
hourCycle: readableActive(() => hourCycle)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: {} })}
{@render child({ props: mergedProps })}
{:else}
{@render children?.()}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -41,8 +41,7 @@
minuteStep = 1,
secondStep = 1,
errorMessageId,
children,
child
children
}: TimePickerProviderProps = $props();
function defaultPlaceholder(): TimeValue {
@ -106,7 +105,7 @@
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: resolvedDir,
hourStep: readableActive(() => hourStep),
minuteStep: readableActive(() => minuteStep),
@ -143,8 +142,4 @@
});
</script>
{#if child}
{@render child({ props: {} })}
{:else}
{@render children?.()}
{/if}
{@render children?.()}

@ -1,5 +1,6 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type { PrimitiveButtonAttributes } from '../../types';
import type {
TimeValue,
TimeValidator,
@ -10,7 +11,7 @@ import type {
} from '$libs/days';
/** Props for the root `TimePicker.Provider`. */
export type TimePickerProviderProps = WithChild<{
export type TimePickerProviderProps = {
id?: string;
// ── Value ──
@ -54,8 +55,8 @@ export type TimePickerProviderProps = WithChild<{
// ── ARIA ──
errorMessageId?: string;
}> &
Omit<PrimitiveDivAttributes, 'placeholder'>;
children?: Snippet;
};
/** Props for `TimePicker.Trigger`. */
export type TimePickerTriggerProps = WithChild<{

@ -46,8 +46,7 @@
minuteStep = 1,
secondStep = 1,
errorMessageId,
children,
child
children
}: TimeRangePickerProviderProps = $props();
/** Default placeholder = current wall-clock time (minute precision). */
@ -147,7 +146,7 @@
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),
locale: readableActive(() => locale),
locale: readableActive(() => locale ?? soma?.langs.getLocale() ?? ''),
dir: resolvedDir,
hourStep: readableActive(() => hourStep),
minuteStep: readableActive(() => minuteStep),
@ -193,8 +192,4 @@
});
</script>
{#if child}
{@render child({ props: {} })}
{:else}
{@render children?.()}
{/if}
{@render children?.()}

@ -1,3 +1,4 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type {
@ -14,7 +15,7 @@ import type {
export type TimeRangeEndpoint = 'start' | 'end';
/** Props for the root `TimeRangePicker.Provider`. */
export type TimeRangePickerProviderProps = WithChild<{
export type TimeRangePickerProviderProps = {
id?: string;
// ── Value ──
@ -81,8 +82,8 @@ export type TimeRangePickerProviderProps = WithChild<{
// ── ARIA ──
errorMessageId?: string;
}> &
Omit<PrimitiveDivAttributes, 'placeholder'>;
children?: Snippet;
};
/** Props for `TimeRangePicker.Trigger`. */
export type TimeRangePickerTriggerProps = WithChild<{

Loading…
Cancel
Save

Powered by TurnKey Linux.