From 383e2ff7f764a5193eaa6ff68db769cb14aacfe1 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 20 May 2026 23:08:50 +0200 Subject: [PATCH] date-range-picker: mode prop + footer button props + revert group chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback batch from incidencia 2026-05-20: 1. Modal vs inline mode - New `mode: 'inline' | 'modal'` prop on the soma `DateRangePicker.Provider`. - Modal wires the popover `modal: true` → outside-click and Escape are ignored; user must commit via the footer Close button (or revert via Cancel). - Provider exposes helpers `clear()`, `cancel()`, `commit()` for the footer. `cancel()` restores the value snapshot taken on the OPEN edge (captured via a `watch` on `opts.open` true-edge transition). 2. Footer buttons as boolean props - `clearButton`, `cancelButton`, `closeButton` props on the picker. The footer renders only if at least one is true. In `mode='modal'` the closeButton is forced on (the user always needs an exit). - `picker-actions.svelte` in the demo reads the picker context via `DateRangePickerProvider.require()` and renders the enabled buttons against `provider.clear/cancel/commit`. 3. Range field shape (revert to Chakra-style two boxes) - Removed the `data-date-range-field-group` wrapper from the demo so the start and end inputs are rendered as two separate boxed fields with the icon embedded in the end box, matching Chakra's layout. - The recipe CSS rules for `data-date-range-field-group` stay available as an opt-in for consumers who prefer the unified pill. 4. Demo defaults - `open` starts at `false` so the picker exercises the real open/close flow when the user clicks the trigger — the segments are for direct keyboard entry, the popover is for visual exploration. - Mode toggle (inline / modal) + footer button switches surfaced as controls in the demo. 5. Plumbing - DateRangePickerOpts gains `mode`, `clearButton`, `cancelButton`, `closeButton` (StateProps for mode, ActiveProps for the booleans). - `DateRangePickerProvider` and `DateRangePickerMode` are now re- exported from the soma barrel for consumers that wire footer actions in the calendar tree. - Test factory updated to seed the new opts. Verified in browser: trigger opens; click-outside in modal mode is ignored; Close commits & closes; Cancel reverts to snapshot & closes; Clear empties the range & keeps open. Heading "May – June 2026" (year collapse) and centered per-calendar titles still working from the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/date-range-picker.svelte | 12 +- .../date-range-picker-provider.svelte.test.ts | 4 + .../date-range-picker-provider.svelte.ts | 67 ++++++++++- .../components/date-range-picker/exports.ts | 5 + .../components/date-range-picker/types.ts | 28 +++++ .../components/date-range-picker/+page.svelte | 35 +++++- .../date-range-picker/picker-actions.svelte | 111 ++++++++++++++++++ 7 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 web/routes/uix/components/date-range-picker/picker-actions.svelte diff --git a/src/uix/soma/components/date-range-picker/components/date-range-picker.svelte b/src/uix/soma/components/date-range-picker/components/date-range-picker.svelte index fbb85aa0d..11eef98f9 100644 --- a/src/uix/soma/components/date-range-picker/components/date-range-picker.svelte +++ b/src/uix/soma/components/date-range-picker/components/date-range-picker.svelte @@ -31,6 +31,10 @@ onOpenChange = () => {}, onOpenChangeComplete = () => {}, closeOnRangeSelect = true, + mode = 'inline', + clearButton = false, + cancelButton = false, + closeButton = false, validate, onInvalid, minValue, @@ -173,6 +177,10 @@ endReadonlySegments: readableActive(() => endReadonlySegments), required: readableActive(() => required), closeOnRangeSelect: readableActive(() => closeOnRangeSelect), + mode: readableActive(() => mode), + clearButton: readableActive(() => clearButton), + cancelButton: readableActive(() => cancelButton), + closeButton: readableActive(() => closeButton), granularity: readableActive(() => granularity), hideTimeZone: readableActive(() => hideTimeZone), hourCycle: readableActive(() => hourCycle), @@ -197,11 +205,13 @@ }); // ── Popover wired to 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') }); // ── DateRangeField wired to shared value / config ───────────────────────── diff --git a/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.test.ts b/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.test.ts index 76210e5da..2ad88b8f3 100644 --- a/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.test.ts @@ -80,6 +80,10 @@ function createDateRangePickerOpts() { endReadonlySegments: state([]), required: state(false), closeOnRangeSelect: state(true), + mode: state<'inline' | 'modal'>('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-range-picker/date-range-picker-provider.svelte.ts b/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.ts index 790fbb665..f9857035d 100644 --- a/src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.ts +++ b/src/uix/soma/components/date-range-picker/date-range-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'; @@ -5,6 +7,14 @@ import { type ActiveProps, type StateProps } from '$libs/reactive'; import type { OnChangeFn, Direction } from '../../types'; import { Soma } from '../../core/soma.svelte'; import { getLocalTimeZone, isBefore } from '$libs/days'; + +/** + * Picker interaction mode. `inline` (default) auto-closes the popover when + * a complete range is selected (subject to `closeOnRangeSelect`). `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 DateRangePickerMode = 'inline' | 'modal'; import type { DateValue, DateRange, @@ -49,6 +59,10 @@ interface DateRangePickerOpts endReadonlySegments: EditableSegmentPart[]; required: boolean; closeOnRangeSelect: boolean; + mode: DateRangePickerMode; + clearButton: boolean; + cancelButton: boolean; + closeButton: boolean; granularity: Granularity | undefined; hideTimeZone: boolean; hourCycle: HourCycle | undefined; @@ -93,6 +107,12 @@ export class DateRangePickerProvider { return new DateRangePickerProvider(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: DateRange | undefined = undefined; + private constructor(opts: DateRangePickerOpts) { this.opts = opts; this.soma = Soma.require(); @@ -111,6 +131,16 @@ export class DateRangePickerProvider { owner: this, context: DateRangePickerProvider.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(() => { @@ -148,13 +178,48 @@ export class DateRangePickerProvider { readonly isInvalid = $derived.by(() => this.validationStatus !== false); - /** Close the popover if `closeOnRangeSelect` is true. Called by the calendar wrapper. */ + /** + * Close the popover if `closeOnRangeSelect` 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. + */ handleRangeSelect(): void { + if (this.opts.mode.current === 'modal') return; if (this.opts.closeOnRangeSelect.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 { + const snapshot = this.valueOnOpen; + if (snapshot) { + this.opts.value.current = { ...snapshot }; + } + this.opts.open.current = false; + } + + /** + * Reset the value to an empty range without closing the popover. Used + * by the Clear button in the footer. + */ + clear(): void { + this.opts.value.current = { start: undefined, end: undefined }; + } + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, diff --git a/src/uix/soma/components/date-range-picker/exports.ts b/src/uix/soma/components/date-range-picker/exports.ts index 001edae14..0451d4470 100644 --- a/src/uix/soma/components/date-range-picker/exports.ts +++ b/src/uix/soma/components/date-range-picker/exports.ts @@ -3,6 +3,11 @@ export { default as Provider } from './components/date-range-picker.svelte'; export { default as Trigger } from './components/date-range-picker-trigger.svelte'; export { default as Calendar } from './components/date-range-picker-calendar.svelte'; +// Provider class — required by consumers that wire footer actions +// (Clear / Cancel / Save) inside the calendar tree via `.require()`. +export { DateRangePickerProvider } from './date-range-picker-provider.svelte'; +export type { DateRangePickerMode } from './date-range-picker-provider.svelte'; + // Popover surface export { Content, Arrow, Close, Overlay, Anchor } from '../popover/exports'; diff --git a/src/uix/soma/components/date-range-picker/types.ts b/src/uix/soma/components/date-range-picker/types.ts index 5f16981fc..e8e32d7e6 100644 --- a/src/uix/soma/components/date-range-picker/types.ts +++ b/src/uix/soma/components/date-range-picker/types.ts @@ -47,6 +47,34 @@ type DateRangePickerBaseProps = { * Calendar. @default true */ closeOnRangeSelect?: boolean; + /** + * Interaction mode. + * - `'inline'` (default): selection auto-applies; `closeOnRangeSelect` + * controls whether the popover dismisses on range completion. 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-range-picker-provider.svelte').DateRangePickerMode; + /** + * 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 range validator. */ diff --git a/web/routes/uix/components/date-range-picker/+page.svelte b/web/routes/uix/components/date-range-picker/+page.svelte index c2128be7a..e78d49f68 100644 --- a/web/routes/uix/components/date-range-picker/+page.svelte +++ b/web/routes/uix/components/date-range-picker/+page.svelte @@ -14,6 +14,7 @@ import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar'; import { popoverMorfo } from '@/uix/morfo/components/popover'; import { getActiveUix } from '$active-uix'; + import PickerActions from './picker-actions.svelte'; import { CalendarDate, getLocalTimeZone, @@ -47,7 +48,9 @@ let stageRef = $state(null); let value = $state({ start: sprintStart, end: sprintEnd }); let placeholder = $state(sprintStart); - let open = $state(true); + // Popover starts closed: the field's segments are for direct keyboard + // entry; the popover is only for visual exploration via the trigger icon. + let open = $state(false); let startInput = $state(toDateInput(sprintStart)); let endInput = $state(toDateInput(sprintEnd)); let minInput = $state(toDateInput(defaultMin)); @@ -56,6 +59,10 @@ let maxDaysInput = $state('21'); let allowSingleDay = $state(false); let closeOnRangeSelect = $state(false); + 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(true); let pagedNavigation = $state(false); @@ -478,6 +485,10 @@ {readonly} {allowSingleDay} {closeOnRangeSelect} + {mode} + {clearButton} + {cancelButton} + {closeButton} {disableDaysOutsideMonth} {fixedWeeks} {pagedNavigation} @@ -503,7 +514,7 @@ calendarLabel="Booking date range" > {labelText} -
+
{#snippet children({ segments }: DateRangePickerInputSnippetProps)} {#each segments as { part, value }, index (`start-${part}-${index}`)} @@ -680,6 +691,23 @@ {/each} + +