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'; import { type ActiveProps, type StateProps } from '$libs/reactive'; import type { OnChangeFn, Direction } from '../../types'; import { Soma } from '../../core/soma.svelte'; import { isBefore } from '$libs/days'; import type { DateValue, DateMatcher, DateOnInvalid, DateValidator, EditableSegmentPart, Granularity, HourCycle, 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'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(datePickerMorfo); // ── Provider (context — shared state for DateField + Popover + Calendar) ──── interface DatePickerOpts extends ProviderOpts, StateProps<{ value: DateValue | undefined; placeholder: DateValue; open: boolean; }>, ActiveProps<{ // Validation validate: DateValidator | undefined; onInvalid: DateOnInvalid | undefined; minValue: DateValue | undefined; maxValue: DateValue | undefined; isDateDisabled: DateMatcher | undefined; isDateUnavailable: DateMatcher | undefined; // State flags disabled: boolean; readonly: boolean; readonlySegments: EditableSegmentPart[]; required: boolean; closeOnDateSelect: boolean; mode: DatePickerMode; clearButton: boolean; cancelButton: boolean; closeButton: boolean; // Format / i18n granularity: Granularity | undefined; hideTimeZone: boolean; hourCycle: HourCycle | undefined; locale: string | undefined; dir: Direction; // Calendar options pagedNavigation: boolean; weekStartsOn: WeekStartsOn | undefined; weekdayFormat: Intl.DateTimeFormatOptions['weekday']; fixedWeeks: boolean; numberOfMonths: number; calendarLabel: string | undefined; disableDaysOutsideMonth: boolean; preventDeselect: boolean; initialFocus: boolean; monthFormat: Intl.DateTimeFormatOptions['month'] | ((month: number) => string); yearFormat: Intl.DateTimeFormatOptions['year'] | ((year: number) => string); showWeekNumbers: boolean; weekNumberFormat: 'iso' | 'simple'; isDateHoliday: DateMatcher | undefined; announceNavigation: boolean; // ARIA errorMessageId: string | undefined; // Callbacks onOpenChangeComplete: OnChangeFn; }> {} /** * Shared-state coordinator for DatePicker. The wrapper also creates a * `PopoverProvider` and a `DateFieldProvider` pointing at the same reactive * sources; `DatePicker.Calendar` creates a `CalendarProvider` likewise. This * class exists so every sub-part can reach the same value / placeholder / * open flags through a single context. */ export class DatePickerProvider { readonly opts: DatePickerOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; readonly runtime: SomaRuntime; static readonly ctx = context('DatePicker'); static get(): DatePickerProvider | undefined { return this.ctx.getOr(undefined) as DatePickerProvider | undefined; } static require(): DatePickerProvider { return this.ctx.get(); } static create(opts: DatePickerOpts) { 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(); this.runtime = this.soma.runtime(datePickerMorfo, { 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: 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(() => { const value = this.opts.value.current; if (!value) return false as const; const message = this.opts.validate.current?.(value); if (message) return { reason: 'custom', message } as const; const minValue = this.opts.minValue.current; if (minValue && isBefore(value, minValue)) return { reason: 'min' } as const; const maxValue = this.opts.maxValue.current; if (maxValue && isBefore(maxValue, value)) return { reason: 'max' } as const; return false; }); readonly isInvalid = $derived.by(() => this.validationStatus !== false); /** * 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, 'data-state': getDataOpenClosed(this.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current), 'data-required': boolToEmptyStrOrUndef(this.opts.required.current), 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), 'aria-disabled': boolToStr(this.opts.disabled.current), 'aria-readonly': boolToStr(this.opts.readonly.current), 'aria-required': boolToStr(this.opts.required.current) } as const) ); } export { attrs as datePickerAttrs };