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
parent
6d805a4529
commit
eb0f128c3e
@ -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…
Reference in new issue