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
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"><{part.defaultElement}></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…
Reference in new issue