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.
svelte-kit-vice/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts

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 };

Powered by TurnKey Linux.