feat(picker): generic deferValue transactional value host

New value-type-agnostic Picker — the shared transactional core that will
later replace the five system pickers' duplicated coordinators (date /
color / time / range), parameterised by TValue.

- morfo/components/picker.ts — provider part only; open/close delegated to
  the composed Popover (no dialog semantics, no events).
- soma/components/picker — PickerProvider<TValue> + root wrapper (composes
  Popover). Exposes workingValue (draft-or-bound), commit/cancel/clear and a
  PickerShellHandle for the shared footer.
- web/routes/uix/components/picker — interactive deferValue testbed.

Unified transaction (the deferValue axis):
- deferValue=false: workingValue IS the bound value (live); cancel() reverts
  to the open-edge snapshot.
- deferValue=true: workingValue routes to an internal draft; the bound value
  only updates on commit() (Accept); any close that is not an explicit accept
  discards. The form / onValueChange only ever see accepted values.

Verified: npm run check 0 new errors. Browser — deferred edit/accept/cancel/
dismiss-discard + live edit + live cancel-revert all correct, no console errors.

Not committed (shared files carry a parallel session's WIP): the soma/components
barrel export + the docs sidebar entry. Demo imports the picker via subpath so
the commit is self-contained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 4438012198
commit ef4447e78e

@ -0,0 +1,67 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Picker — generic transactional value host.
*
* A value-type-agnostic picker shell: it owns a bindable `value`, an `open`
* flag, and the `deferValue` transaction. The popover anatomy (Trigger,
* Content, …) is delegated to a composed `Popover`, so this morfo declares
* no dialog semantics and no open/close events — they belong to the composed
* Popover (same pattern as date-picker, §2 "the Popover IS the dialog").
*
* The in-popover editor (the thing that actually edits the value) is
* consumer-supplied and binds to `PickerProvider.workingValue`. The footer
* actions (Accept / Cancel / Clear) flow through the shared `PickerShell`
* handle that the provider registers.
*
* Scope `soma` only — no perceptual events of its own yet (Accept = commit /
* Cancel = discard are candidate `commit-*` events for a later sema pass).
*/
export const pickerMorfo = {
name: 'Picker',
kebab: 'picker',
scope: ['soma'],
texts: {
label: '#?components.picker.label|Picker'
},
events: [],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: [
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-required',
value: v.propRef('required'),
severity: 'optional',
ariaBoolean: true
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,83 @@
# Picker
Generic **transactional value host** — a value-type-agnostic picker core. It
owns a bound `value`, an `open` flag and a single editing transaction; the
popover anatomy is a composed `Popover` and the in-popover editor is
consumer-supplied. This is the shared core intended to replace the per-family
picker coordinators (date / color / time / range), which today duplicate the
same `value + open + snapshot + commit/cancel/clear + PickerShell handle`
skeleton.
## The transaction — `deferValue`
`deferValue` selects the editing policy. Both policies share one
commit/cancel/clear surface, so the footer wiring (`PickerShell`) is identical
either way.
| `deferValue` | `workingValue` routes to | external observer of `value` sees | `cancel()` / dismiss |
| --- | --- | --- | --- |
| `false` (default) | the **bound** value (live) | every edit | reverts to the open-edge snapshot |
| `true` | an internal **draft** | **nothing until Accept** | discards the draft |
- The in-popover editor binds to **`PickerProvider.workingValue`** (draft-or-bound).
- The hidden form input / `onValueChange` observers read the **bound** value — so
in deferred mode the form only ever sees accepted values.
- `commit()` (Accept) flushes the draft into the bound value and closes.
- `cancel()`, and any close that is not an explicit Accept (outside-click,
Escape, programmatic), **discard** the edits.
- `clear()` resets the working value to `emptyValue`; it does **not** close.
The snapshot taken on the OPEN edge is what `cancel()` reverts to in live mode;
in deferred mode it re-seeds the draft.
## Anatomy
```svelte
<Picker.Provider bind:value bind:open deferValue mode="modal" emptyValue="">
<Picker.Trigger>{value}</Picker.Trigger>
<Picker.Content>
<!-- consumer editor: binds to PickerProvider.workingValue -->
<MyEditor />
<!-- footer: PickerShell.Close = Accept, .Cancel, .Clear -->
</Picker.Content>
</Picker.Provider>
```
```ts
// inside the editor (a descendant of Picker.Provider)
const picker = PickerProvider.require() as PickerProvider<MyValue>;
picker.workingValue.current; // read draft-or-bound
picker.workingValue.current = next; // write draft-or-bound
picker.isDirty; // draft ≠ committed (deferred only)
```
## Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `TValue` | — | Bound value. **Bindable.** |
| `onValueChange` | `(v: TValue) => void` | — | Fires on every edit (live) or once on Accept (deferred). |
| `deferValue` | `boolean` | `false` | Defer writes to an internal draft until Accept. |
| `mode` | `'inline' \| 'modal'` | `'inline'` | Popover dismissal — `modal` blocks outside-click + Escape. |
| `emptyValue` | `TValue` | `undefined` | Value `clear()` resets to. |
| `open` | `boolean` | `false` | Popover open. **Bindable.** |
| `disabled` / `readonly` / `required` | `boolean` | `false` | Projected as `data-*` on the provider root. |
| `validate` | `(v: TValue) => string \| undefined` | — | Runs over the working value; drives `isInvalid`. |
## Provider surface
`PickerProvider<TValue>` — `workingValue: State<TValue>`, `commit()`,
`cancel()`, `clear()`, `isDirty`, `isInvalid`, and a `PickerShellHandle`
registered on `pickerShellContext`.
## Sema events
None yet. Accept (`commit`) and Cancel (`discard`) are the natural candidate
`commit.confirm` / `commit.discard` events for a later sema pass — left out of
the first cut deliberately (open/close perception is delegated to the composed
Popover).
## Status
Standalone core. Migration of the five system pickers onto it (collapsing the
duplicated coordinators) is the follow-up step.

@ -0,0 +1,92 @@
<script lang="ts" generics="TValue">
/**
* Soma `Picker.Provider` — generic transactional value host. Sets up the
* PickerProvider (value / open / deferValue transaction) plus a composed
* PopoverProvider wired to the same `open` state. The in-popover editor is
* supplied by the consumer and binds to `PickerProvider.workingValue`.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { PickerProvider } from '../picker-provider.svelte';
import { PopoverProvider } from '../../popover/popover-provider.svelte';
import type { PickerProviderProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'picker'),
value = $bindable(),
onValueChange = () => {},
emptyValue = undefined,
open = $bindable(false),
onOpenChange = () => {},
onOpenChangeComplete = () => {},
deferValue = false,
mode = 'inline',
disabled = false,
readonly = false,
required = false,
validate,
ref = $bindable(null),
children,
child,
...restProps
}: PickerProviderProps<TValue> = $props();
// ── Shared state wrappers (bound to the consumer's bindables) ──────────────
const sharedValue = writableActive<TValue>(
() => value as TValue,
(v) => {
value = v as never;
onValueChange(v);
}
);
const sharedOpen = writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
);
// ── Picker context (transaction + working value) ──────────────────────────
const state = PickerProvider.create<TValue>({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: sharedValue,
open: sharedOpen,
deferValue: readableActive(() => deferValue),
mode: readableActive(() => mode),
emptyValue: readableActive(() => emptyValue as TValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
validate: readableActive(() => validate),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});
// ── Composed Popover wired to the shared open state ───────────────────────
// In modal mode the popover blocks outside-click + Escape; the user must
// accept / cancel from the footer.
PopoverProvider.create({
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => mode === 'modal')
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,13 @@
// Picker — generic transactional value host (deferValue). Root + composed
// Popover surface. The in-popover editor is consumer-supplied and binds to
// `PickerProvider.workingValue`; footer actions flow through `PickerShell`.
export { default as Provider } from './components/picker.svelte';
// Provider class (workingValue + commit / cancel / clear) + attrs.
export { PickerProvider, pickerAttrs, type PickerOpts, type PickerValidator } from './internals';
// Popover surface (shared via the composed PopoverProvider).
export { Trigger, Content, Arrow, Close, Overlay, Anchor } from '../popover/exports';
export type { PickerProviderProps as ProviderProps } from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,11 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/picker` via `exports.ts`.
export {
PickerProvider,
pickerAttrs,
type PickerOpts,
type PickerValidator
} from './picker-provider.svelte';

@ -0,0 +1,210 @@
import { untrack } from 'svelte';
import { watch } from 'runed';
import { context, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { writableActive, type ActiveProps, type State, type StateProps } from '$libs/reactive';
import type { OnChangeFn } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { pickerShellContext, type PickerShellHandle, type PickerShellMode } from '../picker-shell';
import { pickerMorfo } from '../../../morfo/components/picker';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(pickerMorfo);
/** Custom validator — return an error message when the value is invalid. */
export type PickerValidator<TValue> = (value: TValue) => string | undefined;
export interface PickerOpts<TValue>
extends
ProviderOpts,
StateProps<{
value: TValue;
open: boolean;
}>,
ActiveProps<{
deferValue: boolean;
mode: PickerShellMode;
emptyValue: TValue;
disabled: boolean;
readonly: boolean;
required: boolean;
validate: PickerValidator<TValue> | undefined;
onOpenChangeComplete: OnChangeFn<boolean> | undefined;
}> {}
/**
* Generic transactional picker coordinator.
*
* Owns a single value-editing transaction over a bound `value` plus an
* internal draft buffer. The `deferValue` flag selects the transaction
* policy; both share the same commit / cancel / clear surface so the footer
* wiring (`PickerShell`) is identical either way:
*
* - **`deferValue = false`** (default): `workingValue` IS the bound value;
* edits write through live. `cancel()` reverts to the snapshot captured on
* the OPEN edge. `commit()` just closes (the value is already live).
* - **`deferValue = true`**: `workingValue` routes to the internal `draft`;
* the bound value never changes until `commit()` flushes the draft into it.
* `cancel()` (or any close that is not an explicit accept) discards the
* draft.
*
* The in-popover editor binds to {@link workingValue}; the hidden form input
* / external `onValueChange` observers read the bound `value` — so in
* deferred mode the form only ever sees accepted values.
*/
export class PickerProvider<TValue> {
readonly opts: PickerOpts<TValue>;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
// The Svelte context boundary is type-erased at runtime, so the shared
// context is typed `<any>`; the instance stays strongly typed over TValue.
// Consumers narrow at the call site: `PickerProvider.require() as PickerProvider<MyValue>`.
static readonly ctx = context<PickerProvider<any>>('Picker');
static get(): PickerProvider<any> | undefined {
return this.ctx.getOr(undefined) as PickerProvider<any> | undefined;
}
static require(): PickerProvider<any> {
return this.ctx.get();
}
static create<TValue>(opts: PickerOpts<TValue>) {
return new PickerProvider<TValue>(opts);
}
/** Deferred edit buffer — only authoritative while `deferValue` is on. */
private draft = $state<TValue>(undefined as TValue);
/**
* Snapshot of the committed value captured on the OPEN edge. Drives
* `cancel()` in live mode and re-seeds the draft on discard.
*/
private valueOnOpen: TValue = undefined as TValue;
/**
* Set by `commit()` / `cancel()` so the close-edge watcher knows the
* transaction was already settled and skips the implicit-discard path.
*/
private settledClose = false;
private constructor(opts: PickerOpts<TValue>) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(pickerMorfo, {
states: { open: () => this.opts.open.current },
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
required: () => this.opts.required.current,
invalid: () => this.isInvalid
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: PickerProvider.ctx
});
// Seed the buffer from the committed value so an always-open (inline)
// picker starts coherent before any open edge fires.
this.draft = untrack(() => this.opts.value.current);
this.valueOnOpen = this.draft;
// Transaction boundaries follow the popover's open lifecycle.
watch(
() => this.opts.open.current,
(now, prev) => {
if (now && !prev) {
// OPEN edge — snapshot the committed value + seed the draft.
this.valueOnOpen = untrack(() => this.opts.value.current);
this.draft = this.valueOnOpen;
this.settledClose = false;
} else if (!now && prev) {
// CLOSE edge — a close that did NOT come from commit()/cancel()
// (outside-click, Escape, programmatic close) discards the edits.
// In deferred mode the bound value was never touched, so this is
// a no-op on the form; in live mode it stays as-is (today's
// inline behavior — no auto-revert on dismiss).
if (!this.settledClose && this.deferred) this.draft = this.valueOnOpen;
this.settledClose = false;
}
}
);
// Expose the commit/cancel/clear/mode handle to the shared PickerShell
// footer parts (`<PickerShell.Close/Cancel/Clear>`).
pickerShellContext.set(this.pickerShellHandle);
}
private get deferred(): boolean {
return this.opts.deferValue.current;
}
/**
* The value the in-popover editor reads and writes. In deferred mode it
* routes to the internal draft; otherwise it writes the bound value live
* (which fires the consumer's `onValueChange` upstream).
*/
readonly workingValue: State<TValue> = writableActive<TValue>(
() => (this.deferred ? this.draft : this.opts.value.current),
(v) => {
if (this.deferred) this.draft = v;
else this.opts.value.current = v;
}
);
/** Discard edits made since the popover opened. Does not close. */
private discardEdits(): void {
if (this.deferred) this.draft = this.valueOnOpen;
else this.opts.value.current = this.valueOnOpen;
}
readonly validationStatus = $derived.by<false | { reason: 'custom'; message: string }>(() => {
const message = this.opts.validate.current?.(this.workingValue.current);
return message ? { reason: 'custom', message } : false;
});
readonly isInvalid = $derived.by(() => this.validationStatus !== false);
/** True when the draft differs from the committed value (deferred mode only). */
readonly isDirty = $derived.by(
() => this.deferred && this.draft !== this.opts.value.current
);
// ── Footer actions (PickerShell handle) ───────────────────────────────────
/** Accept: flush the draft into the bound value (deferred) and close. */
commit(): void {
if (this.deferred) this.opts.value.current = this.draft;
this.settledClose = true;
this.opts.open.current = false;
}
/** Cancel: discard edits made since the popover opened and close. */
cancel(): void {
this.discardEdits();
this.settledClose = true;
this.opts.open.current = false;
}
/** Clear: reset the working value to `emptyValue`. Does NOT close. */
clear(): void {
this.workingValue.current = this.opts.emptyValue.current;
}
readonly pickerShellHandle: PickerShellHandle = {
getMode: () => this.opts.mode.current,
commit: () => this.commit(),
cancel: () => this.cancel(),
clear: () => this.clear()
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.renderProps()
} as const)
);
}
export { attrs as pickerAttrs };

@ -0,0 +1,75 @@
import type { Snippet } from 'svelte';
import type { WithChild, OnChangeFn, PrimitiveDivAttributes } from '../../types';
import type { PickerShellMode } from '../picker-shell';
import type { PickerValidator } from './internals';
/**
* Props for the root `Picker.Provider` — the generic transactional value host.
*
* Generic over the value type `TValue`. The root renders a `<div>` and accepts
* standard DOM passthrough; the popover surface (Trigger / Content / …) is
* composed from `Popover`, and the in-popover editor is consumer-supplied,
* binding to `PickerProvider.workingValue`.
*/
export type PickerProviderProps<TValue> = WithChild<
Omit<PrimitiveDivAttributes, 'value'> & {
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Children snippet. */
children?: Snippet;
// ── Value ──
/** Current value. Bindable. */
value?: TValue;
/**
* Called when the bound `value` changes. In deferred mode this only
* fires on accept (commit); in live mode it fires on every edit.
*/
onValueChange?: OnChangeFn<TValue>;
/**
* Value used by `clear()` to reset the working value. Should be the
* "empty" form for `TValue` (e.g. `''`, `undefined`, `{ start, end }`).
* @default undefined
*/
emptyValue?: TValue;
// ── Open state ──
/** Whether the popover is open. Bindable. @default false */
open?: boolean;
/** Called when open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Called after the open/close animation completes. */
onOpenChangeComplete?: OnChangeFn<boolean>;
// ── Transaction ──
/**
* Defer value writes. When `false` (default) edits write the bound
* `value` live and `cancel()` reverts to the open-edge snapshot. When
* `true` edits are buffered in an internal draft and the bound `value`
* only updates when the user accepts (`commit()`); closing without
* accepting discards the draft.
* @default false
*/
deferValue?: boolean;
/**
* Popover dismissal mode.
* - `'inline'` (default): outside-click + Escape close the popover.
* - `'modal'`: outside-click + Escape are blocked; the user must use
* the footer (Accept / Cancel).
* @default 'inline'
*/
mode?: PickerShellMode;
// ── State flags ──
/** Disable interaction. @default false */
disabled?: boolean;
/** Read-only: focusable but not editable. @default false */
readonly?: boolean;
/** Whether an associated form control is required. @default false */
required?: boolean;
// ── Validation ──
/** Custom validator — return an error message when invalid. Runs over the working value. */
validate?: PickerValidator<TValue>;
}
>;

@ -0,0 +1,170 @@
<script lang="ts">
import * as Picker from '$soma/components/picker';
import { compileMorfo } from '$uix/morfo';
import { pickerMorfo } from '@/uix/morfo/components/picker';
import PickerDemoBody from './picker-demo-body.svelte';
type Tab = 'live' | 'morfo';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
let value = $state('hello');
let open = $state(false);
let deferValue = $state(true);
let mode = $state<'inline' | 'modal'>('inline');
const compiled = compileMorfo(pickerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const contentStyle =
'background: var(--uix-surface, #fff); border: 1px solid var(--uix-line, #cbd5e1);' +
'border-radius: 10px; padding: var(--uix-space-3, 12px); box-shadow: 0 12px 32px rgb(15 23 42 / 0.16);';
const triggerStyle =
'padding: 7px 14px; border: 1px solid var(--uix-line, #cbd5e1); border-radius: 8px;' +
'background: var(--uix-surface, #fff); color: inherit; font: inherit; cursor: pointer;';
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Generic · Picker</div>
<h1 data-uix-page-title>Picker · deferValue</h1>
<p data-uix-page-lede>
A value-type-agnostic transactional picker. It owns a bound
<code>value</code>, an <code>open</code> flag and the
<code>deferValue</code> transaction; the popover anatomy is a composed
<code>Popover</code> and the in-popover editor is consumer-supplied,
binding to <code>PickerProvider.workingValue</code>. When
<code>deferValue</code> is on, edits live in an internal draft and the
bound value only updates on <strong>Aceptar</strong>; closing without
accepting discards. This standalone is the core that will later replace
the per-picker coordinators (date / color / time / range).
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span>string
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>soma
</span>
</div>
</header>
<!-- Live stage -->
<div data-uix-stage>
<div data-uix-stage-area>
<Picker.Provider bind:value bind:open {deferValue} {mode} emptyValue="">
<Picker.Trigger style={triggerStyle}>
{value ? `value: "${value}"` : 'pick a value'}
</Picker.Trigger>
<Picker.Content side="bottom" sideOffset={8} style={contentStyle}>
<PickerDemoBody />
</Picker.Content>
</Picker.Provider>
</div>
<div data-uix-stage-trace>
<span><span data-uix-stage-trace-key>bound value · form sees</span> "{value}"</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>open</span>
{String(open)} · <span data-uix-stage-trace-key>defer</span>
{String(deferValue)}
</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Open the picker, type in the field, and watch the readout. In
<strong>deferred</strong> mode <code>working</code> updates live but
<code>bound</code> (what a form sees) only changes on
<strong>Aceptar</strong>. <strong>Cancelar</strong> or closing the
popover discards the draft. In <strong>live</strong> mode editing
writes the bound value immediately and Cancelar reverts to the
open-edge snapshot.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · transaction
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>
deferValue <span data-uix-control-hint>draft until Accept</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={deferValue} />
<span data-uix-switch-label>{deferValue ? 'deferred' : 'live'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>mode <span data-uix-control-hint>popover dismissal</span></span>
<span data-uix-switch>
<input
type="checkbox"
checked={mode === 'modal'}
onchange={(e) => (mode = e.currentTarget.checked ? 'modal' : 'inline')}
/>
<span data-uix-switch-label>{mode}</span>
</span>
</label>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/picker.ts</code>. A single
provider part — open/close are delegated to the composed Popover, so
this morfo declares no dialog semantics and no events.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{pickerMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{pickerMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{pickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{pickerMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{pickerMorfo.events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Archetype</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,68 @@
<script lang="ts">
// Editor + footer rendered INSIDE the picker popover. Reads the provider
// from context, binds the input to `workingValue` (draft-or-bound), and
// wires Accept / Cancel / Clear to the transaction surface.
import { PickerProvider } from '$soma/components/picker';
const picker = PickerProvider.require() as PickerProvider<string>;
</script>
<div class="picker-body">
<input
type="text"
placeholder="edit the value…"
value={picker.workingValue.current ?? ''}
oninput={(e) => (picker.workingValue.current = e.currentTarget.value)}
/>
<div data-uix-stage-trace class="picker-readout">
<span><span data-uix-stage-trace-key>working</span> "{picker.workingValue.current ?? ''}"</span>
<span><span data-uix-stage-trace-key>bound</span> "{picker.opts.value.current ?? ''}"</span>
<span><span data-uix-stage-trace-key>dirty</span> {String(picker.isDirty)}</span>
</div>
<div class="picker-actions">
<button type="button" onclick={() => picker.clear()}>Limpiar</button>
<button type="button" onclick={() => picker.cancel()}>Cancelar</button>
<button type="button" data-accept onclick={() => picker.commit()}>Aceptar</button>
</div>
</div>
<style>
.picker-body {
display: flex;
flex-direction: column;
gap: var(--uix-space-2, 8px);
min-width: 18rem;
}
input {
padding: 6px 10px;
border: 1px solid var(--uix-line, #cbd5e1);
border-radius: 6px;
font: inherit;
background: var(--uix-surface, #fff);
color: inherit;
}
.picker-readout {
flex-wrap: wrap;
}
.picker-actions {
display: flex;
gap: var(--uix-space-2, 8px);
justify-content: flex-end;
}
button {
padding: 5px 12px;
border: 1px solid var(--uix-line, #cbd5e1);
border-radius: 6px;
background: var(--uix-surface, #fff);
color: inherit;
font: inherit;
cursor: pointer;
}
button[data-accept] {
background: var(--uix-accent, #3b5bdb);
color: #fff;
border-color: transparent;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.