You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
225 lines
7.2 KiB
225 lines
7.2 KiB
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<boolean>;
|
|
}> {}
|
|
|
|
/**
|
|
* 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<DatePickerProvider>('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 };
|