From eb0f128c3e9fa20327714d31f87d5786e1bd0928 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 26 Apr 2026 14:25:48 +0200 Subject: [PATCH] =?UTF-8?q?soma:=20V2=20props=20bridge=20(OptsFromProps=20?= =?UTF-8?q?+=20bindProps)=20=E2=80=94=20Toggle=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the triplication that every component used to suffer: 1. Props (autoral, in types.ts) — public consumer API 2. Opts (mechanical) — provider's reactive contract 3. wrapper's `readableActive(() => x)` list (mechanical) After V2 only (1) is hand-written. (2) is derived; (3) collapses to one `bindProps({ ... })` call. src/uix/soma/reactive/opts.ts (new) - `OptsFromProps` type maps a Props interface to the matching Opts: `id` + `ref` baked in; `Managed` selects which keys reach the provider (HTML passthroughs stay out); `StateKey` is the bindable subset (the rest become `Active`). `Exclude` strips the optionality the wrapper resolves via Svelte destructure defaults. - `bindProps(config)` runtime helper: each entry is either a bare getter (→ `readableActive`) or a `{ get, set }` object (→ `writableActive`). Detection is structural (no marker function — avoids collision with Svelte's `$bindable` rune in ` diff --git a/src/uix/soma/components/toggle/toggle-provider.svelte.ts b/src/uix/soma/components/toggle/toggle-provider.svelte.ts index a0d280b1f..f93472feb 100644 --- a/src/uix/soma/components/toggle/toggle-provider.svelte.ts +++ b/src/uix/soma/components/toggle/toggle-provider.svelte.ts @@ -1,32 +1,43 @@ -import { Provider, type WithRefOpts } from '../../provider'; +import { Provider } from '../../provider'; import { createAttrs, registerContract } from '../../attrs'; -import type { ActiveProps, StateProps } from '../../reactive'; +import type { OptsFromProps } from '../../reactive'; import type { OnChangeFn, SomaMouseEvent } from '../../types'; import { Soma } from '../../core/soma.svelte'; import { createMorfoRuntime, type MorfoRuntime } from '../../morfo'; import { FieldProvider } from '../field/field-provider.svelte'; import { toggleMorfo } from '../../../morfo/components/toggle'; +import type { ToggleProps } from './types'; -const attrs = createAttrs(toggleMorfo); +createAttrs(toggleMorfo); registerContract(toggleMorfo); // ── Provider ─────────────────────────────────────────────────────────────── -interface ToggleOpts - extends - WithRefOpts, - StateProps<{ pressed: boolean }>, - ActiveProps<{ - disabled: boolean; - readonly: boolean; - required: boolean; - invalid: boolean; - name: string | undefined; - value: string; - ariaLabel: string | undefined; - onPressedChange: OnChangeFn | undefined; - }> {} +/** + * Provider's reactive contract derived from `ToggleProps`. + * + * - `Managed` — the SOMA-managed subset of public Props (excludes + * `children`, `child`, HTML passthroughs). + * - `'pressed'` — the bindable subset. + * - `id` and `ref` are baked in by `OptsFromProps`. + * + * Add a new prop in `types.ts` → list it in the `Managed` union here → + * pass it through `bindProps` in the wrapper. No third place to keep in sync. + */ +type ToggleOpts = OptsFromProps< + ToggleProps, + | 'pressed' + | 'disabled' + | 'readonly' + | 'required' + | 'invalid' + | 'name' + | 'value' + | 'aria-label' + | 'onPressedChange', + 'pressed' +>; export class ToggleProvider extends Provider { static create(opts: ToggleOpts) { @@ -43,9 +54,9 @@ export class ToggleProvider extends Provider { // attribute lands on this button. Direct assign — NOT $effect (A30). if (this.field) this.field.inputId.current = opts.id.current; - // MorfoRuntime — V1 pilot. Owns the morfo-derived attrs (data-state, - // aria-pressed, aria-disabled, etc.); this provider keeps only handler - // wiring + the HTML `disabled` attribute (not a morfo concern). + // MorfoRuntime — owns the morfo-derived attrs (data-state, aria-pressed, + // aria-disabled, etc.); this provider keeps only handler wiring + the + // HTML `disabled` attribute (not a morfo concern). const soma = Soma.require(); this.runtime = createMorfoRuntime(toggleMorfo, { dom: soma.dom, @@ -57,7 +68,7 @@ export class ToggleProvider extends Provider { readonly: () => this.isReadonly, required: () => this.isRequired, invalid: () => this.isInvalid, - ariaLabel: () => opts.ariaLabel.current + 'aria-label': () => opts['aria-label'].current } }); this.runtime.registerPart('provider', { @@ -95,9 +106,6 @@ export class ToggleProvider extends Provider { * - identity from the runtime (`id`, `data-toggle`, ref attachment) * - the HTML `disabled` attribute (not in morfo — primitive button concern) * - the `onclick` handler - * - * All morfo-declared attrs (`data-state`, `aria-pressed`, `aria-disabled`, - * etc.) are applied to the DOM by the runtime's effect via `dom.apply`. */ readonly props = $derived.by(() => this.assertProps({ @@ -108,6 +116,6 @@ export class ToggleProvider extends Provider { ); } -// `attrs` is exported for any consumer needing the static marker name (e.g. -// querySelector in tests). The runtime owns the runtime emission. -export { attrs as toggleAttrs }; +// `OnChangeFn` re-export — kept so consumers' editor inspection on the wrapper +// still resolves the type by the provider symbol path (legacy import shape). +export type { OnChangeFn }; diff --git a/src/uix/soma/reactive/index.ts b/src/uix/soma/reactive/index.ts index 4d87333c0..c82f0f859 100644 --- a/src/uix/soma/reactive/index.ts +++ b/src/uix/soma/reactive/index.ts @@ -18,3 +18,11 @@ export { // Auto-reset autoReset } from './reactive.svelte'; + +export { + // Props bridge (V2) + bindProps, + type OptsFromProps, + type WritableSpec, + type PropsConfigEntry +} from './opts'; diff --git a/src/uix/soma/reactive/opts.svelte.test.ts b/src/uix/soma/reactive/opts.svelte.test.ts new file mode 100644 index 000000000..d7975d234 --- /dev/null +++ b/src/uix/soma/reactive/opts.svelte.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { flushSync } from 'svelte' + +import { bindProps } from './opts' +import { isActive, isState, state } from './reactive.svelte' +import type { State } from './reactive.svelte' + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T + const cleanup = $effect.root(() => { + result = fn() + }) + return { result, cleanup } +} + +describe('bindProps', () => { + it('wraps a bare getter in a readable Active', () => { + const source = state('foo') + const { result, cleanup } = withEffectRoot(() => { + return bindProps({ + name: () => source.current + }) + }) + + expect(isActive(result.name)).toBe(true) + expect(isState(result.name)).toBe(false) + expect(result.name.current).toBe('foo') + + source.current = 'bar' + flushSync() + expect(result.name.current).toBe('bar') + cleanup() + }) + + it('wraps a bindable() spec in a writable State', () => { + const source = state('foo') + const { result, cleanup } = withEffectRoot(() => { + return bindProps({ + name: { get: () => source.current, set: (v: string) => (source.current = v) } + }) + }) + + const stateBox = result.name as unknown as State + + expect(isActive(stateBox)).toBe(true) + expect(isState(stateBox)).toBe(true) + expect(stateBox.current).toBe('foo') + + stateBox.current = 'baz' + flushSync() + expect(source.current).toBe('baz') + expect(stateBox.current).toBe('baz') + cleanup() + }) + + it('mixes readable and writable entries in a single config', () => { + const pressedSrc = state(false) + const labelSrc = state('click me') + const { result, cleanup } = withEffectRoot(() => { + return bindProps({ + pressed: { + get: () => pressedSrc.current, + set: (v: boolean) => (pressedSrc.current = v) + }, + 'aria-label': () => labelSrc.current + }) + }) + + expect(isState(result.pressed)).toBe(true) + expect(isState(result['aria-label'])).toBe(false) + expect(isActive(result['aria-label'])).toBe(true) + expect(result.pressed.current).toBe(false) + expect(result['aria-label'].current).toBe('click me') + + // Writable mutation propagates to the source variable. + ;(result.pressed as unknown as { current: boolean }).current = true + flushSync() + expect(pressedSrc.current).toBe(true) + + // Readable update via source variable propagates to consumer. + labelSrc.current = 'press me' + flushSync() + expect(result['aria-label'].current).toBe('press me') + cleanup() + }) + + it('preserves kebab-cased keys (e.g. aria-label) end-to-end', () => { + const { result, cleanup } = withEffectRoot(() => { + return bindProps({ + 'data-testid': () => 'tog-1', + 'aria-label': () => 'Star' + }) + }) + + expect(result['data-testid'].current).toBe('tog-1') + expect(result['aria-label'].current).toBe('Star') + cleanup() + }) +}) diff --git a/src/uix/soma/reactive/opts.ts b/src/uix/soma/reactive/opts.ts new file mode 100644 index 000000000..bbfee2042 --- /dev/null +++ b/src/uix/soma/reactive/opts.ts @@ -0,0 +1,122 @@ +/** + * Props → Opts bridge helpers. + * + * Eliminates the triplication every component used to suffer: + * 1. `Props` (autoral, lives in `types.ts`) — public consumer API + * 2. `Opts` (mechanical) — provider's reactive contract + * 3. wrapper's `readableActive(() => x)` list (mechanical) + * + * After V2 only (1) is hand-written. (2) is derived via `OptsFromProps`, + * (3) collapses to a single `bindProps({ ... })` call. + * + * Aliases for kebab-case keys (`'aria-label'` etc.) stay manual at the + * wrapper destructure — TypeScript can't bridge `'aria-label'` to + * `ariaLabel` without a per-key map. Internal soma keys use the public + * Props key as-is (bracket notation in the provider's `props` derivation). + */ + +import { + readableActive, + writableActive, + type Active, + type State +} from './reactive.svelte'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** + * Derives a provider's `Opts` interface from its public `Props` type. + * + * - `Managed` — the subset of Props keys the provider receives. Excludes + * HTML passthroughs (PrimitiveButtonAttributes), children/child snippets, + * and `id` / `ref` (which are baked in below). + * - `StateKey` — subset of `Managed` whose props are bindable (`$bindable` + * on the consumer side). The rest become `Active`. + * + * Output shape is structurally identical to the hand-written + * `interface XOpts extends WithRefOpts, StateProps<...>, ActiveProps<...>`, + * so providers extending `Provider` work unchanged. + * + * Optional props (`disabled?: boolean`) become `State` / + * `Active` — `Exclude<…, undefined>` strips the optionality. The + * wrapper provides defaults via Svelte destructure; the provider sees the + * resolved value. For props that legitimately stay undefined at the wrapper + * level (no destructure default), intersect manually: + * `OptsFromProps<…> & { name: Active }`. + */ +export type OptsFromProps< + P, + Managed extends keyof P, + StateKey extends Managed +> = { + id: Active; + ref: State; +} & { + [K in StateKey]-?: State>; +} & { + [K in Exclude]-?: Active>; +}; + +/** + * Per-key entry in a `bindProps` config. A bare getter becomes `Active`; + * a `{ get, set }` object becomes `State`. + * + * The marker is structural rather than nominal so there's no collision with + * Svelte's `$bindable` rune in `