diff --git a/src/uix/morfo/components/toggle.ts b/src/uix/morfo/components/toggle.ts
index 9f5467d4e..2c5a43147 100644
--- a/src/uix/morfo/components/toggle.ts
+++ b/src/uix/morfo/components/toggle.ts
@@ -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',
diff --git a/src/uix/soma/components/toggle/components/toggle.svelte b/src/uix/soma/components/toggle/components/toggle.svelte
index de429abbc..bd2f84eae 100644
--- a/src/uix/soma/components/toggle/components/toggle.svelte
+++ b/src/uix/soma/components/toggle/components/toggle.svelte
@@ -1,5 +1,5 @@
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 `