|
|
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 { calendarNavContext, type CalendarNav } from './calendar-nav';
|
|
|
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 implements CalendarNav {
|
|
|
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 }
|
|
|
});
|
|
|
|
|
|
// Publish the shared nav contract so the refined chrome (month/year
|
|
|
// selectors, prev/next) drives this calendar without a concrete-provider
|
|
|
// dependency.
|
|
|
calendarNavContext.set(this);
|
|
|
|
|
|
// 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);
|
|
|
}
|
|
|
|
|
|
/** CalendarNav: the currently displayed month the chrome reads + writes. */
|
|
|
getPlaceholder(): DateValue {
|
|
|
return this.opts.placeholder.current;
|
|
|
}
|
|
|
|
|
|
// ── 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);
|
|
|
});
|
|
|
}
|