soma: V2 props bridge (OptsFromProps + bindProps) — Toggle refactor

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<P, Managed, StateKey>` 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<P[K], undefined>` 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 `<script>` blocks).
- `WritableSpec<T>` defaults to `unknown` for storage but
  `PropsConfigEntry<T>` defaults to `any` for variance — heterogeneous bag
  literals are bivariant under `any`, contravariant under `unknown`.

Toggle migration as the proof
- `morfo/components/toggle.ts`: rename `propRef('ariaLabel')` → `propRef('aria-label')`
  so the morfo and the public Props key match. Bracket notation in the
  provider handles the kebab-cased key.
- `soma/components/toggle/toggle-provider.svelte.ts`:
  - `interface ToggleOpts { ... }` (15 lines hand-rolled) replaced by
    `type ToggleOpts = OptsFromProps<ToggleProps, ManagedKeys, 'pressed'>` (10 lines)
  - sources read `opts['aria-label'].current`
- `soma/components/toggle/components/toggle.svelte`:
  - 11 lines of `name: readableActive(() => name)` collapsed to a single
    `bindProps({ ... })` call where each line is just `name: () => name` or
    `pressed: { get: () => pressed, set: (v) => (pressed = v) }`

Tests
- `opts.svelte.test.ts` — 4 unit tests verifying readable/writable
  detection, mixed configs, and kebab-keyed entries (`'aria-label'`).

Verification
- 111/111 soma tests green.
- 66/66 morfo-check (Toggle's emitted DOM unchanged).
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors).

Recipe for migrating other components:
  1. List `Managed` keys (everything in Props that isn't HTML passthrough).
  2. List `StateKey` subset (anything bindable in the wrapper).
  3. Replace the manual Opts interface with `OptsFromProps<...>`.
  4. Wrap the wrapper's prop forwarding in `bindProps({ ... })`.
morfo-runtime
dev 6 months ago
parent 6d805a4529
commit eb0f128c3e

@ -46,9 +46,9 @@ export const toggleMorfo = {
{ attr: 'aria-pressed', value: v.stateRef('on') },
{
attr: 'aria-label',
value: v.propRef('ariaLabel'),
value: v.propRef('aria-label'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'ariaLabel' }
condition: { when: 'prop-truthy', prop: 'aria-label' }
},
{
attr: 'aria-disabled',

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { bindProps } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ToggleProvider } from '../toggle-provider.svelte';
@ -24,25 +24,23 @@
...restProps
}: ToggleProps = $props();
const state = ToggleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
pressed: writableActive(
() => pressed,
(v) => (pressed = v)
),
onPressedChange: readableActive(() => onPressedChange),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
value: readableActive(() => value),
ariaLabel: readableActive(() => ariaLabel)
});
// Single bridge call. Bindable fields use `{ get, set }`; the rest are
// plain getters that bindProps wraps in `readableActive` automatically.
const state = ToggleProvider.create(
bindProps({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
pressed: { get: () => pressed, set: (v) => (pressed = v) },
onPressedChange: () => onPressedChange,
disabled: () => disabled,
readonly: () => readonly,
required: () => required,
invalid: () => invalid,
name: () => name,
value: () => value,
'aria-label': () => ariaLabel
}) as Parameters<typeof ToggleProvider.create>[0]
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -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<boolean> | 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<ToggleOpts> {
static create(opts: ToggleOpts) {
@ -43,9 +54,9 @@ export class ToggleProvider extends Provider<ToggleOpts> {
// 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<ToggleOpts> {
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<ToggleOpts> {
* - 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<ToggleOpts> {
);
}
// `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 };

@ -18,3 +18,11 @@ export {
// Auto-reset
autoReset
} from './reactive.svelte';
export {
// Props bridge (V2)
bindProps,
type OptsFromProps,
type WritableSpec,
type PropsConfigEntry
} from './opts';

@ -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<T>(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<string>
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()
})
})

@ -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<XOpts>` work unchanged.
*
* Optional props (`disabled?: boolean`) become `State<boolean>` /
* `Active<boolean>` — `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<string | undefined> }`.
*/
export type OptsFromProps<
P,
Managed extends keyof P,
StateKey extends Managed
> = {
id: Active<string>;
ref: State<HTMLElement | null>;
} & {
[K in StateKey]-?: State<Exclude<P[K], undefined>>;
} & {
[K in Exclude<Managed, StateKey>]-?: Active<Exclude<P[K], undefined>>;
};
/**
* 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 `<script>` blocks.
*/
export type WritableSpec<T = unknown> = { get: () => T; set: (value: T) => void };
/**
* Defaults to `any` rather than `unknown` so heterogeneous bag literals
* (e.g. mixing `{ get: () => HTMLElement | null }` with `{ get: () => boolean }`)
* type-check — function parameters are contravariant under `unknown` but
* bivariant under `any`, which is exactly what a config bag needs.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type PropsConfigEntry<T = any> = (() => T) | WritableSpec<T>;
// ── Runtime ──────────────────────────────────────────────────────────────────
function isWritableSpec(value: unknown): value is WritableSpec<unknown> {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as WritableSpec<unknown>).get === 'function' &&
typeof (value as WritableSpec<unknown>).set === 'function'
);
}
/**
* Wraps a config of getters / `{ get, set }` specs into the matching map
* of `Active` / `State` boxes. Each entry is wrapped independently so
* reactivity tracks per-key (vs. one shared subscription).
*
* ```ts
* const opts = bindProps({
* id: () => id,
* ref: { get: () => ref, set: (v) => (ref = v) },
* pressed: { get: () => pressed, set: (v) => (pressed = v) },
* disabled: () => disabled,
* 'aria-label': () => ariaLabel
* });
* ToggleProvider.create(opts);
* ```
*
* Return type is intentionally loose (`Record<string, Active<unknown>>`).
* The provider's `Opts` (typed via `OptsFromProps`) provides the structural
* contract; cast at the call-site if the compiler needs help.
*/
export function bindProps(
config: Record<string, PropsConfigEntry>
): Record<string, Active<unknown>> {
const result: Record<string, Active<unknown>> = {};
for (const key in config) {
const entry = config[key];
if (isWritableSpec(entry)) {
result[key] = writableActive(entry.get, entry.set);
} else {
result[key] = readableActive(entry as () => unknown);
}
}
return result;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.