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/calendar/calendar-provider.svelte.ts

1134 lines
39 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { context, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { CALENDAR_LANGS } from './langs';
import {
type DateValue,
type DateMatcher,
type Month,
type WeekStartsOn,
type DateOrder,
isSameDay,
startOfMonth,
endOfMonth,
getLocalTimeZone,
CalendarDate,
today,
resolveDateOrder
} from '$libs/days';
import {
applyMultipleSelection,
applySingleSelection,
computeDateFlags,
createMonths,
getIsoWeekNumber,
getSimpleWeekNumber,
getWeekdays,
isOutsideMonth,
isToday,
isWeekend,
shiftDate,
validateCalendarBounds
} from './helpers';
import type {
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarMonthSelectSnippetProps,
CalendarView,
CalendarYearSelectSnippetProps
} from './types';
// ── Contract ─────────────────────────────────────────────────────────────────
import { calendarMorfo } from '../../../morfo/components/calendar';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(calendarMorfo);
// ── Root ────────────────────────────────────────────────────────────────────
export interface CalendarOpts
extends
WithRefOpts,
StateProps<{
placeholder: DateValue;
value: DateValue | DateValue[] | undefined;
}>,
ActiveProps<{
type: 'single' | 'multiple';
preventDeselect: boolean;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
minViewValue: DateValue | undefined;
maxViewValue: DateValue | undefined;
disabled: boolean;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions['weekday'];
isDateDisabled: DateMatcher | undefined;
isDateUnavailable: DateMatcher | undefined;
isDateHoliday: DateMatcher | undefined;
fixedWeeks: boolean;
numberOfMonths: number;
calendarLabel: string | undefined;
readonly: boolean;
disableDaysOutsideMonth: boolean;
maxDays: number | undefined;
monthFormat: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
showWeekNumbers: boolean;
weekNumberFormat: 'iso' | 'simple';
announceNavigation: boolean;
initialFocus: boolean;
dir: 'ltr' | 'rtl';
locale: string;
}> {
onValueChange?: OnChangeFn<DateValue | DateValue[] | undefined>;
}
/**
* Resolve the initial placeholder when the consumer has not supplied one.
* Prefers the current selection, falls back to today. Exposed so the wrapper
* can stay thin instead of mutating props.
*/
export function resolveInitialCalendarPlaceholder(
placeholder: DateValue | undefined,
value: DateValue | DateValue[] | undefined,
type: 'single' | 'multiple'
): DateValue {
if (placeholder) return placeholder;
if (type === 'single' && value && !Array.isArray(value)) return value;
if (type === 'multiple' && Array.isArray(value) && value.length > 0) {
return value[value.length - 1];
}
const t = today(getLocalTimeZone());
return new CalendarDate(t.year, t.month, t.day);
}
function sameDateArray(a: DateValue[], b: DateValue[]): boolean {
if (a.length !== b.length) return false;
return a.every((date, index) => isSameDay(date, b[index]));
}
export class CalendarProvider {
readonly opts: CalendarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<CalendarProvider>('Calendar');
static get(): CalendarProvider | undefined {
return this.ctx.getOr(undefined) as CalendarProvider | undefined;
}
static require(): CalendarProvider {
return this.ctx.get();
}
static create(opts: CalendarOpts) {
return new CalendarProvider(opts);
}
/** Child ids exposed so the Grid can wire aria-labelledby. */
headingId = state('');
/** Live-region text for announcements. */
announcement = $state('');
/**
* Navigation view (Ark/Mantine/MUI pattern): the day grid switches in-place
* to a month grid then a year grid for fast navigation — no native `<select>`,
* no nested popover. The `MonthView` / `YearView` parts render the reused
* `MonthGrid` / `YearGrid` when active; the `Heading` advances the view.
*/
view = $state<CalendarView>('day');
private lastLoggedValidationKey: string | null = null;
private constructor(opts: CalendarOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(calendarMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: CalendarProvider.ctx,
// Source for the morfo's `aria-disabled` (propRef('disabled')) so
// renderProps() resolves it instead of the provider re-declaring it.
props: { disabled: () => opts.disabled.current }
});
// Initial focus management used to live in the wrapper. Move it here so
// the wrapper stays thin and the focus contract is centralized.
$effect(() => {
if (!opts.initialFocus.current) return;
const root = opts.ref.current;
if (!root) return;
queueMicrotask(() => {
const el = root.querySelector<HTMLElement>(`[${attrs.day}][data-focused]`);
this.soma.dom.focus(el);
});
});
$effect(() => {
const status = this.validationStatus;
if (status === false) {
this.lastLoggedValidationKey = null;
return;
}
const key =
status.reason === 'selection-bounds'
? `${status.reason}:${status.minValue.toString()}:${status.maxValue.toString()}`
: `${status.reason}:${status.minViewValue.toString()}:${status.maxViewValue.toString()}`;
if (key === this.lastLoggedValidationKey) return;
this.lastLoggedValidationKey = key;
this.soma.logger.error('soma.calendar', status.message, {
context:
status.reason === 'selection-bounds'
? {
reason: status.reason,
minValue: status.minValue.toString(),
maxValue: status.maxValue.toString()
}
: {
reason: status.reason,
minViewValue: status.minViewValue.toString(),
maxViewValue: status.maxViewValue.toString()
}
});
});
}
// ── Derived reactive model ────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma.langs.getLocale() || 'en'
);
readonly months: Month<DateValue>[] = $derived.by(() =>
createMonths(this.opts.placeholder.current, {
numberOfMonths: Math.max(1, this.opts.numberOfMonths.current),
weekStartsOn: this.opts.weekStartsOn.current,
fixedWeeks: this.opts.fixedWeeks.current,
locale: this.effectiveLocale
})
);
readonly weekStartsOnResolved = $derived.by(() => {
const custom = this.opts.weekStartsOn.current;
if (custom !== undefined) return custom;
// Locale default — derive from the first cell produced by the month
// generator (its day-of-week in en-US is the start).
const first = this.months[0]?.weeks[0]?.[0];
if (!first) return 0;
const dow = first.toDate(getLocalTimeZone()).getDay();
return dow as WeekStartsOn;
});
readonly weekdays = $derived.by(() =>
getWeekdays(this.effectiveLocale, this.weekStartsOnResolved, this.opts.weekdayFormat.current)
);
/**
* Resolved date-segment order:
* 1. explicit `locale` prop → resolve from that locale (prop wins)
* 2. app-wide `soma.dates.getDateOrder()` (when no prop)
* 3. derive from the effective locale as last fallback
* Matches the precedence used by DateField so a prop-driven Calendar
* formats consistently with its sibling DateField in the same locale.
*/
readonly dateOrder: DateOrder = $derived.by(() => {
if (this.opts.locale.current) return resolveDateOrder(this.effectiveLocale);
const fromService = this.soma.dates?.getDateOrder?.();
if (fromService) return fromService as DateOrder;
return resolveDateOrder(this.effectiveLocale);
});
/**
* Visible months as bare DateValues (first day of month). Used for
* prev/next button bound checks so we don't need to recreate
* `months` just for comparisons.
*/
readonly visibleMonths = $derived.by(() => this.months.map((m) => m.value));
/** Current value normalized to an array for uniform operations. */
readonly selectedDates: DateValue[] = $derived.by(() => {
const v = this.opts.value.current;
if (!v) return [];
return Array.isArray(v) ? v : [v];
});
readonly boundsValidationStatus = $derived.by(() =>
validateCalendarBounds({
minValue: this.opts.minValue.current,
maxValue: this.opts.maxValue.current,
minViewValue: this.opts.minViewValue.current,
maxViewValue: this.opts.maxViewValue.current
})
);
readonly validationStatus = $derived.by(() => this.boundsValidationStatus);
readonly validationMessage = $derived.by(() =>
this.validationStatus === false ? undefined : this.validationStatus.message
);
readonly selectionInvalid = $derived.by(() => {
for (const d of this.selectedDates) {
const flags = this.computeFlags(d);
if (flags.disabled || flags.unavailable) return true;
}
return false;
});
readonly isInvalid = $derived.by(() => this.validationStatus !== false || this.selectionInvalid);
readonly headingValue = $derived.by(() => {
const first = this.visibleMonths[0];
if (!first) return '';
const month = this.formatMonth(first.month);
const year = this.formatYear(first.year);
if (this.visibleMonths.length > 1) {
const last = this.visibleMonths[this.visibleMonths.length - 1];
const lastMonth = this.formatMonth(last.month);
const lastYear = this.formatYear(last.year);
return `${month} ${year} – ${lastMonth} ${lastYear}`;
}
return `${month} ${year}`;
});
readonly fullCalendarLabel = $derived.by(() => {
const base =
this.opts.calendarLabel.current ?? this.soma.langs.ts(CALENDAR_LANGS.LABEL) ?? 'Calendar';
return `${base}, ${this.headingValue}`;
});
// ── Navigation ────────────────────────────────────────────────────────────
private canShowPlaceholder(next: DateValue): boolean {
const min = this.opts.minViewValue.current;
if (min && endOfMonth(next).compare(min) < 0) return false;
const max = this.opts.maxViewValue.current;
if (!max) return true;
const last = next.add({ months: Math.max(1, this.opts.numberOfMonths.current) - 1 });
return last.compare(max) <= 0;
}
private setPlaceholder(next: DateValue, target?: HTMLElement): void {
const nextMonth = startOfMonth(next);
if (!this.canShowPlaceholder(nextMonth)) return;
if (this.opts.placeholder.current.compare(nextMonth) === 0) return;
this.opts.placeholder.current = nextMonth;
this.announceNavigation();
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
}
prevPage(target?: HTMLElement): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.setPlaceholder(
startOfMonth(this.opts.placeholder.current.subtract({ months: step })),
target
);
}
nextPage(target?: HTMLElement): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.setPlaceholder(startOfMonth(this.opts.placeholder.current.add({ months: step })), target);
}
prevYear(target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.subtract({ years: 1 }), target);
}
nextYear(target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.add({ years: 1 }), target);
}
setMonth(month: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ month }), target);
}
setYear(year: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ year }), target);
}
// ── Navigation view (day ↔ month ↔ year) ──────────────────────────────────
setView(view: CalendarView): void {
this.view = view;
}
/** Heading click: drill UP one level (day → month → year). */
advanceView(): void {
this.view = this.view === 'day' ? 'month' : 'year';
}
/** Month grid select: set the placeholder year+month and drill back DOWN to days. */
selectMonthView(year: number, month: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ year, month }), target);
this.view = 'day';
}
/** Year grid select: set the placeholder year and drill back DOWN to months. */
selectYearView(year: number, target?: HTMLElement): void {
this.setYear(year, target);
this.view = 'month';
}
private announceNavigation(): void {
if (!this.opts.announceNavigation.current) return;
this.announcement = this.headingValue;
}
// ── Bound checks ──────────────────────────────────────────────────────────
readonly isPrevButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const min = this.opts.minViewValue.current;
if (!min) return false;
const prev = this.visibleMonths[0]?.subtract({ months: 1 });
return prev ? endOfMonth(prev).compare(min) < 0 : false;
});
readonly isNextButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const max = this.opts.maxViewValue.current;
if (!max) return false;
const last = this.visibleMonths[this.visibleMonths.length - 1];
const next = last?.add({ months: 1 });
return next ? next.compare(max) > 0 : false;
});
// ── Selection ────────────────────────────────────────────────────────────
/**
* Handle activation of a date (click or Enter/Space on a Day). Respects
* readonly, disabled, unavailable. Dispatches to single/multiple handler
* and calls the `onValueChange` callback configured in the wrapper.
*/
select(date: DateValue, target?: HTMLElement): void {
if (this.opts.readonly.current || this.opts.disabled.current) return;
const flags = this.computeFlags(date);
if (flags.disabled || flags.unavailable) return;
const type = this.opts.type.current;
const preventDeselect = this.opts.preventDeselect.current;
let eventName: 'commit-select' | 'commit-unselect';
if (type === 'single') {
const prev = this.opts.value.current as DateValue | undefined;
const next = applySingleSelection(prev, date, preventDeselect);
if (prev && next && isSameDay(prev, next)) return;
if (!prev && !next) return;
this.opts.value.current = next;
eventName = next ? 'commit-select' : 'commit-unselect';
} else {
const prev = (this.opts.value.current as DateValue[] | undefined) ?? [];
const next = applyMultipleSelection(prev, date, preventDeselect, this.opts.maxDays.current);
if (sameDateArray(prev, next)) return;
this.opts.value.current = next;
eventName = next.some((d) => isSameDay(d, date)) ? 'commit-select' : 'commit-unselect';
}
void this.runtime.trigger(eventName, target ? { fallbackTarget: target } : undefined);
}
isSelected(date: DateValue): boolean {
for (const d of this.selectedDates) if (isSameDay(d, date)) return true;
return false;
}
computeFlags(date: DateValue) {
return computeDateFlags(date, {
minValue: this.opts.minValue.current,
maxValue: this.opts.maxValue.current,
disabled: this.opts.disabled.current,
isDateDisabled: this.opts.isDateDisabled.current,
isDateUnavailable: this.opts.isDateUnavailable.current,
isDateHoliday: this.opts.isDateHoliday.current
});
}
// ── Keyboard ──────────────────────────────────────────────────────────────
/**
* Keyboard routing for the root element — delegated to by Day onkeydown.
* Returns true when the event was handled (caller must preventDefault).
*/
handleDayKeydown(e: SomaKeyboardEvent<HTMLElement>, date: DateValue): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
'horizontal'
);
let target: DateValue | undefined;
if (e.key === horizNext) target = shiftDate(date, 1);
else if (e.key === horizPrev) target = shiftDate(date, -1);
else if (e.key === KEYS.ARROW_DOWN) target = shiftDate(date, 7);
else if (e.key === KEYS.ARROW_UP) target = shiftDate(date, -7);
else if (e.key === KEYS.HOME) {
const monthStart = startOfMonth(date);
target = monthStart;
} else if (e.key === KEYS.END) {
target = endOfMonth(date);
} else if (e.key === KEYS.PAGE_UP) {
target = e.shiftKey ? date.subtract({ years: 1 }) : date.subtract({ months: 1 });
} else if (e.key === KEYS.PAGE_DOWN) {
target = e.shiftKey ? date.add({ years: 1 }) : date.add({ months: 1 });
} else if (e.key === KEYS.ENTER || e.key === ' ') {
e.preventDefault();
this.select(date, e.currentTarget as HTMLElement);
return true;
}
if (!target) return false;
e.preventDefault();
// If target is outside the visible months, advance the placeholder
// so the focused cell renders.
if (
isOutsideMonth(target, this.visibleMonths[0]) &&
(this.visibleMonths.length === 1 ||
target.compare(this.visibleMonths[this.visibleMonths.length - 1]) < 0 ||
target.compare(this.visibleMonths[0]) < 0)
) {
this.setPlaceholder(startOfMonth(target), e.currentTarget as HTMLElement);
}
// Move focus after the DOM updates.
queueMicrotask(() => {
const root = this.opts.ref.current;
if (!root) return;
const el = root.querySelector<HTMLElement>(
`[data-calendar-day][data-value="${target!.toString()}"]`
);
this.soma.dom.focus(el);
});
return true;
}
// ── Formatters ────────────────────────────────────────────────────────────
formatMonth(month: number): string {
const fmt = this.opts.monthFormat.current;
if (typeof fmt === 'function') return fmt(month);
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt ?? 'long' }).format(anchor);
}
formatYear(year: number): string {
const fmt = this.opts.yearFormat.current;
if (typeof fmt === 'function') return fmt(year);
const anchor = new Date(Date.UTC(year, 5, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { year: fmt ?? 'numeric' }).format(anchor);
}
formatDay(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { day: 'numeric' }).format(
date.toDate(getLocalTimeZone())
);
}
// ── Week numbers ──────────────────────────────────────────────────────────
getWeekNumber(weekFirstDate: DateValue): number {
return this.opts.weekNumberFormat.current === 'iso'
? getIsoWeekNumber(weekFirstDate)
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved, this.effectiveLocale);
}
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() =>
this.runtimePart.assert({
// role + aria-disabled are morfo-declared, resolved via renderProps.
...this.runtimePart.renderProps(),
dir: this.opts.dir.current,
// Computed accessible name (calendarLabel + heading) — overrides the
// morfo's raw `ariaLabel` propRef (which the provider doesn't source).
'aria-label': this.fullCalendarLabel,
// Soma-owned data values (the morfo declares these attrs without a value).
'data-type': this.opts.type.current,
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid)
} as const)
);
}
// ── Header ───────────────────────────────────────────────────────────────────
export class CalendarHeaderProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarHeaderProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('header', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Heading ──────────────────────────────────────────────────────────────────
export interface CalendarHeadingOpts
extends
WithRefOpts,
ActiveProps<{
value: DateValue | undefined;
}> {}
export class CalendarHeadingProvider {
readonly opts: CalendarHeadingOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CalendarHeadingOpts) {
return new CalendarHeadingProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarHeadingOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('heading', {
id: opts.id,
ref: opts.ref,
owner: this
});
if (!opts.value.current) this.provider.headingId.current = opts.id.current;
}
readonly headingValue = $derived.by(() => {
const value = this.opts.value.current;
if (!value) return this.provider.headingValue;
return `${this.provider.formatMonth(value.month)} ${this.provider.formatYear(value.year)}`;
});
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, 'aria-hidden': true as const } as const)
);
}
// ── Prev / Next Buttons ─────────────────────────────────────────────────────
abstract class CalendarPaginationButtonProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: CalendarProvider;
protected abstract disabled(): boolean;
protected abstract trigger(target?: HTMLElement): void;
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button') {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part(part, {
id: opts.id,
ref: opts.ref,
owner: this,
// Source for the morfo's `aria-disabled` (propRef('disabled')) — the
// button-local bound check (isPrev/isNextButtonDisabled).
props: { disabled: () => this.disabled() }
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.disabled()) return;
this.trigger(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() => {
const disabled = this.disabled();
return this.runtimePart.assert({
// type, aria-label (commonRef calendar.prev/next-month) and
// aria-disabled are morfo-declared, resolved via renderProps.
...this.runtimePart.renderProps(),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
onclick: this.onclick
} as const);
});
}
export class CalendarPrevButtonProvider extends CalendarPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new CalendarPrevButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'prev-button');
}
protected disabled() {
return this.provider.isPrevButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.prevPage(target);
}
}
export class CalendarNextButtonProvider extends CalendarPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new CalendarNextButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'next-button');
}
protected disabled() {
return this.provider.isNextButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.nextPage(target);
}
}
// ── MonthSelect ──────────────────────────────────────────────────────────────
export interface CalendarMonthSelectOpts
extends
WithRefOpts,
ActiveProps<{
months: number[];
monthFormat: Intl.DateTimeFormatOptions['month'] | ((month: number) => string) | undefined;
disabled: boolean;
}> {}
export class CalendarMonthSelectProvider {
readonly opts: CalendarMonthSelectOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CalendarMonthSelectOpts) {
return new CalendarMonthSelectProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarMonthSelectOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('month-select', {
id: opts.id,
ref: opts.ref,
owner: this,
// Source for the morfo's `aria-disabled` (propRef('disabled')) — the
// combined own-or-provider disabled state.
props: { disabled: () => this.isDisabled }
});
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.opts.disabled.current
);
readonly monthItems: CalendarMonthSelectSnippetProps['monthItems'] = $derived.by(() => {
const customFmt = this.opts.monthFormat.current;
const formatter = (m: number) => {
if (typeof customFmt === 'function') return customFmt(m);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(2024, m - 1, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
month: customFmt
}).format(anchor);
}
return this.provider.formatMonth(m);
};
return this.opts.months.current.map((value) => ({ value, label: formatter(value) }));
});
readonly selectedMonthItem = $derived.by(() => {
const month = this.provider.opts.placeholder.current.month;
return (
this.monthItems.find((i) => i.value === month) ??
this.monthItems[0] ?? { value: month, label: String(month) }
);
});
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const month = Number.parseInt(target.value, 10);
if (!Number.isNaN(month)) this.provider.setMonth(month, target);
};
readonly props = $derived.by(() => {
const disabled = this.isDisabled;
return this.runtimePart.assert({
// aria-label (commonRef calendar.month-select) and aria-disabled are
// morfo-declared, resolved via renderProps.
...this.runtimePart.renderProps(),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
value: String(this.selectedMonthItem.value),
onchange: this.onchange
} as const);
});
}
// ── YearSelect ───────────────────────────────────────────────────────────────
export interface CalendarYearSelectOpts
extends
WithRefOpts,
ActiveProps<{
years: number[];
yearFormat: Intl.DateTimeFormatOptions['year'] | ((year: number) => string) | undefined;
disabled: boolean;
}> {}
export class CalendarYearSelectProvider {
readonly opts: CalendarYearSelectOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CalendarYearSelectOpts) {
return new CalendarYearSelectProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarYearSelectOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('year-select', {
id: opts.id,
ref: opts.ref,
owner: this,
// Source for the morfo's `aria-disabled` (propRef('disabled')) — the
// combined own-or-provider disabled state.
props: { disabled: () => this.isDisabled }
});
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.opts.disabled.current
);
readonly yearItems: CalendarYearSelectSnippetProps['yearItems'] = $derived.by(() => {
const customFmt = this.opts.yearFormat.current;
const formatter = (y: number) => {
if (typeof customFmt === 'function') return customFmt(y);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(y, 5, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
year: customFmt
}).format(anchor);
}
return this.provider.formatYear(y);
};
return this.opts.years.current.map((value) => ({ value, label: formatter(value) }));
});
readonly selectedYearItem = $derived.by(() => {
const year = this.provider.opts.placeholder.current.year;
return (
this.yearItems.find((i) => i.value === year) ??
this.yearItems[0] ?? { value: year, label: String(year) }
);
});
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const year = Number.parseInt(target.value, 10);
if (!Number.isNaN(year)) this.provider.setYear(year, target);
};
readonly props = $derived.by(() => {
const disabled = this.isDisabled;
return this.runtimePart.assert({
// aria-label (commonRef calendar.year-select) and aria-disabled are
// morfo-declared, resolved via renderProps.
...this.runtimePart.renderProps(),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
value: String(this.selectedYearItem.value),
onchange: this.onchange
} as const);
});
}
// ── Grid / GridHead / GridBody / GridRow / HeadCell (presentational) ────────
export interface CalendarGridOpts
extends
WithRefOpts,
ActiveProps<{
labelledBy: string | undefined;
}> {}
export class CalendarGridProvider {
readonly opts: CalendarGridOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CalendarGridOpts) {
return new CalendarGridProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarGridOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('grid', {
id: opts.id,
ref: opts.ref,
owner: this,
// Sources for the morfo's `aria-readonly`/`aria-disabled`
// (propRef('readonly')/propRef('disabled')) so renderProps resolves them.
props: {
readonly: () => this.provider.opts.readonly.current,
disabled: () => this.provider.opts.disabled.current
}
});
}
readonly props = $derived.by(() => {
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
return this.runtimePart.assert({
// role, aria-readonly and aria-disabled are morfo-declared, resolved
// via renderProps.
...this.runtimePart.renderProps(),
// Computed: honours the `labelledBy` prop override + the registered
// heading id (overrides the morfo's raw partRef('heading')).
'aria-labelledby': headingId || undefined
} as const);
});
}
export class CalendarGridHeadProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarGridHeadProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('grid-head', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
export class CalendarGridBodyProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarGridBodyProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('grid-body', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
export class CalendarGridRowProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarGridRowProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('grid-row', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, role: 'row' as const } as const)
);
}
export class CalendarHeadCellProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarHeadCellProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('head-cell', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, scope: 'col' as const } as const)
);
}
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface CalendarCellOpts
extends WithRefOpts, ActiveProps<{ date: DateValue; month: DateValue }> {}
export class CalendarCellProvider {
readonly opts: CalendarCellOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<CalendarCellProvider>('CalendarCell');
static get(): CalendarCellProvider | undefined {
return this.ctx.getOr(undefined) as CalendarCellProvider | undefined;
}
static require(): CalendarCellProvider {
return this.ctx.get();
}
static create(opts: CalendarCellOpts) {
return new CalendarCellProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarCellOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('cell', {
id: opts.id,
ref: opts.ref,
owner: this,
context: CalendarCellProvider.ctx,
// Sources for the morfo's `aria-selected`/`aria-disabled`
// (propRef('selected')/propRef('disabled')) — the cell-local flags.
props: {
selected: () => this.selected,
disabled: () => this.isDisabled
}
});
}
readonly flags = $derived.by(() => this.provider.computeFlags(this.opts.date.current));
readonly selected = $derived.by(() => this.provider.isSelected(this.opts.date.current));
readonly outside = $derived.by(() =>
isOutsideMonth(this.opts.date.current, this.opts.month.current)
);
readonly todayFlag = $derived.by(() => isToday(this.opts.date.current));
readonly weekendFlag = $derived.by(() => isWeekend(this.opts.date.current));
readonly isDisabled = $derived.by(() => {
const base = this.flags.disabled;
if (base) return true;
return this.outside && this.provider.opts.disableDaysOutsideMonth.current;
});
readonly snippetProps: CalendarCellSnippetProps = $derived.by(() => ({
disabled: this.isDisabled,
unavailable: this.flags.unavailable,
selected: this.selected,
today: this.todayFlag,
weekend: this.weekendFlag,
holiday: this.flags.holiday,
outsideMonth: this.outside
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
// role, aria-selected and aria-disabled are morfo-declared, resolved
// via renderProps.
...this.runtimePart.renderProps(),
// Soma-owned data values (the morfo declares these attrs without a value).
'data-selected': boolToEmptyStrOrUndef(this.selected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-unavailable': boolToEmptyStrOrUndef(this.flags.unavailable),
'data-today': boolToEmptyStrOrUndef(this.todayFlag),
'data-weekend': boolToEmptyStrOrUndef(this.weekendFlag),
'data-holiday': boolToEmptyStrOrUndef(this.flags.holiday),
'data-outside-month': boolToEmptyStrOrUndef(this.outside),
'data-value': this.opts.date.current.toString()
} as const)
);
}
// ── Day ──────────────────────────────────────────────────────────────────────
export class CalendarDayProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new CalendarDayProvider(opts);
}
readonly provider: CalendarProvider;
readonly cell: CalendarCellProvider;
private constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.cell = CalendarCellProvider.require();
this.runtimePart = this.provider.runtime.part('day', {
id: opts.id,
ref: opts.ref,
owner: this,
// Sources for the morfo's `aria-disabled`/`aria-selected`
// (propRef('disabled')/propRef('selected')) — the cell's resolved flags.
props: {
disabled: () => this.cell.snippetProps.disabled,
selected: () => this.cell.snippetProps.selected
}
});
}
readonly focused = $derived.by(() =>
isSameDay(this.cell.opts.date.current, this.provider.opts.placeholder.current)
);
readonly day = $derived.by(() => this.provider.formatDay(this.cell.opts.date.current));
readonly snippetProps: CalendarDaySnippetProps = $derived.by(() => ({
...this.cell.snippetProps,
day: this.day
}));
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
const date = this.cell.opts.date.current;
this.provider.select(date, e.currentTarget as HTMLElement);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
this.provider.handleDayKeydown(e, this.cell.opts.date.current);
};
readonly props = $derived.by(() => {
const s = this.cell.snippetProps;
const tabindex = this.focused && !s.disabled ? 0 : -1;
return this.runtimePart.assert({
// role, aria-disabled and aria-selected are morfo-declared, resolved
// via renderProps.
...this.runtimePart.renderProps(),
tabindex,
// Soma-owned data values (the morfo declares these attrs without a value).
'data-selected': boolToEmptyStrOrUndef(s.selected),
'data-disabled': boolToEmptyStrOrUndef(s.disabled),
'data-unavailable': boolToEmptyStrOrUndef(s.unavailable),
'data-today': boolToEmptyStrOrUndef(s.today),
'data-weekend': boolToEmptyStrOrUndef(s.weekend),
'data-holiday': boolToEmptyStrOrUndef(s.holiday),
'data-outside-month': boolToEmptyStrOrUndef(s.outsideMonth),
'data-focused': boolToEmptyStrOrUndef(this.focused),
'data-value': this.cell.opts.date.current.toString(),
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}

Powered by TurnKey Linux.