diff --git a/src/uix/morfo/components/picker.ts b/src/uix/morfo/components/picker.ts new file mode 100644 index 000000000..86208a693 --- /dev/null +++ b/src/uix/morfo/components/picker.ts @@ -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; diff --git a/src/uix/soma/components/picker/README.md b/src/uix/soma/components/picker/README.md new file mode 100644 index 000000000..6f700e4df --- /dev/null +++ b/src/uix/soma/components/picker/README.md @@ -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 + + {value} + + + + + + +``` + +```ts +// inside the editor (a descendant of Picker.Provider) +const picker = PickerProvider.require() as PickerProvider; +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` — `workingValue: State`, `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. diff --git a/src/uix/soma/components/picker/components/picker.svelte b/src/uix/soma/components/picker/components/picker.svelte new file mode 100644 index 000000000..c8f511d78 --- /dev/null +++ b/src/uix/soma/components/picker/components/picker.svelte @@ -0,0 +1,92 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/picker/exports.ts b/src/uix/soma/components/picker/exports.ts new file mode 100644 index 000000000..39bcc7a6c --- /dev/null +++ b/src/uix/soma/components/picker/exports.ts @@ -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'; diff --git a/src/uix/soma/components/picker/index.ts b/src/uix/soma/components/picker/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/picker/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/picker/internals.ts b/src/uix/soma/components/picker/internals.ts new file mode 100644 index 000000000..7930fc64b --- /dev/null +++ b/src/uix/soma/components/picker/internals.ts @@ -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'; diff --git a/src/uix/soma/components/picker/picker-provider.svelte.ts b/src/uix/soma/components/picker/picker-provider.svelte.ts new file mode 100644 index 000000000..39d7c8f1b --- /dev/null +++ b/src/uix/soma/components/picker/picker-provider.svelte.ts @@ -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 = (value: TValue) => string | undefined; + +export interface PickerOpts + extends + ProviderOpts, + StateProps<{ + value: TValue; + open: boolean; + }>, + ActiveProps<{ + deferValue: boolean; + mode: PickerShellMode; + emptyValue: TValue; + disabled: boolean; + readonly: boolean; + required: boolean; + validate: PickerValidator | undefined; + onOpenChangeComplete: OnChangeFn | 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 { + readonly opts: PickerOpts; + readonly soma: Soma; + readonly runtime: SomaRuntime; + readonly runtimePart: SomaRuntimePart; + + // The Svelte context boundary is type-erased at runtime, so the shared + // context is typed ``; the instance stays strongly typed over TValue. + // Consumers narrow at the call site: `PickerProvider.require() as PickerProvider`. + static readonly ctx = context>('Picker'); + static get(): PickerProvider | undefined { + return this.ctx.getOr(undefined) as PickerProvider | undefined; + } + static require(): PickerProvider { + return this.ctx.get(); + } + static create(opts: PickerOpts) { + return new PickerProvider(opts); + } + + /** Deferred edit buffer — only authoritative while `deferValue` is on. */ + private draft = $state(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) { + 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 (``). + 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 = writableActive( + () => (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(() => { + 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 }; diff --git a/src/uix/soma/components/picker/types.ts b/src/uix/soma/components/picker/types.ts new file mode 100644 index 000000000..b87dc75f3 --- /dev/null +++ b/src/uix/soma/components/picker/types.ts @@ -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 `
` 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 = WithChild< + Omit & { + /** 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; + /** + * 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; + /** Called after the open/close animation completes. */ + onOpenChangeComplete?: OnChangeFn; + + // ── 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; + } +>; diff --git a/web/routes/uix/components/picker/+page.svelte b/web/routes/uix/components/picker/+page.svelte new file mode 100644 index 000000000..eaba7c518 --- /dev/null +++ b/web/routes/uix/components/picker/+page.svelte @@ -0,0 +1,170 @@ + + +
+
+
Generic · Picker
+

Picker · deferValue

+

+ A value-type-agnostic transactional picker. It owns a bound + value, an open flag and the + deferValue transaction; the popover anatomy is a composed + Popover and the in-popover editor is consumer-supplied, + binding to PickerProvider.workingValue. When + deferValue is on, edits live in an internal draft and the + bound value only updates on Aceptar; closing without + accepting discards. This standalone is the core that will later replace + the per-picker coordinators (date / color / time / range). +

+
+ + parts{compiled.parts.order.length} + + + valuestring + + + scopesoma + +
+
+ + +
+
+ + + {value ? `value: "${value}"` : 'pick a value'} + + + + + +
+
+ bound value · form sees "{value}" + + open + {String(open)} · defer + {String(deferValue)} + +
+
+ + +
+ + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Open the picker, type in the field, and watch the readout. In + deferred mode working updates live but + bound (what a form sees) only changes on + Aceptar. Cancelar or closing the + popover discards the draft. In live mode editing + writes the bound value immediately and Cancelar reverts to the + open-edge snapshot. +

+ +
+ soma props · transaction +
+
+ + +
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+

+ Source: src/uix/morfo/components/picker.ts. A single + provider part — open/close are delegated to the composed Popover, so + this morfo declares no dialog semantics and no events. +

+ +
+ + + + + + + + + +
FieldValue
name"{pickerMorfo.name}"
kebab"{pickerMorfo.kebab}"
scope[{pickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{pickerMorfo.parts.length}
events.length{pickerMorfo.events.length}
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementArchetypeStates
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}
+
+
+ {/if} +
diff --git a/web/routes/uix/components/picker/picker-demo-body.svelte b/web/routes/uix/components/picker/picker-demo-body.svelte new file mode 100644 index 000000000..8ab944c61 --- /dev/null +++ b/web/routes/uix/components/picker/picker-demo-body.svelte @@ -0,0 +1,68 @@ + + +
+ (picker.workingValue.current = e.currentTarget.value)} + /> + +
+ working "{picker.workingValue.current ?? ''}" + bound "{picker.opts.value.current ?? ''}" + dirty {String(picker.isDirty)} +
+ +
+ + + +
+
+ +