date-range-picker: mode prop + footer button props + revert group chrome

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 0d164b54de
commit 383e2ff7f7

@ -31,6 +31,10 @@
onOpenChange = () => {}, onOpenChange = () => {},
onOpenChangeComplete = () => {}, onOpenChangeComplete = () => {},
closeOnRangeSelect = true, closeOnRangeSelect = true,
mode = 'inline',
clearButton = false,
cancelButton = false,
closeButton = false,
validate, validate,
onInvalid, onInvalid,
minValue, minValue,
@ -173,6 +177,10 @@
endReadonlySegments: readableActive(() => endReadonlySegments), endReadonlySegments: readableActive(() => endReadonlySegments),
required: readableActive(() => required), required: readableActive(() => required),
closeOnRangeSelect: readableActive(() => closeOnRangeSelect), closeOnRangeSelect: readableActive(() => closeOnRangeSelect),
mode: readableActive(() => mode),
clearButton: readableActive(() => clearButton),
cancelButton: readableActive(() => cancelButton),
closeButton: readableActive(() => closeButton),
granularity: readableActive(() => granularity), granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone), hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle), hourCycle: readableActive(() => hourCycle),
@ -197,11 +205,13 @@
}); });
// ── Popover wired to shared open state ──────────────────────────────────── // ── 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({ PopoverProvider.create({
id: readableActive(() => `${id}-popover`), id: readableActive(() => `${id}-popover`),
open: sharedOpen, open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete), onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => false) modal: readableActive(() => mode === 'modal')
}); });
// ── DateRangeField wired to shared value / config ───────────────────────── // ── DateRangeField wired to shared value / config ─────────────────────────

@ -80,6 +80,10 @@ function createDateRangePickerOpts() {
endReadonlySegments: state<EditableSegmentPart[]>([]), endReadonlySegments: state<EditableSegmentPart[]>([]),
required: state(false), required: state(false),
closeOnRangeSelect: state(true), closeOnRangeSelect: state(true),
mode: state<'inline' | 'modal'>('inline'),
clearButton: state(false),
cancelButton: state(false),
closeButton: state(false),
granularity: state<Granularity | undefined>(undefined), granularity: state<Granularity | undefined>(undefined),
hideTimeZone: state(false), hideTimeZone: state(false),
hourCycle: state<HourCycle | undefined>(undefined), hourCycle: state<HourCycle | undefined>(undefined),

@ -1,3 +1,5 @@
import { untrack } from 'svelte';
import { watch } from 'runed';
import { context, type ProviderOpts } from '../../provider'; import { context, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo'; import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, boolToStr, getDataOpenClosed } from '$adom'; 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 type { OnChangeFn, Direction } from '../../types';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { getLocalTimeZone, isBefore } from '$libs/days'; 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 { import type {
DateValue, DateValue,
DateRange, DateRange,
@ -49,6 +59,10 @@ interface DateRangePickerOpts
endReadonlySegments: EditableSegmentPart[]; endReadonlySegments: EditableSegmentPart[];
required: boolean; required: boolean;
closeOnRangeSelect: boolean; closeOnRangeSelect: boolean;
mode: DateRangePickerMode;
clearButton: boolean;
cancelButton: boolean;
closeButton: boolean;
granularity: Granularity | undefined; granularity: Granularity | undefined;
hideTimeZone: boolean; hideTimeZone: boolean;
hourCycle: HourCycle | undefined; hourCycle: HourCycle | undefined;
@ -93,6 +107,12 @@ export class DateRangePickerProvider {
return new DateRangePickerProvider(opts); 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) { private constructor(opts: DateRangePickerOpts) {
this.opts = opts; this.opts = opts;
this.soma = Soma.require(); this.soma = Soma.require();
@ -111,6 +131,16 @@ export class DateRangePickerProvider {
owner: this, owner: this,
context: DateRangePickerProvider.ctx 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(() => { readonly validationStatus = $derived.by(() => {
@ -148,13 +178,48 @@ export class DateRangePickerProvider {
readonly isInvalid = $derived.by(() => this.validationStatus !== false); 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 { handleRangeSelect(): void {
if (this.opts.mode.current === 'modal') return;
if (this.opts.closeOnRangeSelect.current) { if (this.opts.closeOnRangeSelect.current) {
this.opts.open.current = false; 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(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, ...this.runtimePart.props,

@ -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 Trigger } from './components/date-range-picker-trigger.svelte';
export { default as Calendar } from './components/date-range-picker-calendar.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 // Popover surface
export { Content, Arrow, Close, Overlay, Anchor } from '../popover/exports'; export { Content, Arrow, Close, Overlay, Anchor } from '../popover/exports';

@ -47,6 +47,34 @@ type DateRangePickerBaseProps = {
* Calendar. @default true * Calendar. @default true
*/ */
closeOnRangeSelect?: boolean; 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 ── // ── Validation ──
/** Custom range validator. */ /** Custom range validator. */

@ -14,6 +14,7 @@
import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar'; import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar';
import { popoverMorfo } from '@/uix/morfo/components/popover'; import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix'; import { getActiveUix } from '$active-uix';
import PickerActions from './picker-actions.svelte';
import { import {
CalendarDate, CalendarDate,
getLocalTimeZone, getLocalTimeZone,
@ -47,7 +48,9 @@
let stageRef = $state<HTMLElement | null>(null); let stageRef = $state<HTMLElement | null>(null);
let value = $state<DateRange | undefined>({ start: sprintStart, end: sprintEnd }); let value = $state<DateRange | undefined>({ start: sprintStart, end: sprintEnd });
let placeholder = $state<DateValue>(sprintStart); let placeholder = $state<DateValue>(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 startInput = $state(toDateInput(sprintStart));
let endInput = $state(toDateInput(sprintEnd)); let endInput = $state(toDateInput(sprintEnd));
let minInput = $state(toDateInput(defaultMin)); let minInput = $state(toDateInput(defaultMin));
@ -56,6 +59,10 @@
let maxDaysInput = $state('21'); let maxDaysInput = $state('21');
let allowSingleDay = $state(false); let allowSingleDay = $state(false);
let closeOnRangeSelect = $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 disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(true); let fixedWeeks = $state(true);
let pagedNavigation = $state(false); let pagedNavigation = $state(false);
@ -478,6 +485,10 @@
{readonly} {readonly}
{allowSingleDay} {allowSingleDay}
{closeOnRangeSelect} {closeOnRangeSelect}
{mode}
{clearButton}
{cancelButton}
{closeButton}
{disableDaysOutsideMonth} {disableDaysOutsideMonth}
{fixedWeeks} {fixedWeeks}
{pagedNavigation} {pagedNavigation}
@ -503,7 +514,7 @@
calendarLabel="Booking date range" calendarLabel="Booking date range"
> >
<DateRangePicker.Label>{labelText}</DateRangePicker.Label> <DateRangePicker.Label>{labelText}</DateRangePicker.Label>
<div class="range-field-row" data-date-range-field-group> <div class="range-field-row">
<DateRangePicker.Input type="start" name={startName} aria-label="Start date"> <DateRangePicker.Input type="start" name={startName} aria-label="Start date">
{#snippet children({ segments }: DateRangePickerInputSnippetProps)} {#snippet children({ segments }: DateRangePickerInputSnippetProps)}
{#each segments as { part, value }, index (`start-${part}-${index}`)} {#each segments as { part, value }, index (`start-${part}-${index}`)}
@ -680,6 +691,23 @@
{/each} {/each}
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-radio>
<input type="radio" bind:group={mode} value="inline" />
<span data-uix-radio-label>inline</span>
</span>
<span data-uix-radio>
<input type="radio" bind:group={mode} value="modal" />
<span data-uix-radio-label>modal</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>footer buttons</span>
<span data-uix-switch><input type="checkbox" bind:checked={clearButton} /><span data-uix-switch-label>clear</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={cancelButton} /><span data-uix-switch-label>cancel</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={closeButton} /><span data-uix-switch-label>close</span></span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>states</span> <span data-uix-control-label>states</span>
<span data-uix-switch><input type="checkbox" bind:checked={required} /><span data-uix-switch-label>required</span></span> <span data-uix-switch><input type="checkbox" bind:checked={required} /><span data-uix-switch-label>required</span></span>
@ -983,7 +1011,7 @@
<div class="range-months" data-month-count={months.length}> <div class="range-months" data-month-count={months.length}>
{#each months as month (month.value.toString())} {#each months as month (month.value.toString())}
<div class="range-month-panel"> <div class="range-month-panel"><!-- per-month title now lives in the header above -->
<DateRangePicker.Grid> <DateRangePicker.Grid>
<DateRangePicker.GridHead> <DateRangePicker.GridHead>
<DateRangePicker.GridRow> <DateRangePicker.GridRow>
@ -1017,6 +1045,7 @@
</div> </div>
{/each} {/each}
</div> </div>
<PickerActions />
{/snippet} {/snippet}
<style> <style>

@ -0,0 +1,111 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
// Footer for the DateRangePicker calendar. Lives inside the provider tree
// (rendered from the Calendar snippet) so it can pull options + helpers
// straight from context.
//
// Buttons are configured via props on the picker:
// - `clearButton` — Clear (reset value, keep open)
// - `cancelButton` — Cancel (revert to value-at-open, close)
// - `closeButton` — Close / Save (commit current value, close)
//
// In `mode='modal'` Close is forced on so the user always has a way out:
// modal popovers ignore outside-click and Escape.
//
// If no buttons are enabled (and the picker is not modal), this footer
// renders nothing.
const provider = DateRangePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
const showClear = $derived(provider.opts.clearButton.current);
const showCancel = $derived(provider.opts.cancelButton.current);
const showClose = $derived(provider.opts.closeButton.current || mode === 'modal');
const visible = $derived(showClear || showCancel || showClose);
</script>
{#if visible}
<div class="picker-footer" data-date-range-picker-footer data-mode={mode}>
{#if showClear}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.clear()}
>
Clear
</button>
{/if}
<div class="picker-footer-spacer"></div>
{#if showCancel}
<button
type="button"
class="picker-footer-button"
data-tone="ghost"
onclick={() => provider.cancel()}
>
Cancel
</button>
{/if}
{#if showClose}
<button
type="button"
class="picker-footer-button"
data-tone="solid"
onclick={() => provider.commit()}
>
Close
</button>
{/if}
</div>
{/if}
<style>
.picker-footer {
display: flex;
align-items: center;
gap: var(--uix-space-2, 0.5rem);
padding-block-start: var(--uix-space-3, 0.75rem);
margin-block-start: var(--uix-space-3, 0.75rem);
border-block-start: 1px solid var(--color-border-subtle);
}
.picker-footer-spacer {
flex: 1 1 auto;
}
.picker-footer-button {
inline-size: max-content;
min-block-size: var(--control-height-sm);
padding-inline: var(--uix-space-3, 0.75rem);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
font: inherit;
font-size: var(--uix-text-sm);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
.picker-footer-button[data-tone='ghost']:hover {
background: var(--color-surface-overlay);
}
.picker-footer-button[data-tone='solid'] {
border-color: var(--color-primary-border);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
}
.picker-footer-button[data-tone='solid']:hover {
background: var(--color-primary-solid-hover);
}
.picker-footer-button:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.