diff --git a/src/uix/PENDIENTES.md b/src/uix/PENDIENTES.md index 3b11e3aa5..b6fa56c19 100644 --- a/src/uix/PENDIENTES.md +++ b/src/uix/PENDIENTES.md @@ -8,7 +8,7 @@ | Item | Disposición | Notas | | --- | --- | --- | -| Aplicar `mode: 'inline' \| 'modal'` + footer (`clearButton`/`cancelButton`/`closeButton`) a `date-picker` (single) | **implementar** | Mismo patrón que el range. Provider expone `clear/cancel/commit`. Modal fuerza closeButton. | +| Aplicar `mode: 'inline' \| 'modal'` + footer (`clearButton`/`cancelButton`/`closeButton`) a `date-picker` (single) | **hecho** 2026-05-21 | Patrón replicado del range. Provider expone `commit/cancel/clear` + snapshot vía `watch(open)`. Morfo expandido a 6 eventos (open/close-commit/close-cancel/close-dismiss/close-dismiss-outside/commit-clear) + scope:`['soma', 'sema']` + APG dialog-modal. Demo expone chips `mode` + 3 switches en footer. Pendiente verificación visual. | | Propagar el patrón modal+footer a `time-picker`, `time-range-picker`, `color-picker`, `combobox`, `select`, `popover` cuando proceda | **implementar** | Decidir por componente; muchos no necesitan footer. | | `MonthRangePicker` (Chakra-style) | **implementar** | Componente nuevo. Segments month/year + grid de meses, sin calendario de días. | | `YearRangePicker` | **implementar** | Componente nuevo. Segments year + grid de años. | diff --git a/src/uix/morfo/components/date-picker.ts b/src/uix/morfo/components/date-picker.ts index 5ce08c81a..0b4427818 100644 --- a/src/uix/morfo/components/date-picker.ts +++ b/src/uix/morfo/components/date-picker.ts @@ -1,16 +1,132 @@ import type { Morfo } from '../types'; import { v } from '../types'; +/** + * DatePicker — composite popover containing a DateField (segmented input) + * + Calendar. The morfo declares the picker's own surface (trigger + + * calendar shell + dismissal/commit events); cell-level events live on + * the `calendar` morfo. + */ export const datePickerMorfo = { name: 'DatePicker', kebab: 'date-picker', - scope: ['soma'], + scope: ['soma', 'sema'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.date-picker.label|Date Picker', trigger: '#?components.date-picker.trigger|Open calendar', calendar: '#?components.date-picker.calendar|Date calendar' }, + events: [ + { + name: 'open', + semantic: { + family: 'emerge', + verb: 'open', + target: v.partRef('calendar'), + sequence: 'pre', + intent: { + fromProp: 'intent', + default: 'neutral', + supported: ['neutral', 'affirm', 'risk'] + } + }, + commits: { + part: v.partRef('calendar'), + attr: 'data-state', + value: 'open' + } + }, + { + // The user committed a date and the popover closed. + // `fulfill` because the picker's job — pick a date — is done. + name: 'close-commit', + semantic: { + family: 'commit', + verb: 'save', + target: v.partRef('calendar'), + sequence: 'post', + intent: 'fulfill' + }, + regime: 'lock', + prewrite: [{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'committed' }], + commits: { + part: v.partRef('calendar'), + attr: 'data-state', + value: 'closed' + } + }, + { + // Explicit cancel from a Save/Cancel button (modal mode). + name: 'close-cancel', + semantic: { + family: 'emerge', + verb: 'close', + target: v.partRef('calendar'), + sequence: 'pre' + }, + regime: 'lock', + prewrite: [{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'cancelled' }], + commits: { + part: v.partRef('calendar'), + attr: 'data-state', + value: 'closed' + } + }, + { + // Escape key. + name: 'close-dismiss', + semantic: { + family: 'emerge', + verb: 'dismiss', + target: v.partRef('calendar'), + sequence: 'pre' + }, + regime: 'lock', + prewrite: [{ part: v.partRef('calendar'), attr: 'data-last-action', value: 'dismissed' }], + commits: { + part: v.partRef('calendar'), + attr: 'data-state', + value: 'closed' + } + }, + { + // Click outside the popover. + name: 'close-dismiss-outside', + semantic: { + family: 'emerge', + verb: 'dismiss', + target: v.partRef('calendar'), + sequence: 'pre' + }, + regime: 'lock', + prewrite: [ + { + part: v.partRef('calendar'), + attr: 'data-last-action', + value: 'dismissed-outside' + } + ], + commits: { + part: v.partRef('calendar'), + attr: 'data-state', + value: 'closed' + } + }, + { + // User cleared the date (explicit clear button or backspace). + name: 'commit-clear', + semantic: { + family: 'commit', + verb: 'reset', + target: v.partRef('calendar'), + sequence: 'post', + intent: 'neutral' + } + } + ], + focus: { initial: 'first-focusable', trap: true, @@ -28,11 +144,11 @@ export const datePickerMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, - { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-readonly', severity: 'optional' }, - { attr: 'data-required', severity: 'optional' }, - { attr: 'data-invalid', severity: 'optional' } + { 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' }, @@ -49,7 +165,7 @@ export const datePickerMorfo = { role: 'button', optional: false, states: ['open', 'closed'], - data: [{ attr: 'data-state', values: ['open', 'closed'] }], + data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }], aria: [ { attr: 'type', value: v.literal('button') }, { attr: 'aria-haspopup', value: v.literal('dialog') }, @@ -65,17 +181,56 @@ export const datePickerMorfo = { value: v.translationRef('#?components.date-picker.trigger|Open calendar'), severity: 'recommended' } + ], + keyboard: [ + { key: 'Enter', action: 'open' }, + { key: ' ', action: 'open' } ] }, { name: 'Calendar', kebab: 'calendar', + archetype: 'content', kind: 'public', defaultElement: 'div', role: 'dialog', optional: false, - data: [], - aria: [] + states: ['open', 'closed'], + data: [ + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, + { + /** + * Causal exit reason — mirror of dialog's `data-last-action`. + * Updated by `prewrite` BEFORE `data-state` flips to `closed`, + * so Sema can tint the exit animation per-action. + */ + attr: 'data-last-action', + values: ['committed', 'cancelled', 'dismissed', 'dismissed-outside'], + severity: 'optional' + }, + { + attr: 'data-starting-style', + severity: 'optional', + condition: { when: 'state-equals', state: 'open', value: 'starting' } + }, + { + attr: 'data-ending-style', + severity: 'optional', + condition: { when: 'state-equals', state: 'closed', value: 'ending' } + } + ], + aria: [ + { + attr: 'aria-label', + value: v.translationRef('#?components.date-picker.calendar|Date calendar'), + severity: 'recommended' + } + ], + keyboard: [ + { key: 'Escape', action: 'close' }, + { key: 'Tab', action: 'focus-next' }, + { key: 'Shift+Tab', action: 'focus-prev' } + ] } ] } as const satisfies Morfo; diff --git a/src/uix/soma/components/date-picker/components/date-picker.svelte b/src/uix/soma/components/date-picker/components/date-picker.svelte index 229d6e737..2394fde43 100644 --- a/src/uix/soma/components/date-picker/components/date-picker.svelte +++ b/src/uix/soma/components/date-picker/components/date-picker.svelte @@ -23,6 +23,10 @@ onOpenChange = () => {}, onOpenChangeComplete = () => {}, closeOnDateSelect = true, + mode = 'inline', + clearButton = false, + cancelButton = false, + closeButton = false, validate, onInvalid, minValue, @@ -133,6 +137,10 @@ readonlySegments: readableActive(() => readonlySegments), required: readableActive(() => required), closeOnDateSelect: readableActive(() => closeOnDateSelect), + mode: readableActive(() => mode), + clearButton: readableActive(() => clearButton), + cancelButton: readableActive(() => cancelButton), + closeButton: readableActive(() => closeButton), granularity: readableActive(() => granularity), hideTimeZone: readableActive(() => hideTimeZone), hourCycle: readableActive(() => hourCycle), @@ -158,11 +166,13 @@ }); // ── Popover provider wired to the shared open state ──────────────────────── + // In modal mode the popover ignores outside-click and Escape dismissal — + // the user must commit via Save/Close or revert via Cancel from the footer. PopoverProvider.create({ id: readableActive(() => `${id}-popover`), open: sharedOpen, onOpenChangeComplete: readableActive(() => onOpenChangeComplete), - modal: readableActive(() => false) + modal: readableActive(() => mode === 'modal') }); // ── DateField provider wired to the shared value / placeholder / config ─── diff --git a/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts b/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts index b90ba0851..5d9c77563 100644 --- a/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts @@ -20,7 +20,7 @@ import { Soma } from '$soma/core/soma.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; import type { Direction, OnChangeFn } from '../../types'; -import { DatePickerProvider } from './date-picker-provider.svelte'; +import { DatePickerProvider, type DatePickerMode } from './date-picker-provider.svelte'; function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { let result!: T; @@ -72,6 +72,10 @@ function createDatePickerOpts() { readonlySegments: state([]), required: state(false), closeOnDateSelect: state(true), + mode: state('inline'), + clearButton: state(false), + cancelButton: state(false), + closeButton: state(false), granularity: state(undefined), hideTimeZone: state(false), hourCycle: state(undefined), diff --git a/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts b/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts index 2bccf48ab..0c6d55dcf 100644 --- a/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts +++ b/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts @@ -1,3 +1,5 @@ +import { untrack } from 'svelte'; +import { watch } from 'runed'; import { context, type ProviderOpts } from '../../provider'; import { createAttrs } from '$uix/morfo'; import { boolToEmptyStrOrUndef, boolToStr, getDataOpenClosed } from '$adom'; @@ -16,6 +18,14 @@ import type { WeekStartsOn } from '$libs/days'; +/** + * Picker interaction mode. `inline` (default) auto-closes the popover when + * a date is selected (subject to `closeOnDateSelect`). `modal` keeps the + * popover open until the user explicitly confirms via the Save action in + * the footer; Cancel restores the value snapshot taken at open. + */ +export type DatePickerMode = 'inline' | 'modal'; + // ── Contract ───────────────────────────────────────────────────────────────── import { datePickerMorfo } from '../../../morfo/components/date-picker'; @@ -47,6 +57,10 @@ interface DatePickerOpts readonlySegments: EditableSegmentPart[]; required: boolean; closeOnDateSelect: boolean; + mode: DatePickerMode; + clearButton: boolean; + cancelButton: boolean; + closeButton: boolean; // Format / i18n granularity: Granularity | undefined; hideTimeZone: boolean; @@ -99,6 +113,12 @@ export class DatePickerProvider { return new DatePickerProvider(opts); } + /** + * Snapshot of `value` captured on the OPEN edge. Used by `cancel()` to + * revert any mid-session edits back to the value the popover opened with. + */ + private valueOnOpen: DateValue | undefined = undefined; + private constructor(opts: DatePickerOpts) { this.opts = opts; this.soma = Soma.require(); @@ -117,6 +137,16 @@ export class DatePickerProvider { owner: this, context: DatePickerProvider.ctx }); + + // Capture value on the opening edge so `cancel()` can revert. + watch( + () => this.opts.open.current, + (now, prev) => { + if (now && !prev) { + this.valueOnOpen = untrack(() => this.opts.value.current); + } + } + ); } readonly validationStatus = $derived.by(() => { @@ -137,13 +167,45 @@ export class DatePickerProvider { readonly isInvalid = $derived.by(() => this.validationStatus !== false); - /** Close the popover if `closeOnDateSelect` is `true`. Called by the Calendar. */ + /** + * Close the popover if `closeOnDateSelect` is `true` AND mode is inline. + * Modal mode never auto-closes on selection — the user must commit via + * the explicit Save action in the footer. + */ handleDateSelect(): void { + if (this.opts.mode.current === 'modal') return; if (this.opts.closeOnDateSelect.current) { this.opts.open.current = false; } } + // ── Action helpers (footer wiring) ──────────────────────────────────────── + + /** + * Commit the current value and close the popover. The default behavior + * for a "Save" or "Done" button in the footer, especially in modal mode. + */ + commit(): void { + this.opts.open.current = false; + } + + /** + * Discard any edits made since the popover opened and close. Used by + * the Cancel button in the footer. + */ + cancel(): void { + this.opts.value.current = this.valueOnOpen; + this.opts.open.current = false; + } + + /** + * Reset the value to undefined without closing the popover. Used by + * the Clear button in the footer. + */ + clear(): void { + this.opts.value.current = undefined; + } + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, diff --git a/src/uix/soma/components/date-picker/exports.ts b/src/uix/soma/components/date-picker/exports.ts index 788eec3a9..5a754f10d 100644 --- a/src/uix/soma/components/date-picker/exports.ts +++ b/src/uix/soma/components/date-picker/exports.ts @@ -3,6 +3,10 @@ export { default as Provider } from './components/date-picker.svelte'; export { default as Trigger } from './components/date-picker-trigger.svelte'; export { default as Calendar } from './components/date-picker-calendar.svelte'; +// Provider class (action helpers: commit / cancel / clear / handleDateSelect) +export { DatePickerProvider } from './date-picker-provider.svelte'; +export type { DatePickerMode } from './date-picker-provider.svelte'; + // Popover surface (shared via composed PopoverProvider) export { Content, diff --git a/src/uix/soma/components/date-picker/types.ts b/src/uix/soma/components/date-picker/types.ts index f497b2e22..7e578c85b 100644 --- a/src/uix/soma/components/date-picker/types.ts +++ b/src/uix/soma/components/date-picker/types.ts @@ -47,10 +47,39 @@ type DatePickerBaseProps = { onOpenChangeComplete?: OnChangeFn; /** * Whether the popover closes automatically when a date is selected in the - * Calendar. + * Calendar. Has no effect in `mode='modal'` — modal pickers never + * auto-close. * @default true */ closeOnDateSelect?: boolean; + /** + * Interaction mode. + * - `'inline'` (default): selection auto-applies; `closeOnDateSelect` + * controls whether the popover dismisses on selection. Footer actions + * are optional ergonomic affordances. + * - `'modal'`: selection edits a buffer; the popover stays open until + * the user confirms via the Save action in the footer. Cancel reverts + * to the value the popover opened with. + * @default 'inline' + */ + mode?: import('./date-picker-provider.svelte').DatePickerMode; + /** + * Whether the footer renders a Clear button (resets value, keeps popover + * open). @default false + */ + clearButton?: boolean; + /** + * Whether the footer renders a Cancel button (reverts to value-at-open + * and closes). @default false + */ + cancelButton?: boolean; + /** + * Whether the footer renders a Close/Save button (commits current value + * and closes). In `mode='modal'` this is forced ON so the user always has + * a way out — modal pickers don't dismiss on outside click or Escape. + * @default false (inline) · forced true (modal) + */ + closeButton?: boolean; // ── Validation ── /** Custom validator — return an error message when invalid. */ diff --git a/web/routes/uix/components/date-picker/+page.svelte b/web/routes/uix/components/date-picker/+page.svelte index 59e4cba6e..2aa76ac68 100644 --- a/web/routes/uix/components/date-picker/+page.svelte +++ b/web/routes/uix/components/date-picker/+page.svelte @@ -15,6 +15,7 @@ import { popoverMorfo } from '@/uix/morfo/components/popover'; import { getActiveUix } from '$active-uix'; import { CalendarDate, type DateMatcher, type DateValue, type WeekStartsOn } from '$libs/days'; + import PickerActions from './picker-actions.svelte'; const uix = getActiveUix(); void uix; @@ -49,6 +50,10 @@ let disabled = $state(false); let readonly = $state(false); let closeOnDateSelect = $state(true); + let mode = $state<'inline' | 'modal'>('inline'); + let clearButton = $state(false); + let cancelButton = $state(false); + let closeButton = $state(false); let disableDaysOutsideMonth = $state(true); let fixedWeeks = $state(false); let pagedNavigation = $state(false); @@ -420,6 +425,10 @@ {size} {variant} {color} + {mode} + {clearButton} + {cancelButton} + {closeButton} minValue={minValue} maxValue={maxValue} weekStartsOn={resolvedWeekStartsOn} @@ -585,6 +594,25 @@ disabled readonly + +