diff --git a/docs/src/routes/(docs)/sink/+page.svelte b/docs/src/routes/(docs)/sink/+page.svelte
index 2befd9409..eeb82552b 100644
--- a/docs/src/routes/(docs)/sink/+page.svelte
+++ b/docs/src/routes/(docs)/sink/+page.svelte
@@ -1,15 +1,23 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/routes/(docs)/sink/calendar.svelte b/docs/src/routes/(docs)/sink/calendar.svelte
new file mode 100644
index 000000000..c71554c46
--- /dev/null
+++ b/docs/src/routes/(docs)/sink/calendar.svelte
@@ -0,0 +1,72 @@
+
+
+
+ {#snippet children({ months, weekdays })}
+
+
+
+
+
+
+
+
+
+
+ {#each months as month, i (i)}
+
+
+
+ {#each weekdays as day, i (i)}
+
+ {day.slice(0, 2)}
+
+ {/each}
+
+
+
+ {#each month.weeks as weekDates, i (i)}
+
+ {#each weekDates as date, i (i)}
+
+
+
+ {date.day}
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
+ {/snippet}
+
diff --git a/docs/src/routes/(docs)/sink/month-cal.svelte b/docs/src/routes/(docs)/sink/month-cal.svelte
new file mode 100644
index 000000000..7aadcdf44
--- /dev/null
+++ b/docs/src/routes/(docs)/sink/month-cal.svelte
@@ -0,0 +1,59 @@
+
+
+
+ {#snippet children({ years })}
+
+
+
+
+
+
+
+
+
+
+ {#each years as year, i (i)}
+
+
+ {#each year.months as months, i (i)}
+
+ {#each months as { value, label }, i (i)}
+
+
+
+ {label}
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
+ {/snippet}
+
diff --git a/docs/src/routes/(docs)/sink/range-cal.svelte b/docs/src/routes/(docs)/sink/range-cal.svelte
index 2701d3322..fbac842bd 100644
--- a/docs/src/routes/(docs)/sink/range-cal.svelte
+++ b/docs/src/routes/(docs)/sink/range-cal.svelte
@@ -58,7 +58,7 @@
)}
>
{date.day}
diff --git a/docs/src/routes/(docs)/sink/range-month-cal.svelte b/docs/src/routes/(docs)/sink/range-month-cal.svelte
new file mode 100644
index 000000000..bea6aeb04
--- /dev/null
+++ b/docs/src/routes/(docs)/sink/range-month-cal.svelte
@@ -0,0 +1,64 @@
+
+
+
+ {#snippet children({ years })}
+
+
+ ←
+
+
+
+ →
+
+
+
+ {#each years as year (year.value.year)}
+
+
+ {#each year.months as months, i (i)}
+
+ {#each months as { value, label }, d (d)}
+
+
+
+ {label}
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
+ {/snippet}
+
diff --git a/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts b/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts
index 3e6347ee8..8da276937 100644
--- a/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts
@@ -1,47 +1,25 @@
-import {
- type DateValue,
- getLocalTimeZone,
- isSameDay,
- isSameMonth,
- isToday,
-} from "@internationalized/date";
-import { DEV } from "esm-env";
-import { onMount, untrack } from "svelte";
-import {
- attachRef,
- DOMContext,
- type ReadableBoxedValues,
- type WritableBoxedValues,
-} from "svelte-toolbelt";
-import { Context, watch } from "runed";
+import { type DateValue, getLocalTimeZone, isSameMonth, isToday } from "@internationalized/date";
+import { untrack } from "svelte";
+import { attachRef, type ReadableBoxedValues } from "svelte-toolbelt";
+import { Context } from "runed";
import type { RangeCalendarRootState } from "../range-calendar/range-calendar.svelte.js";
import {
getAriaDisabled,
- getAriaHidden,
- getAriaReadonly,
getAriaSelected,
getDataDisabled,
- getDataReadonly,
getDataSelected,
getDataUnavailable,
} from "$lib/internal/attrs.js";
-import type { BitsKeyboardEvent, BitsMouseEvent, WithRefOpts } from "$lib/internal/types.js";
-import { useId } from "$lib/internal/use-id.js";
-import type { DateMatcher, Month } from "$lib/shared/index.js";
-import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js";
+import type { WithRefOpts } from "$lib/internal/types.js";
+import type { Month } from "$lib/shared/index.js";
import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js";
import {
- calendarAttrs,
- createAccessibleHeading,
createMonths,
- getCalendarElementProps,
getCalendarHeadingValue,
- getDateWithPreviousTime,
getDefaultYears,
getIsNextButtonDisabled,
getIsPrevButtonDisabled,
getWeekdays,
- handleCalendarKeydown,
handleCalendarNextPage,
handleCalendarPrevPage,
shiftCalendarFocus,
@@ -49,66 +27,71 @@ import {
useMonthViewOptionsSync,
useMonthViewPlaceholderSync,
} from "$lib/internal/date-time/calendar-helpers.svelte.js";
-import { getDateValueType, isBefore, toDate } from "$lib/internal/date-time/utils.js";
+import { getDateValueType, toDate } from "$lib/internal/date-time/utils.js";
import type { WeekStartsOn } from "$lib/shared/date/types.js";
+import {
+ CalendarBaseCellState,
+ CalendarBaseGridBodyState,
+ CalendarBaseGridHeadState,
+ CalendarBaseGridRowState,
+ CalendarBaseGridState,
+ CalendarBaseHeadCellState,
+ CalendarBaseHeaderState,
+ CalendarBaseHeadingState,
+ CalendarBaseNextButtonState,
+ CalendarBasePrevButtonState,
+ CalendarBaseRootState,
+ CalendarBaseUnitState,
+ type CalendarBaseCellStateOpts,
+ type CalendarBaseGridBodyStateOpts,
+ type CalendarBaseGridHeadStateOpts,
+ type CalendarBaseGridRowStateOpts,
+ type CalendarBaseGridStateOpts,
+ type CalendarBaseHeadCellStateOpts,
+ type CalendarBaseHeaderStateOpts,
+ type CalendarBaseHeadingStateOpts,
+ type CalendarBaseNextButtonStateOpts,
+ type CalendarBasePrevButtonStateOpts,
+ type CalendarBaseRootStateOpts,
+ type CalendarBaseUnitStateOpts,
+} from "../../internal/date-time/calendar-base.svelte.js";
+import type { MonthCalendarRootState } from "../month-calendar/month-calendar.svelte.js";
+import { RangeMonthCalendarRootState } from "../range-month-calendar/range-month-calendar.svelte.js";
+
+type RootState =
+ | CalendarRootState
+ | RangeCalendarRootState
+ | MonthCalendarRootState
+ | RangeMonthCalendarRootState;
interface CalendarRootStateOpts
- extends WithRefOpts,
- WritableBoxedValues<{
- value: DateValue | undefined | DateValue[];
- placeholder: DateValue;
- }>,
+ extends CalendarBaseRootStateOpts,
ReadableBoxedValues<{
- preventDeselect: boolean;
- minValue: DateValue | undefined;
- maxValue: DateValue | undefined;
- disabled: boolean;
- pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
- isDateDisabled: DateMatcher;
- isDateUnavailable: DateMatcher;
fixedWeeks: boolean;
numberOfMonths: number;
- locale: string;
- calendarLabel: string;
- type: "single" | "multiple";
- readonly: boolean;
disableDaysOutsideMonth: boolean;
- initialFocus: boolean;
- maxDays: number | undefined;
- /**
- * This is strictly used by the `DatePicker` component to close the popover when a date
- * is selected. It is not intended to be used by the user.
- */
- onDateSelect?: () => void;
monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
- }> {
- defaultPlaceholder: DateValue;
-}
+ }> {}
-export const CalendarRootContext = new Context(
- "Calendar.Root | RangeCalender.Root"
+export const CalendarRootContext = new Context(
+ "Calendar.Root | RangeCalendar.Root | MonthCalendar.Root | RangeMonthCalendar.Root"
);
-export class CalendarRootState {
+export class CalendarRootState extends CalendarBaseRootState {
static create(opts: CalendarRootStateOpts) {
- return CalendarRootContext.set(new CalendarRootState(opts));
+ return CalendarRootContext.set(new CalendarRootState(opts)) as CalendarRootState;
}
- readonly opts: CalendarRootStateOpts;
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
readonly formatter: Formatter;
- readonly accessibleHeadingId = useId();
- readonly domContext: DOMContext;
months: Month[] = $state([]);
- announcer: Announcer;
constructor(opts: CalendarRootStateOpts) {
- this.opts = opts;
- this.domContext = new DOMContext(opts.ref);
- this.announcer = getAnnouncer(null);
+ super(opts, "day");
+
this.formatter = createFormatter({
initialLocale: this.opts.locale.current,
monthFormat: this.opts.monthFormat,
@@ -116,25 +99,11 @@ export class CalendarRootState {
});
this.setMonths = this.setMonths.bind(this);
- this.nextPage = this.nextPage.bind(this);
- this.prevPage = this.prevPage.bind(this);
this.prevYear = this.prevYear.bind(this);
this.nextYear = this.nextYear.bind(this);
this.setYear = this.setYear.bind(this);
this.setMonth = this.setMonth.bind(this);
this.isOutsideVisibleMonths = this.isOutsideVisibleMonths.bind(this);
- this.isDateDisabled = this.isDateDisabled.bind(this);
- this.isDateSelected = this.isDateSelected.bind(this);
- this.shiftFocus = this.shiftFocus.bind(this);
- this.handleCellClick = this.handleCellClick.bind(this);
- this.handleMultipleUpdate = this.handleMultipleUpdate.bind(this);
- this.handleSingleUpdate = this.handleSingleUpdate.bind(this);
- this.onkeydown = this.onkeydown.bind(this);
- this.getBitsAttr = this.getBitsAttr.bind(this);
-
- onMount(() => {
- this.announcer = getAnnouncer(this.domContext.getDocument());
- });
this.months = createMonths({
dateObj: this.opts.placeholder.current,
@@ -144,10 +113,6 @@ export class CalendarRootState {
numberOfMonths: this.opts.numberOfMonths.current,
});
- this.#setupInitialFocusEffect();
- this.#setupAccessibleHeadingEffect();
- this.#setupFormatterEffect();
-
/**
* Updates the displayed months based on changes in the placeholder value.
*/
@@ -174,48 +139,14 @@ export class CalendarRootState {
weekStartsOn: this.opts.weekStartsOn,
});
- /**
- * Update the accessible heading's text content when the `fullCalendarLabel`
- * changes.
- */
- watch(
- () => this.fullCalendarLabel,
- (label) => {
- const node = this.domContext.getElementById(this.accessibleHeadingId);
- if (!node) return;
- node.textContent = label;
- }
- );
-
- /**
- * Synchronize the placeholder value with the current value.
- */
- watch(
- () => this.opts.value.current,
- () => {
- const value = this.opts.value.current;
- if (Array.isArray(value) && value.length) {
- const lastValue = value[value.length - 1];
- if (lastValue && this.opts.placeholder.current !== lastValue) {
- this.opts.placeholder.current = lastValue;
- }
- } else if (
- !Array.isArray(value) &&
- value &&
- this.opts.placeholder.current !== value
- ) {
- this.opts.placeholder.current = value;
- }
- }
- );
-
useEnsureNonDisabledPlaceholder({
placeholder: opts.placeholder,
defaultPlaceholder: opts.defaultPlaceholder,
- isDateDisabled: opts.isDateDisabled,
+ isUnitDisabled: opts.isUnitDisabled,
maxValue: opts.maxValue,
minValue: opts.minValue,
ref: opts.ref,
+ unit: "day",
});
}
@@ -250,39 +181,6 @@ export class CalendarRootState {
});
});
- #setupInitialFocusEffect() {
- $effect(() => {
- const initialFocus = untrack(() => this.opts.initialFocus.current);
- if (initialFocus) {
- // focus the first `data-focused` day node
- const firstFocusedDay =
- this.opts.ref.current?.querySelector(`[data-focused]`);
- if (firstFocusedDay) {
- firstFocusedDay.focus();
- }
- }
- });
- }
-
- #setupAccessibleHeadingEffect() {
- $effect(() => {
- if (!this.opts.ref.current) return;
- const removeHeading = createAccessibleHeading({
- calendarNode: this.opts.ref.current,
- label: this.fullCalendarLabel,
- accessibleHeadingId: this.accessibleHeadingId,
- });
- return removeHeading;
- });
- }
-
- #setupFormatterEffect() {
- $effect.pre(() => {
- if (this.formatter.getLocale() === this.opts.locale.current) return;
- this.formatter.setLocale(this.opts.locale.current);
- });
- }
-
/**
* Navigates to the next page of the calendar.
*/
@@ -347,24 +245,6 @@ export class CalendarRootState {
});
});
- isInvalid = $derived.by(() => {
- const value = this.opts.value.current;
- const isDateDisabled = this.opts.isDateDisabled.current;
- const isDateUnavailable = this.opts.isDateUnavailable.current;
- if (Array.isArray(value)) {
- if (!value.length) return false;
- for (const date of value) {
- if (isDateDisabled(date)) return true;
- if (isDateUnavailable(date)) return true;
- }
- } else {
- if (!value) return false;
- if (isDateDisabled(value)) return true;
- if (isDateUnavailable(value)) return true;
- }
- return false;
- });
-
readonly headingValue = $derived.by(() => {
this.opts.monthFormat.current;
this.opts.yearFormat.current;
@@ -375,33 +255,10 @@ export class CalendarRootState {
});
});
- readonly fullCalendarLabel = $derived.by(() => {
- return `${this.opts.calendarLabel.current} ${this.headingValue}`;
- });
-
isOutsideVisibleMonths(date: DateValue) {
return !this.visibleMonths.some((month) => isSameMonth(date, month));
}
- isDateDisabled(date: DateValue) {
- if (this.opts.isDateDisabled.current(date) || this.opts.disabled.current) return true;
- const minValue = this.opts.minValue.current;
- const maxValue = this.opts.maxValue.current;
- if (minValue && isBefore(date, minValue)) return true;
- if (maxValue && isBefore(maxValue, date)) return true;
- return false;
- }
-
- isDateSelected(date: DateValue) {
- const value = this.opts.value.current;
- if (Array.isArray(value)) {
- return value.some((d) => isSameDay(d, date));
- } else if (!value) {
- return false;
- }
- return isSameDay(value, date);
- }
-
shiftFocus(node: HTMLElement, add: number) {
return shiftCalendarFocus({
node,
@@ -410,103 +267,9 @@ export class CalendarRootState {
calendarNode: this.opts.ref.current,
isPrevButtonDisabled: this.isPrevButtonDisabled,
isNextButtonDisabled: this.isNextButtonDisabled,
- months: this.months,
- numberOfMonths: this.opts.numberOfMonths.current,
- });
- }
-
- #isMultipleSelectionValid(selectedDates: DateValue[]): boolean {
- // only validate for multiple type and when maxDays is set
- if (this.opts.type.current !== "multiple") return true;
- if (!this.opts.maxDays.current) return true;
- const selectedCount = selectedDates.length;
- if (this.opts.maxDays.current && selectedCount > this.opts.maxDays.current) return false;
- return true;
- }
-
- handleCellClick(_: Event, date: DateValue) {
- if (
- this.opts.readonly.current ||
- this.opts.isDateDisabled.current?.(date) ||
- this.opts.isDateUnavailable.current?.(date)
- ) {
- return;
- }
-
- const prev = this.opts.value.current;
- const multiple = this.opts.type.current === "multiple";
- if (multiple) {
- if (Array.isArray(prev) || prev === undefined) {
- this.opts.value.current = this.handleMultipleUpdate(prev, date);
- }
- } else if (!Array.isArray(prev)) {
- const next = this.handleSingleUpdate(prev, date);
- if (!next) {
- this.announcer.announce("Selected date is now empty.", "polite", 5000);
- } else {
- this.announcer.announce(
- `Selected Date: ${this.formatter.selectedDate(next, false)}`,
- "polite"
- );
- }
- this.opts.value.current = getDateWithPreviousTime(next, prev);
- if (next !== undefined) {
- this.opts.onDateSelect?.current?.();
- }
- }
- }
-
- handleMultipleUpdate(prev: DateValue[] | undefined, date: DateValue) {
- if (!prev) {
- const newSelection = [date];
- return this.#isMultipleSelectionValid(newSelection) ? newSelection : [date];
- }
- if (!Array.isArray(prev)) {
- if (DEV) throw new Error("Invalid value for multiple prop.");
- return;
- }
- const index = prev.findIndex((d) => isSameDay(d, date));
- const preventDeselect = this.opts.preventDeselect.current;
- if (index === -1) {
- // adding a new date - check if it would be valid
- const newSelection = [...prev, date];
- if (this.#isMultipleSelectionValid(newSelection)) {
- return newSelection;
- } else {
- // reset to just the newly selected date when constraints are violated
- return [date];
- }
- } else if (preventDeselect) {
- return prev;
- } else {
- const next = prev.filter((d) => !isSameDay(d, date));
- if (!next.length) {
- this.opts.placeholder.current = date;
- return undefined;
- }
- return next;
- }
- }
-
- handleSingleUpdate(prev: DateValue | undefined, date: DateValue) {
- if (Array.isArray(prev)) {
- if (DEV) throw new Error("Invalid value for single prop.");
- }
- if (!prev) return date;
- const preventDeselect = this.opts.preventDeselect.current;
- if (!preventDeselect && isSameDay(prev, date)) {
- this.opts.placeholder.current = date;
- return undefined;
- }
- return date;
- }
-
- onkeydown(event: BitsKeyboardEvent) {
- handleCalendarKeydown({
- event,
- handleCellClick: this.handleCellClick,
- shiftFocus: this.shiftFocus,
- placeholderValue: this.opts.placeholder.current,
+ items: this.months,
+ numberOfUnits: this.opts.numberOfMonths.current,
+ unit: "months",
});
}
@@ -514,80 +277,36 @@ export class CalendarRootState {
months: this.months,
weekdays: this.weekdays,
}));
-
- getBitsAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
- return calendarAttrs.getAttr(part);
- };
-
- readonly props = $derived.by(
- () =>
- ({
- ...getCalendarElementProps({
- fullCalendarLabel: this.fullCalendarLabel,
- id: this.opts.id.current,
- isInvalid: this.isInvalid,
- disabled: this.opts.disabled.current,
- readonly: this.opts.readonly.current,
- }),
- [this.getBitsAttr("root")]: "",
- //
- onkeydown: this.onkeydown,
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarHeadingStateOpts extends WithRefOpts {}
+interface CalendarHeadingStateOpts extends CalendarBaseHeadingStateOpts {}
-export class CalendarHeadingState {
+export class CalendarHeadingState extends CalendarBaseHeadingState {
static create(opts: CalendarHeadingStateOpts) {
return new CalendarHeadingState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarHeadingStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarHeadingStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "aria-hidden": getAriaHidden(true),
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("heading")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-const CalendarCellContext = new Context("Calendar.Cell | RangeCalendar.Cell");
+const CalendarCellContext = new Context(
+ "Calendar.Cell | RangeCalendar.Cell | MonthCalendar.Cell | RangeMonthCalendar.Cell"
+);
interface CalendarCellStateOpts
- extends WithRefOpts,
+ extends CalendarBaseCellStateOpts,
ReadableBoxedValues<{
- date: DateValue;
month: DateValue;
}> {}
-export class CalendarCellState {
+export class CalendarCellState extends CalendarBaseCellState<
+ CalendarCellStateOpts,
+ CalendarRootState
+> {
static create(opts: CalendarCellStateOpts) {
return CalendarCellContext.set(
new CalendarCellState(opts, CalendarRootContext.get() as CalendarRootState)
);
}
- readonly opts: CalendarCellStateOpts;
- readonly root: CalendarRootState;
- readonly cellDate = $derived.by(() => toDate(this.opts.date.current));
- readonly isDisabled = $derived.by(() => this.root.isDateDisabled(this.opts.date.current));
- readonly isUnavailable = $derived.by(() =>
- this.root.opts.isDateUnavailable.current(this.opts.date.current)
- );
readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone()));
readonly isOutsideMonth = $derived.by(
() => !isSameMonth(this.opts.date.current, this.opts.month.current)
@@ -595,10 +314,6 @@ export class CalendarCellState {
readonly isOutsideVisibleMonths = $derived.by(() =>
this.root.isOutsideVisibleMonths(this.opts.date.current)
);
- readonly isFocusedDate = $derived.by(() =>
- isSameDay(this.opts.date.current, this.root.opts.placeholder.current)
- );
- readonly isSelectedDate = $derived.by(() => this.root.isDateSelected(this.opts.date.current));
readonly labelText = $derived.by(() =>
this.root.formatter.custom(this.cellDate, {
weekday: "long",
@@ -609,16 +324,9 @@ export class CalendarCellState {
);
constructor(opts: CalendarCellStateOpts, root: CalendarRootState) {
- this.opts = opts;
- this.root = root;
+ super(opts, root);
}
- readonly snippetProps = $derived.by(() => ({
- disabled: this.isDisabled,
- unavailable: this.isUnavailable,
- selected: this.isSelectedDate,
- }));
-
readonly ariaDisabled = $derived.by(() => {
return (
this.isDisabled ||
@@ -634,8 +342,8 @@ export class CalendarCellState {
"data-today": this.isDateToday ? "" : undefined,
"data-outside-month": this.isOutsideMonth ? "" : undefined,
"data-outside-visible-months": this.isOutsideVisibleMonths ? "" : undefined,
- "data-focused": this.isFocusedDate ? "" : undefined,
- "data-selected": getDataSelected(this.isSelectedDate),
+ "data-focused": this.isFocusedUnit ? "" : undefined,
+ "data-selected": getDataSelected(this.isSelectedUnit),
"data-value": this.opts.date.current.toString(),
"data-type": getDateValueType(this.opts.date.current),
"data-disabled": getDataDisabled(
@@ -650,7 +358,7 @@ export class CalendarCellState {
({
id: this.opts.id.current,
role: "gridcell",
- "aria-selected": getAriaSelected(this.isSelectedDate),
+ "aria-selected": getAriaSelected(this.isSelectedUnit),
"aria-disabled": getAriaDisabled(this.ariaDisabled),
...this.sharedDataAttrs,
[this.root.getBitsAttr("cell")]: "",
@@ -659,40 +367,33 @@ export class CalendarCellState {
);
}
-interface CalendarDayStateOpts extends WithRefOpts {}
+interface CalendarDayStateOpts extends CalendarBaseUnitStateOpts {}
-export class CalendarDayState {
+export class CalendarDayState extends CalendarBaseUnitState<
+ CalendarDayStateOpts,
+ CalendarCellState
+> {
static create(opts: CalendarDayStateOpts) {
return new CalendarDayState(opts, CalendarCellContext.get());
}
- readonly opts: CalendarDayStateOpts;
- readonly cell: CalendarCellState;
-
constructor(opts: CalendarDayStateOpts, cell: CalendarCellState) {
- this.opts = opts;
- this.cell = cell;
- this.onclick = this.onclick.bind(this);
+ super(opts, cell);
}
readonly #tabindex = $derived.by(() =>
(this.cell.isOutsideMonth && this.cell.root.opts.disableDaysOutsideMonth.current) ||
this.cell.isDisabled
? undefined
- : this.cell.isFocusedDate
+ : this.cell.isFocusedUnit
? 0
: -1
);
- onclick(e: BitsMouseEvent) {
- if (this.cell.isDisabled) return;
- this.cell.root.handleCellClick(e, this.cell.opts.date.current);
- }
-
readonly snippetProps = $derived.by(() => ({
disabled: this.cell.isDisabled,
unavailable: this.cell.isUnavailable,
- selected: this.cell.isSelectedDate,
+ selected: this.cell.isSelectedUnit,
day: `${this.cell.opts.date.current.day}`,
}));
@@ -715,256 +416,68 @@ export class CalendarDayState {
);
}
-interface CalendarNextButtonStateOpts extends WithRefOpts {}
+interface CalendarNextButtonStateOpts extends CalendarBaseNextButtonStateOpts {}
-export class CalendarNextButtonState {
+export class CalendarNextButtonState extends CalendarBaseNextButtonState {
static create(opts: CalendarNextButtonStateOpts) {
return new CalendarNextButtonState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarNextButtonStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
- readonly isDisabled = $derived.by(() => this.root.isNextButtonDisabled);
-
- constructor(
- opts: CalendarNextButtonStateOpts,
- root: CalendarRootState | RangeCalendarRootState
- ) {
- this.opts = opts;
- this.root = root;
- this.onclick = this.onclick.bind(this);
- }
-
- onclick(_: BitsMouseEvent) {
- if (this.isDisabled) return;
- this.root.nextPage();
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- role: "button",
- type: "button",
- "aria-label": "Next",
- "aria-disabled": getAriaDisabled(this.isDisabled),
- "data-disabled": getDataDisabled(this.isDisabled),
- disabled: this.isDisabled,
- [this.root.getBitsAttr("next-button")]: "",
- //
- onclick: this.onclick,
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarPrevButtonStateOpts extends WithRefOpts {}
+interface CalendarPrevButtonStateOpts extends CalendarBasePrevButtonStateOpts {}
-export class CalendarPrevButtonState {
+export class CalendarPrevButtonState extends CalendarBasePrevButtonState {
static create(opts: CalendarPrevButtonStateOpts) {
return new CalendarPrevButtonState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarPrevButtonStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
- readonly isDisabled = $derived.by(() => this.root.isPrevButtonDisabled);
-
- constructor(
- opts: CalendarPrevButtonStateOpts,
- root: CalendarRootState | RangeCalendarRootState
- ) {
- this.opts = opts;
- this.root = root;
- this.onclick = this.onclick.bind(this);
- }
-
- onclick(_: BitsMouseEvent) {
- if (this.isDisabled) return;
- this.root.prevPage();
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- role: "button",
- type: "button",
- "aria-label": "Previous",
- "aria-disabled": getAriaDisabled(this.isDisabled),
- "data-disabled": getDataDisabled(this.isDisabled),
- disabled: this.isDisabled,
- [this.root.getBitsAttr("prev-button")]: "",
- //
- onclick: this.onclick,
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarGridStateOpts extends WithRefOpts {}
+interface CalendarGridStateOpts extends CalendarBaseGridStateOpts {}
-export class CalendarGridState {
+export class CalendarGridState extends CalendarBaseGridState {
static create(opts: CalendarGridStateOpts) {
return new CalendarGridState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarGridStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarGridStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- tabindex: -1,
- role: "grid",
- "aria-readonly": getAriaReadonly(this.root.opts.readonly.current),
- "aria-disabled": getAriaDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- [this.root.getBitsAttr("grid")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarGridBodyStateOpts extends WithRefOpts {}
+interface CalendarGridBodyStateOpts extends CalendarBaseGridBodyStateOpts {}
-export class CalendarGridBodyState {
+export class CalendarGridBodyState extends CalendarBaseGridBodyState {
static create(opts: CalendarGridBodyStateOpts) {
return new CalendarGridBodyState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarGridBodyStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarGridBodyStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("grid-body")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarGridHeadStateOpts extends WithRefOpts {}
+interface CalendarGridHeadStateOpts extends CalendarBaseGridHeadStateOpts {}
-export class CalendarGridHeadState {
+export class CalendarGridHeadState extends CalendarBaseGridHeadState {
static create(opts: CalendarGridHeadStateOpts) {
return new CalendarGridHeadState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarGridHeadStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarGridHeadStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("grid-head")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarGridRowStateOpts extends WithRefOpts {}
+interface CalendarGridRowStateOpts extends CalendarBaseGridRowStateOpts {}
-export class CalendarGridRowState {
+export class CalendarGridRowState extends CalendarBaseGridRowState {
static create(opts: CalendarGridRowStateOpts) {
return new CalendarGridRowState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarGridRowStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarGridRowStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("grid-row")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarHeadCellStateOpts extends WithRefOpts {}
+interface CalendarHeadCellStateOpts extends CalendarBaseHeadCellStateOpts {}
-export class CalendarHeadCellState {
+export class CalendarHeadCellState extends CalendarBaseHeadCellState {
static create(opts: CalendarHeadCellStateOpts) {
return new CalendarHeadCellState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarHeadCellStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarHeadCellStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("head-cell")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
-interface CalendarHeaderStateOpts extends WithRefOpts {}
+interface CalendarHeaderStateOpts extends CalendarBaseHeaderStateOpts {}
-export class CalendarHeaderState {
+export class CalendarHeaderState extends CalendarBaseHeaderState {
static create(opts: CalendarHeaderStateOpts) {
return new CalendarHeaderState(opts, CalendarRootContext.get());
}
-
- readonly opts: CalendarHeaderStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
-
- constructor(opts: CalendarHeaderStateOpts, root: CalendarRootState | RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly props = $derived.by(
- () =>
- ({
- id: this.opts.id.current,
- "data-disabled": getDataDisabled(this.root.opts.disabled.current),
- "data-readonly": getDataReadonly(this.root.opts.readonly.current),
- [this.root.getBitsAttr("header")]: "",
- ...attachRef(this.opts.ref),
- }) as const
- );
}
interface CalendarMonthSelectStateOpts
@@ -981,12 +494,9 @@ export class CalendarMonthSelectState {
}
readonly opts: CalendarMonthSelectStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
+ readonly root: RootState;
- constructor(
- opts: CalendarMonthSelectStateOpts,
- root: CalendarRootState | RangeCalendarRootState
- ) {
+ constructor(opts: CalendarMonthSelectStateOpts, root: RootState) {
this.opts = opts;
this.root = root;
this.onchange = this.onchange.bind(this);
@@ -1072,12 +582,9 @@ export class CalendarYearSelectState {
}
readonly opts: CalendarYearSelectStateOpts;
- readonly root: CalendarRootState | RangeCalendarRootState;
+ readonly root: RootState;
- constructor(
- opts: CalendarYearSelectStateOpts,
- root: CalendarRootState | RangeCalendarRootState
- ) {
+ constructor(opts: CalendarYearSelectStateOpts, root: RootState) {
this.opts = opts;
this.root = root;
this.onchange = this.onchange.bind(this);
diff --git a/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte b/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte
index 57ae71306..b60975c5b 100644
--- a/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte
+++ b/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte
@@ -84,8 +84,8 @@
weekdayFormat: box.with(() => weekdayFormat),
weekStartsOn: box.with(() => weekStartsOn),
pagedNavigation: box.with(() => pagedNavigation),
- isDateDisabled: box.with(() => isDateDisabled),
- isDateUnavailable: box.with(() => isDateUnavailable),
+ isUnitDisabled: box.with(() => isDateDisabled),
+ isUnitUnavailable: box.with(() => isDateUnavailable),
fixedWeeks: box.with(() => fixedWeeks),
numberOfMonths: box.with(() => numberOfMonths),
locale: resolveLocaleProp(() => locale),
@@ -96,7 +96,7 @@
maxValue: box.with(() => maxValue),
disableDaysOutsideMonth: box.with(() => disableDaysOutsideMonth),
initialFocus: box.with(() => initialFocus),
- maxDays: box.with(() => maxDays),
+ maxUnits: box.with(() => maxDays),
placeholder: box.with(
() => placeholder as DateValue,
(v) => {
diff --git a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker-calendar.svelte b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker-calendar.svelte
index bb934a439..ea74623f0 100644
--- a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker-calendar.svelte
+++ b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker-calendar.svelte
@@ -25,8 +25,8 @@
),
calendarLabel: datePickerRootState.opts.calendarLabel,
fixedWeeks: datePickerRootState.opts.fixedWeeks,
- isDateDisabled: datePickerRootState.opts.isDateDisabled,
- isDateUnavailable: datePickerRootState.opts.isDateUnavailable,
+ isUnitDisabled: datePickerRootState.opts.isDateDisabled,
+ isUnitUnavailable: datePickerRootState.opts.isDateUnavailable,
locale: datePickerRootState.opts.locale,
numberOfMonths: datePickerRootState.opts.numberOfMonths,
pagedNavigation: datePickerRootState.opts.pagedNavigation,
@@ -44,7 +44,7 @@
onDateSelect: datePickerRootState.opts.onDateSelect,
initialFocus: datePickerRootState.opts.initialFocus,
defaultPlaceholder: datePickerRootState.opts.defaultPlaceholder,
- maxDays: box.with(() => undefined),
+ maxUnits: box.with(() => undefined),
monthFormat: datePickerRootState.opts.monthFormat,
yearFormat: datePickerRootState.opts.yearFormat,
});
diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker-calendar.svelte b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker-calendar.svelte
index 3ab60eaf4..1114d0818 100644
--- a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker-calendar.svelte
+++ b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker-calendar.svelte
@@ -25,8 +25,8 @@
),
calendarLabel: dateRangePickerRootState.opts.calendarLabel,
fixedWeeks: dateRangePickerRootState.opts.fixedWeeks,
- isDateDisabled: dateRangePickerRootState.opts.isDateDisabled,
- isDateUnavailable: dateRangePickerRootState.opts.isDateUnavailable,
+ isUnitDisabled: dateRangePickerRootState.opts.isDateDisabled,
+ isUnitUnavailable: dateRangePickerRootState.opts.isDateUnavailable,
locale: dateRangePickerRootState.opts.locale,
numberOfMonths: dateRangePickerRootState.opts.numberOfMonths,
pagedNavigation: dateRangePickerRootState.opts.pagedNavigation,
@@ -45,8 +45,8 @@
startValue: dateRangePickerRootState.opts.startValue,
endValue: dateRangePickerRootState.opts.endValue,
defaultPlaceholder: dateRangePickerRootState.opts.defaultPlaceholder,
- minDays: dateRangePickerRootState.opts.minDays,
- maxDays: dateRangePickerRootState.opts.maxDays,
+ minUnits: dateRangePickerRootState.opts.minDays,
+ maxUnits: dateRangePickerRootState.opts.maxDays,
monthFormat: dateRangePickerRootState.opts.monthFormat,
yearFormat: dateRangePickerRootState.opts.yearFormat,
});
diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts
index 97608e625..dde07afdf 100644
--- a/packages/bits-ui/src/lib/bits/index.ts
+++ b/packages/bits-ui/src/lib/bits/index.ts
@@ -20,6 +20,7 @@ export { Label } from "./label/index.js";
export { LinkPreview } from "./link-preview/index.js";
export { Menubar } from "./menubar/index.js";
export { Meter } from "./meter/index.js";
+export { MonthCalendar } from "./month-calendar/index.js";
export { NavigationMenu } from "./navigation-menu/index.js";
export { Pagination } from "./pagination/index.js";
export { PinInput } from "./pin-input/index.js";
@@ -27,6 +28,7 @@ export { Popover } from "./popover/index.js";
export { Progress } from "./progress/index.js";
export { RadioGroup } from "./radio-group/index.js";
export { RangeCalendar } from "./range-calendar/index.js";
+export { RangeMonthCalendar } from "./range-month-calendar/index.js";
export { RatingGroup } from "./rating-group/index.js";
export { ScrollArea } from "./scroll-area/index.js";
export { Select } from "./select/index.js";
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-cell.svelte b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-cell.svelte
new file mode 100644
index 000000000..178a00a16
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-cell.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({
+ props: mergedProps,
+ ...cellState.snippetProps,
+ })}
+{:else}
+
+ {@render children?.(cellState.snippetProps)}
+ |
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-month.svelte b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-month.svelte
new file mode 100644
index 000000000..f2f9f42d2
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar-month.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({
+ props: mergedProps,
+ ...dayState.snippetProps,
+ })}
+{:else}
+
+ {#if children}
+ {@render children?.(dayState.snippetProps)}
+ {:else}
+ {dayState.cell.opts.date.current.day}
+ {/if}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar.svelte b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar.svelte
new file mode 100644
index 000000000..3aceb6c51
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/components/month-calendar.svelte
@@ -0,0 +1,123 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps, ...rootState.snippetProps })}
+{:else}
+
+ {@render children?.(rootState.snippetProps)}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/exports.ts b/packages/bits-ui/src/lib/bits/month-calendar/exports.ts
new file mode 100644
index 000000000..02d599313
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/exports.ts
@@ -0,0 +1,25 @@
+export { default as Root } from "./components/month-calendar.svelte";
+export { default as Day } from "./components/month-calendar-month.svelte";
+export { default as Cell } from "./components/month-calendar-cell.svelte";
+export { default as Grid } from "$lib/bits/calendar/components/calendar-grid.svelte";
+export { default as GridBody } from "$lib/bits/calendar/components/calendar-grid-body.svelte";
+export { default as GridRow } from "$lib/bits/calendar/components/calendar-grid-row.svelte";
+export { default as Header } from "$lib/bits/calendar/components/calendar-header.svelte";
+export { default as Heading } from "$lib/bits/calendar/components/calendar-heading.svelte";
+export { default as NextButton } from "$lib/bits/calendar/components/calendar-next-button.svelte";
+export { default as PrevButton } from "$lib/bits/calendar/components/calendar-prev-button.svelte";
+export { default as YearSelect } from "$lib/bits/calendar/components/calendar-year-select.svelte";
+
+export type {
+ MonthCalendarRootProps as RootProps,
+ MonthCalendarPrevButtonProps as PrevButtonProps,
+ MonthCalendarNextButtonProps as NextButtonProps,
+ MonthCalendarHeadingProps as HeadingProps,
+ MonthCalendarHeaderProps as HeaderProps,
+ MonthCalendarGridProps as GridProps,
+ MonthCalendarGridBodyProps as GridBodyProps,
+ MonthCalendarCellProps as CellProps,
+ MonthCalendarGridRowProps as GridRowProps,
+ MonthCalendarMonthProps as MonthProps,
+ MonthCalendarYearSelectProps as YearSelectProps,
+} from "./types.js";
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/index.ts b/packages/bits-ui/src/lib/bits/month-calendar/index.ts
new file mode 100644
index 000000000..a6e7c38e5
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/index.ts
@@ -0,0 +1 @@
+export * as MonthCalendar from "./exports.js";
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/month-calendar.svelte.ts b/packages/bits-ui/src/lib/bits/month-calendar/month-calendar.svelte.ts
new file mode 100644
index 000000000..12198da4d
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/month-calendar.svelte.ts
@@ -0,0 +1,322 @@
+import { type DateValue, getLocalTimeZone, isSameMonth, today } from "@internationalized/date";
+import { untrack } from "svelte";
+import { attachRef, type ReadableBoxedValues } from "svelte-toolbelt";
+import { Context } from "runed";
+import {
+ getAriaDisabled,
+ getAriaSelected,
+ getDataDisabled,
+ getDataSelected,
+ getDataUnavailable,
+} from "$lib/internal/attrs.js";
+import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js";
+import {
+ createYears,
+ getDefaultYears,
+ getIsNextMonthCalendarButtonDisabled,
+ getIsPrevMonthCalendarButtonDisabled,
+ getMonthCalendarHeadingValue,
+ handleMonthCalendarNextPage,
+ handleMonthCalendarPrevPage,
+ shiftCalendarFocus,
+ useEnsureNonDisabledPlaceholder,
+ useYearViewOptionsSync,
+ useYearViewPlaceholderSync,
+} from "$lib/internal/date-time/calendar-helpers.svelte.js";
+import { getDateValueType } from "$lib/internal/date-time/utils.js";
+import type { Year } from "$lib/shared/date/types.js";
+import {
+ CalendarBaseCellState,
+ CalendarBaseRootState,
+ CalendarBaseUnitState,
+ type CalendarBaseCellStateOpts,
+ type CalendarBaseRootStateOpts,
+ type CalendarBaseUnitStateOpts,
+} from "../../internal/date-time/calendar-base.svelte.js";
+import { CalendarRootContext } from "../calendar/calendar.svelte.js";
+
+interface MonthCalendarRootStateOpts
+ extends CalendarBaseRootStateOpts,
+ ReadableBoxedValues<{
+ numberOfYears: number;
+ monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+ yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
+ }> {}
+
+export class MonthCalendarRootState extends CalendarBaseRootState {
+ static create(opts: MonthCalendarRootStateOpts) {
+ return CalendarRootContext.set(new MonthCalendarRootState(opts)) as MonthCalendarRootState;
+ }
+
+ readonly visibleYears = $derived.by(() => this.years.map((year) => year.value));
+ readonly formatter: Formatter;
+ years: Year[] = $state([]);
+
+ constructor(opts: MonthCalendarRootStateOpts) {
+ super(opts, "month");
+ this.formatter = createFormatter({
+ initialLocale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat,
+ yearFormat: this.opts.yearFormat,
+ });
+
+ this.setYears = this.setYears.bind(this);
+ this.prevYear = this.prevYear.bind(this);
+ this.nextYear = this.nextYear.bind(this);
+ this.setYear = this.setYear.bind(this);
+
+ this.years = createYears({
+ dateObj: this.opts.placeholder.current,
+ monthFormat: this.opts.monthFormat.current,
+ locale: this.opts.locale.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ });
+
+ /**
+ * Updates the displayed years based on changes in the placeholder value.
+ */
+ useYearViewPlaceholderSync({
+ placeholder: this.opts.placeholder,
+ getVisibleYears: () => this.visibleYears,
+ locale: this.opts.locale,
+ monthFormat: this.opts.monthFormat,
+ numberOfYears: this.opts.numberOfYears,
+ setYears: (years) => (this.years = years),
+ });
+
+ /**
+ * Updates the displayed years based on changes in the options values,
+ * which determines the year to show in the calendar.
+ */
+ useYearViewOptionsSync({
+ locale: this.opts.locale,
+ numberOfYears: this.opts.numberOfYears,
+ placeholder: this.opts.placeholder,
+ monthFormat: this.opts.monthFormat,
+ setYears: this.setYears,
+ });
+
+ useEnsureNonDisabledPlaceholder({
+ placeholder: opts.placeholder,
+ defaultPlaceholder: opts.defaultPlaceholder,
+ isUnitDisabled: opts.isUnitDisabled,
+ maxValue: opts.maxValue,
+ minValue: opts.minValue,
+ ref: opts.ref,
+ unit: "month",
+ });
+ }
+
+ setYears(years: Year[]) {
+ this.years = years;
+ }
+
+ readonly initialPlaceholderYear = $derived.by(() =>
+ untrack(() => this.opts.placeholder.current.year)
+ );
+
+ readonly defaultYears = $derived.by(() => {
+ return getDefaultYears({
+ minValue: this.opts.minValue.current,
+ maxValue: this.opts.maxValue.current,
+ placeholderYear: this.initialPlaceholderYear,
+ });
+ });
+
+ /**
+ * Navigates to the next page of the calendar.
+ */
+ nextPage() {
+ handleMonthCalendarNextPage({
+ locale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ pagedNavigation: this.opts.pagedNavigation.current,
+ setYears: this.setYears,
+ setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
+ years: this.years,
+ });
+ }
+
+ /**
+ * Navigates to the previous page of the calendar.
+ */
+ prevPage() {
+ handleMonthCalendarPrevPage({
+ locale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ pagedNavigation: this.opts.pagedNavigation.current,
+ setYears: this.setYears,
+ setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
+ years: this.years,
+ });
+ }
+
+ nextYear() {
+ this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 });
+ }
+
+ prevYear() {
+ this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 });
+ }
+
+ setYear(year: number) {
+ this.opts.placeholder.current = this.opts.placeholder.current.set({ year });
+ }
+
+ isNextButtonDisabled = $derived.by(() => {
+ return getIsNextMonthCalendarButtonDisabled({
+ maxValue: this.opts.maxValue.current,
+ years: this.years,
+ disabled: this.opts.disabled.current,
+ });
+ });
+
+ isPrevButtonDisabled = $derived.by(() => {
+ return getIsPrevMonthCalendarButtonDisabled({
+ minValue: this.opts.minValue.current,
+ years: this.years,
+ disabled: this.opts.disabled.current,
+ });
+ });
+
+ readonly headingValue = $derived.by(() => {
+ this.opts.monthFormat.current;
+ this.opts.yearFormat.current;
+ return getMonthCalendarHeadingValue({
+ years: this.years,
+ formatter: this.formatter,
+ locale: this.opts.locale.current,
+ });
+ });
+
+ shiftFocus(node: HTMLElement, add: number) {
+ return shiftCalendarFocus({
+ node,
+ add,
+ placeholder: this.opts.placeholder,
+ calendarNode: this.opts.ref.current,
+ isPrevButtonDisabled: this.isPrevButtonDisabled,
+ isNextButtonDisabled: this.isNextButtonDisabled,
+ items: this.years,
+ numberOfUnits: this.opts.numberOfYears.current,
+ unit: "years",
+ });
+ }
+
+ readonly snippetProps = $derived.by(() => ({
+ years: this.years,
+ }));
+}
+
+const MonthCalendarCellContext = new Context(
+ "MonthCalendar.Cell | RangeMonthCalendar.Cell"
+);
+
+interface CalendarCellStateOpts
+ extends CalendarBaseCellStateOpts,
+ ReadableBoxedValues<{
+ year: DateValue;
+ }> {}
+
+export class MonthCalendarCellState extends CalendarBaseCellState<
+ CalendarCellStateOpts,
+ MonthCalendarRootState
+> {
+ static create(opts: CalendarCellStateOpts) {
+ return MonthCalendarCellContext.set(
+ new MonthCalendarCellState(opts, CalendarRootContext.get() as MonthCalendarRootState)
+ );
+ }
+
+ readonly isThisMonth = $derived.by(() =>
+ isSameMonth(today(getLocalTimeZone()), this.opts.date.current)
+ );
+ readonly labelText = $derived.by(() =>
+ this.root.formatter.custom(this.cellDate, {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })
+ );
+
+ constructor(opts: CalendarCellStateOpts, root: MonthCalendarRootState) {
+ super(opts, root);
+ }
+
+ readonly ariaDisabled = $derived.by(() => {
+ return this.isDisabled || this.isUnavailable;
+ });
+
+ readonly sharedDataAttrs = $derived.by(
+ () =>
+ ({
+ "data-unavailable": getDataUnavailable(this.isUnavailable),
+ "data-this-month": this.isThisMonth ? "" : undefined,
+ "data-focused": this.isFocusedUnit ? "" : undefined,
+ "data-selected": getDataSelected(this.isSelectedUnit),
+ "data-value": this.opts.date.current.toString(),
+ "data-type": getDateValueType(this.opts.date.current),
+ "data-disabled": getDataDisabled(this.isDisabled),
+ }) as const
+ );
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "gridcell",
+ "aria-selected": getAriaSelected(this.isSelectedUnit),
+ "aria-disabled": getAriaDisabled(this.ariaDisabled),
+ ...this.sharedDataAttrs,
+ [this.root.getBitsAttr("cell")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+interface CalendarMonthStateOpts extends CalendarBaseUnitStateOpts {}
+
+export class MonthCalendarMonthState extends CalendarBaseUnitState<
+ CalendarMonthStateOpts,
+ MonthCalendarCellState
+> {
+ static create(opts: CalendarMonthStateOpts) {
+ return new MonthCalendarMonthState(opts, MonthCalendarCellContext.get());
+ }
+
+ constructor(opts: CalendarMonthStateOpts, cell: MonthCalendarCellState) {
+ super(opts, cell);
+ }
+
+ readonly #tabindex = $derived.by(() =>
+ this.cell.isDisabled ? undefined : this.cell.isFocusedUnit ? 0 : -1
+ );
+
+ readonly snippetProps = $derived.by(() => ({
+ disabled: this.cell.isDisabled,
+ unavailable: this.cell.isUnavailable,
+ selected: this.cell.isSelectedUnit,
+ month: `${this.cell.opts.date.current.day}`,
+ }));
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "button",
+ "aria-label": this.cell.labelText,
+ "aria-disabled": getAriaDisabled(this.cell.ariaDisabled),
+ ...this.cell.sharedDataAttrs,
+ tabindex: this.#tabindex,
+ [this.cell.root.getBitsAttr("month")]: "",
+ // Shared logic for range month calendar and month calendar
+ "data-bits-month": "",
+ //
+ onclick: this.onclick,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
diff --git a/packages/bits-ui/src/lib/bits/month-calendar/types.ts b/packages/bits-ui/src/lib/bits/month-calendar/types.ts
new file mode 100644
index 000000000..0528e458c
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/month-calendar/types.ts
@@ -0,0 +1,271 @@
+import type { DateValue } from "@internationalized/date";
+import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js";
+import type { DateMatcher } from "$lib/shared/index.js";
+import type {
+ BitsPrimitiveDivAttributes,
+ BitsPrimitiveTdAttributes,
+} from "$lib/shared/attributes.js";
+import type { Year } from "$lib/shared/date/types.js";
+import type { CalendarCellSnippetProps } from "$lib/types.js";
+
+export type MonthCalendarRootSnippetProps = {
+ years: Year[];
+};
+
+type MonthCalendarBaseRootPropsWithoutHTML = {
+ /**
+ * The placeholder date, used to control the view of the
+ * calendar when no value is present.
+ *
+ * @default the current date
+ */
+ placeholder?: DateValue;
+
+ /**
+ * A callback function called when the placeholder value
+ * changes.
+ */
+ onPlaceholderChange?: OnChangeFn;
+
+ /**
+ * Whether or not users can deselect a date once selected
+ * without selecting another date.
+ *
+ * @default false
+ */
+ preventDeselect?: boolean;
+
+ /**
+ * The minimum date that can be selected in the calendar.
+ */
+ minValue?: DateValue;
+
+ /**
+ * The maximum date that can be selected in the calendar.
+ */
+ maxValue?: DateValue;
+
+ /**
+ * Whether or not the calendar is disabled.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Applicable only when `numberOfMonths` is greater than 1.
+ *
+ * Controls whether to use paged navigation for the next and previous buttons in the
+ * date picker. With paged navigation set to `true`, clicking the next/prev buttons
+ * changes all months in view. When set to `false`, it shifts the view by a single month.
+ *
+ * For example, with `pagedNavigation` set to `true` and 2 months displayed (January and
+ * February), clicking the next button changes the view to March and April. If `pagedNavigation`
+ * is `false`, the view shifts to February and March.
+ *
+ * @default false
+ */
+ pagedNavigation?: boolean;
+
+ /**
+ * A function that receives a date and returns `true` or `false` to indicate whether
+ * the date is disabled.
+ *
+ * @remarks
+ * Disabled dates cannot be focused or selected. Additionally, they are tagged
+ * with a data attribute to enable custom styling.
+ *
+ * `[data-disabled]` - applied to disabled dates
+ *
+ */
+ isMonthDisabled?: DateMatcher;
+
+ /**
+ * Dates matching the provided matchers are marked as "unavailable." Unlike disabled dates,
+ * users can still focus and select unavailable dates. However, selecting an unavailable date
+ * renders the date picker as invalid.
+ *
+ * For example, in a calendar for booking appointments, you might mark already booked dates as
+ * unavailable. These dates could become available again before the appointment date, allowing
+ * users to select them to learn more about the appointment.
+ *
+ * `[data-unavailable]` - applied to unavailable dates
+ *
+ */
+ isMonthUnavailable?: DateMatcher;
+
+ /**
+ * Determines the number of years to display on the calendar simultaneously.
+ * For navigation between months, refer to the `pagedNavigation` prop.
+ *
+ * @default 1
+ */
+ numberOfYears?: number;
+
+ /**
+ * This label is exclusively used for accessibility, remaining hidden from the page.
+ * It's read by screen readers when the calendar is opened. The current month and year
+ * are automatically appended to the label, so you only need to provide the base label.
+ *
+ * For instance:
+ * - 'Date of birth' will be read as 'Date of birth, January 2021' if the current month is January 2021.
+ * - 'Appointment date' will be read as 'Appointment date, January 2021' if the current month is January 2021.
+ * - 'Booking date' will be read as 'Booking date, January 2021' if the current month is January 2021.
+ */
+ calendarLabel?: string;
+
+ /**
+ * The default locale setting.
+ *
+ * @default 'en'
+ */
+ locale?: string;
+
+ /**
+ * Whether the calendar is readonly. When true, the user will be able
+ * to focus and navigate the calendar, but will not be able to select
+ * dates. @see disabled for a similar prop that prevents focusing
+ * and selecting dates.
+ *
+ * @default false
+ */
+ readonly?: boolean;
+
+ /**
+ * If `true`, the calendar will focus the selected day, today, or the first day of the month
+ * in that order depending on what is visible when the calendar is mounted.
+ */
+ initialFocus?: boolean;
+
+ /**
+ * The maximum number of months that can be selected in multiple mode.
+ * When set, users cannot select more dates than this number.
+ *
+ * @default undefined
+ */
+ maxMonths?: number;
+
+ /**
+ * The format of the month names in the calendar.
+ *
+ * @default "long"
+ */
+ monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+
+ /**
+ * The format of the year names in the calendar.
+ *
+ * @default "numeric"
+ */
+ yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
+};
+
+export type MonthCalendarSingleRootPropsWithoutHTML = {
+ /**
+ * The type of calendar. If set to `'single'`, the calendar will
+ * only allow a single date to be selected. If set to `'multiple'`,
+ * the calendar will allow multiple dates to be selected.
+ */
+ type: "single";
+
+ /**
+ * The value of the selected date in the calendar.
+ */
+ value?: DateValue;
+
+ /**
+ * A callback function called when the value changes.
+ */
+ onValueChange?: OnChangeFn;
+};
+
+export type MonthCalendarMultipleRootPropsWithoutHTML = {
+ /**
+ * The type of calendar. If set to `'single'`, the calendar will
+ * only allow a single date to be selected. If set to `'multiple'`,
+ * the calendar will allow multiple dates to be selected.
+ */
+ type: "multiple";
+
+ /**
+ * The value of the selected dates in the calendar.
+ */
+ value?: DateValue[];
+
+ /**
+ * A callback function called when the value changes.
+ */
+ onValueChange?: OnChangeFn;
+};
+
+export type _MonthCalendarSingleRootPropsWithoutHTML = MonthCalendarBaseRootPropsWithoutHTML &
+ MonthCalendarSingleRootPropsWithoutHTML;
+
+export type MonthCalendarSingleRootProps = _MonthCalendarSingleRootPropsWithoutHTML &
+ Without;
+
+export type _MonthCalendarMultipleRootPropsWithoutHTML = MonthCalendarBaseRootPropsWithoutHTML &
+ MonthCalendarMultipleRootPropsWithoutHTML;
+
+export type MonthCalendarMultipleRootProps = _MonthCalendarMultipleRootPropsWithoutHTML &
+ Without;
+
+export type MonthCalendarRootPropsWithoutHTML = MonthCalendarBaseRootPropsWithoutHTML &
+ (
+ | WithChild
+ | WithChild
+ );
+
+export type MonthCalendarRootProps = MonthCalendarRootPropsWithoutHTML &
+ Without;
+
+export type MonthCalendarCellPropsWithoutHTML = WithChild<
+ {
+ /**
+ * The month value of the cell.
+ *
+ * @required
+ */
+ month: DateValue;
+
+ /**
+ * The year DateValue that this cell is being rendered in.
+ */
+ year: DateValue;
+ },
+ CalendarCellSnippetProps
+>;
+
+export type MonthCalendarCellProps = MonthCalendarCellPropsWithoutHTML &
+ Without;
+
+export type MonthCalendarMonthSnippetProps = {
+ disabled: boolean;
+ unavailable: boolean;
+ selected: boolean;
+ month: string;
+};
+
+export type MonthCalendarMonthPropsWithoutHTML = WithChild<{}, MonthCalendarMonthSnippetProps>;
+
+export type MonthCalendarMonthProps = MonthCalendarMonthPropsWithoutHTML &
+ Without;
+
+export type {
+ CalendarPrevButtonProps as MonthCalendarPrevButtonProps,
+ CalendarPrevButtonPropsWithoutHTML as MonthCalendarPrevButtonPropsWithoutHTML,
+ CalendarNextButtonProps as MonthCalendarNextButtonProps,
+ CalendarNextButtonPropsWithoutHTML as MonthCalendarNextButtonPropsWithoutHTML,
+ CalendarHeadingProps as MonthCalendarHeadingProps,
+ CalendarHeadingPropsWithoutHTML as MonthCalendarHeadingPropsWithoutHTML,
+ CalendarGridProps as MonthCalendarGridProps,
+ CalendarGridPropsWithoutHTML as MonthCalendarGridPropsWithoutHTML,
+ CalendarGridBodyProps as MonthCalendarGridBodyProps,
+ CalendarGridBodyPropsWithoutHTML as MonthCalendarGridBodyPropsWithoutHTML,
+ CalendarGridRowProps as MonthCalendarGridRowProps,
+ CalendarGridRowPropsWithoutHTML as MonthCalendarGridRowPropsWithoutHTML,
+ CalendarHeaderProps as MonthCalendarHeaderProps,
+ CalendarHeaderPropsWithoutHTML as MonthCalendarHeaderPropsWithoutHTML,
+ CalendarYearSelectProps as MonthCalendarYearSelectProps,
+ CalendarYearSelectPropsWithoutHTML as MonthCalendarYearSelectPropsWithoutHTML,
+} from "../calendar/types.js";
diff --git a/packages/bits-ui/src/lib/bits/range-calendar/components/range-calendar.svelte b/packages/bits-ui/src/lib/bits/range-calendar/components/range-calendar.svelte
index 4187ea379..77c7e172e 100644
--- a/packages/bits-ui/src/lib/bits/range-calendar/components/range-calendar.svelte
+++ b/packages/bits-ui/src/lib/bits/range-calendar/components/range-calendar.svelte
@@ -107,8 +107,8 @@
preventDeselect: box.with(() => preventDeselect),
minValue: box.with(() => minValue),
maxValue: box.with(() => maxValue),
- isDateUnavailable: box.with(() => isDateUnavailable),
- isDateDisabled: box.with(() => isDateDisabled),
+ isUnitUnavailable: box.with(() => isDateUnavailable),
+ isUnitDisabled: box.with(() => isDateDisabled),
pagedNavigation: box.with(() => pagedNavigation),
weekStartsOn: box.with(() => weekStartsOn),
weekdayFormat: box.with(() => weekdayFormat),
@@ -117,8 +117,8 @@
calendarLabel: box.with(() => calendarLabel),
fixedWeeks: box.with(() => fixedWeeks),
disableDaysOutsideMonth: box.with(() => disableDaysOutsideMonth),
- minDays: box.with(() => minDays),
- maxDays: box.with(() => maxDays),
+ minUnits: box.with(() => minDays),
+ maxUnits: box.with(() => maxDays),
excludeDisabled: box.with(() => excludeDisabled),
startValue: box.with(
() => startValue,
diff --git a/packages/bits-ui/src/lib/bits/range-calendar/range-calendar.svelte.ts b/packages/bits-ui/src/lib/bits/range-calendar/range-calendar.svelte.ts
index 30e368991..a12fb1025 100644
--- a/packages/bits-ui/src/lib/bits/range-calendar/range-calendar.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/range-calendar/range-calendar.svelte.ts
@@ -1,26 +1,9 @@
-import {
- type DateValue,
- getLocalTimeZone,
- isSameDay,
- isSameMonth,
- isToday,
-} from "@internationalized/date";
-import {
- attachRef,
- DOMContext,
- type ReadableBoxedValues,
- type WritableBoxedValues,
-} from "svelte-toolbelt";
-import { Context, watch } from "runed";
+import { type DateValue, getLocalTimeZone, isSameMonth, isToday } from "@internationalized/date";
+import { attachRef, type ReadableBoxedValues } from "svelte-toolbelt";
+import { Context } from "runed";
import { CalendarRootContext } from "../calendar/calendar.svelte.js";
-import type { DateRange, Month } from "$lib/shared/index.js";
-import type {
- BitsFocusEvent,
- BitsKeyboardEvent,
- BitsMouseEvent,
- WithRefOpts,
-} from "$lib/internal/types.js";
-import { useId } from "$lib/internal/use-id.js";
+import type { Month } from "$lib/shared/index.js";
+import type { WithRefOpts } from "$lib/internal/types.js";
import {
getAriaDisabled,
getAriaSelected,
@@ -28,18 +11,14 @@ import {
getDataSelected,
getDataUnavailable,
} from "$lib/internal/attrs.js";
-import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js";
import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js";
import {
- calendarAttrs,
createMonths,
- getCalendarElementProps,
getCalendarHeadingValue,
getDefaultYears,
getIsNextButtonDisabled,
getIsPrevButtonDisabled,
getWeekdays,
- handleCalendarKeydown,
handleCalendarNextPage,
handleCalendarPrevPage,
shiftCalendarFocus,
@@ -47,71 +26,38 @@ import {
useMonthViewOptionsSync,
useMonthViewPlaceholderSync,
} from "$lib/internal/date-time/calendar-helpers.svelte.js";
-import {
- areAllDaysBetweenValid,
- getDateValueType,
- isAfter,
- isBefore,
- isBetweenInclusive,
- toDate,
-} from "$lib/internal/date-time/utils.js";
+import { getDateValueType, isBefore } from "$lib/internal/date-time/utils.js";
import type { WeekStartsOn } from "$lib/shared/date/types.js";
-import { onMount, untrack } from "svelte";
+import { untrack } from "svelte";
+import {
+ RangeCalendarBaseCellState,
+ RangeCalendarBaseRootState,
+ RangeCalendarBaseUnitState,
+ type RangeCalendarBaseRootStateOpts,
+} from "$lib/internal/date-time/calendar-range-base.svelte.js";
const RangeCalendarCellContext = new Context("RangeCalendar.Cell");
interface RangeCalendarRootStateOpts
- extends WithRefOpts,
- WritableBoxedValues<{
- value: DateRange;
- placeholder: DateValue;
- startValue: DateValue | undefined;
- endValue: DateValue | undefined;
- }>,
+ extends RangeCalendarBaseRootStateOpts,
ReadableBoxedValues<{
- preventDeselect: boolean;
- minValue: DateValue | undefined;
- maxValue: DateValue | undefined;
- disabled: boolean;
- pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
- isDateDisabled: (date: DateValue) => boolean;
- isDateUnavailable: (date: DateValue) => boolean;
fixedWeeks: boolean;
numberOfMonths: number;
- locale: string;
- calendarLabel: string;
- readonly: boolean;
disableDaysOutsideMonth: boolean;
- excludeDisabled: boolean;
- minDays: number | undefined;
- maxDays: number | undefined;
- /**
- * This is strictly used by the `DateRangePicker` component to close the popover when a date range
- * is selected. It is not intended to be used by the user.
- */
- onRangeSelect?: () => void;
monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
- }> {
- defaultPlaceholder: DateValue;
-}
+ }> {}
-export class RangeCalendarRootState {
+export class RangeCalendarRootState extends RangeCalendarBaseRootState {
static create(opts: RangeCalendarRootStateOpts) {
- return CalendarRootContext.set(new RangeCalendarRootState(opts));
+ return CalendarRootContext.set(new RangeCalendarRootState(opts)) as RangeCalendarRootState;
}
- readonly opts: RangeCalendarRootStateOpts;
readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value));
months: Month[] = $state([]);
- announcer: Announcer;
formatter: Formatter;
- accessibleHeadingId = useId();
- focusedValue = $state(undefined);
- lastPressedDateValue: DateValue | undefined = undefined;
- domContext: DOMContext;
/**
* This derived state holds an array of localized day names for the current
@@ -128,35 +74,6 @@ export class RangeCalendarRootState {
});
});
- readonly isStartInvalid = $derived.by(() => {
- if (!this.opts.startValue.current) return false;
- return (
- this.isDateUnavailable(this.opts.startValue.current) ||
- this.isDateDisabled(this.opts.startValue.current)
- );
- });
-
- readonly isEndInvalid = $derived.by(() => {
- if (!this.opts.endValue.current) return false;
- return (
- this.isDateUnavailable(this.opts.endValue.current) ||
- this.isDateDisabled(this.opts.endValue.current)
- );
- });
-
- readonly isInvalid = $derived.by(() => {
- if (this.isStartInvalid || this.isEndInvalid) return true;
-
- if (
- this.opts.endValue.current &&
- this.opts.startValue.current &&
- isBefore(this.opts.endValue.current, this.opts.startValue.current)
- )
- return true;
-
- return false;
- });
-
readonly isNextButtonDisabled = $derived.by(() => {
return getIsNextButtonDisabled({
maxValue: this.opts.maxValue.current,
@@ -183,34 +100,6 @@ export class RangeCalendarRootState {
});
});
- readonly fullCalendarLabel = $derived.by(
- () => `${this.opts.calendarLabel.current} ${this.headingValue}`
- );
-
- readonly highlightedRange = $derived.by(() => {
- if (this.opts.startValue.current && this.opts.endValue.current) return null;
- if (!this.opts.startValue.current || !this.focusedValue) return null;
-
- const isStartBeforeFocused = isBefore(this.opts.startValue.current, this.focusedValue);
- const start = isStartBeforeFocused ? this.opts.startValue.current : this.focusedValue;
- const end = isStartBeforeFocused ? this.focusedValue : this.opts.startValue.current;
- const range = { start, end };
-
- if (isSameDay(start.add({ days: 1 }), end) || isSameDay(start, end)) {
- return range;
- }
-
- const isValid = areAllDaysBetweenValid(
- start,
- end,
- this.isDateUnavailable,
- this.isDateDisabled
- );
-
- if (isValid) return range;
- return null;
- });
-
readonly initialPlaceholderYear = $derived.by(() =>
untrack(() => this.opts.placeholder.current.year)
);
@@ -224,9 +113,8 @@ export class RangeCalendarRootState {
});
constructor(opts: RangeCalendarRootStateOpts) {
- this.opts = opts;
- this.domContext = new DOMContext(opts.ref);
- this.announcer = getAnnouncer(null);
+ super(opts, "day");
+
this.formatter = createFormatter({
initialLocale: this.opts.locale.current,
monthFormat: this.opts.monthFormat,
@@ -241,15 +129,6 @@ export class RangeCalendarRootState {
numberOfMonths: this.opts.numberOfMonths.current,
});
- $effect.pre(() => {
- if (this.formatter.getLocale() === this.opts.locale.current) return;
- this.formatter.setLocale(this.opts.locale.current);
- });
-
- onMount(() => {
- this.announcer = getAnnouncer(this.domContext.getDocument());
- });
-
/**
* Updates the displayed months based on changes in the placeholder values,
* which determines the month to show in the calendar.
@@ -277,170 +156,24 @@ export class RangeCalendarRootState {
weekStartsOn: this.opts.weekStartsOn,
});
- /**
- * Update the accessible heading's text content when the `fullCalendarLabel`
- * changes.
- */
- $effect(() => {
- const node = this.domContext.getElementById(this.accessibleHeadingId);
- if (!node) return;
- node.textContent = this.fullCalendarLabel;
- });
-
- /**
- * Synchronize the start and end values with the `value` in case
- * it is updated externally.
- */
- watch(
- () => this.opts.value.current,
- (value) => {
- if (value.start && value.end) {
- this.opts.startValue.current = value.start;
- this.opts.endValue.current = value.end;
- } else if (value.start) {
- this.opts.startValue.current = value.start;
- this.opts.endValue.current = undefined;
- } else if (value.start === undefined && value.end === undefined) {
- this.opts.startValue.current = undefined;
- this.opts.endValue.current = undefined;
- }
- }
- );
-
- /**
- * Synchronize the placeholder value with the current start value
- */
- watch(
- () => this.opts.value.current,
- (value) => {
- const startValue = value.start;
- if (startValue && this.opts.placeholder.current !== startValue) {
- this.opts.placeholder.current = startValue;
- }
- }
- );
-
- /**
- * Check for disabled dates in the selected range when excludeDisabled is enabled
- */
- watch(
- [
- () => this.opts.startValue.current,
- () => this.opts.endValue.current,
- () => this.opts.excludeDisabled.current,
- ],
- ([startValue, endValue, excludeDisabled]) => {
- if (!excludeDisabled || !startValue || !endValue) return;
-
- if (this.#hasDisabledDatesInRange(startValue, endValue)) {
- this.#setStartValue(undefined);
- this.#setEndValue(undefined);
- this.#announceEmpty();
- }
- }
- );
-
- watch(
- [() => this.opts.startValue.current, () => this.opts.endValue.current],
- ([startValue, endValue]) => {
- if (
- this.opts.value.current &&
- this.opts.value.current.start === startValue &&
- this.opts.value.current.end === endValue
- ) {
- return;
- }
-
- if (startValue && endValue) {
- this.#updateValue((prev) => {
- if (prev.start === startValue && prev.end === endValue) {
- return prev;
- }
- if (isBefore(endValue, startValue)) {
- const start = startValue;
- const end = endValue;
- this.#setStartValue(end);
- this.#setEndValue(start);
- if (!this.#isRangeValid(endValue, startValue)) {
- this.#setStartValue(startValue);
- this.#setEndValue(undefined);
- return { start: startValue, end: undefined };
- }
- return { start: endValue, end: startValue };
- } else {
- if (!this.#isRangeValid(startValue, endValue)) {
- this.#setStartValue(endValue);
- this.#setEndValue(undefined);
- return { start: endValue, end: undefined };
- }
- return {
- start: startValue,
- end: endValue,
- };
- }
- });
- } else if (
- this.opts.value.current &&
- this.opts.value.current.start &&
- this.opts.value.current.end
- ) {
- this.opts.value.current.start = undefined;
- this.opts.value.current.end = undefined;
- }
- }
- );
-
- this.shiftFocus = this.shiftFocus.bind(this);
- this.handleCellClick = this.handleCellClick.bind(this);
- this.onkeydown = this.onkeydown.bind(this);
- this.nextPage = this.nextPage.bind(this);
- this.prevPage = this.prevPage.bind(this);
this.nextYear = this.nextYear.bind(this);
this.prevYear = this.prevYear.bind(this);
this.setYear = this.setYear.bind(this);
this.setMonth = this.setMonth.bind(this);
- this.isDateDisabled = this.isDateDisabled.bind(this);
- this.isDateUnavailable = this.isDateUnavailable.bind(this);
+
this.isOutsideVisibleMonths = this.isOutsideVisibleMonths.bind(this);
- this.isSelected = this.isSelected.bind(this);
useEnsureNonDisabledPlaceholder({
placeholder: opts.placeholder,
defaultPlaceholder: opts.defaultPlaceholder,
- isDateDisabled: opts.isDateDisabled,
+ isUnitDisabled: opts.isUnitDisabled,
maxValue: opts.maxValue,
minValue: opts.minValue,
ref: opts.ref,
+ unit: "day",
});
}
- #updateValue(cb: (value: DateRange) => DateRange) {
- const value = this.opts.value.current;
- const newValue = cb(value);
- this.opts.value.current = newValue;
- if (newValue.start && newValue.end) {
- this.opts.onRangeSelect?.current?.();
- }
- }
-
- #setStartValue(value: DateValue | undefined) {
- this.opts.startValue.current = value;
- // update the main value prop immediately for external consumers
- this.#updateValue((prev) => ({
- ...prev,
- start: value,
- }));
- }
-
- #setEndValue(value: DateValue | undefined) {
- this.opts.endValue.current = value;
- // update the main value prop immediately for external consumers
- this.#updateValue((prev) => ({
- ...prev,
- end: value,
- }));
- }
-
setMonths = (months: Month[]) => {
this.months = months;
};
@@ -449,45 +182,7 @@ export class RangeCalendarRootState {
return !this.visibleMonths.some((month) => isSameMonth(date, month));
}
- isDateDisabled(date: DateValue) {
- if (this.opts.isDateDisabled.current(date) || this.opts.disabled.current) return true;
- const minValue = this.opts.minValue.current;
- const maxValue = this.opts.maxValue.current;
- if (minValue && isBefore(date, minValue)) return true;
- if (maxValue && isAfter(date, maxValue)) return true;
- return false;
- }
-
- isDateUnavailable(date: DateValue) {
- if (this.opts.isDateUnavailable.current(date)) return true;
- return false;
- }
-
- isSelectionStart(date: DateValue) {
- if (!this.opts.startValue.current) return false;
- return isSameDay(date, this.opts.startValue.current);
- }
-
- isSelectionEnd(date: DateValue) {
- if (!this.opts.endValue.current) return false;
- return isSameDay(date, this.opts.endValue.current);
- }
-
- isSelected(date: DateValue) {
- if (this.opts.startValue.current && isSameDay(this.opts.startValue.current, date))
- return true;
- if (this.opts.endValue.current && isSameDay(this.opts.endValue.current, date)) return true;
- if (this.opts.startValue.current && this.opts.endValue.current) {
- return isBetweenInclusive(
- date,
- this.opts.startValue.current,
- this.opts.endValue.current
- );
- }
- return false;
- }
-
- #isRangeValid(start: DateValue, end: DateValue): boolean {
+ isRangeValid(start: DateValue, end: DateValue): boolean {
// ensure we always use the correct order for calculation
const orderedStart = isBefore(end, start) ? end : start;
const orderedEnd = isBefore(end, start) ? start : end;
@@ -499,13 +194,13 @@ export class RangeCalendarRootState {
const daysDifference = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const daysInRange = daysDifference + 1; // +1 to include both start and end days
- if (this.opts.minDays.current && daysInRange < this.opts.minDays.current) return false;
- if (this.opts.maxDays.current && daysInRange > this.opts.maxDays.current) return false;
+ if (this.opts.minUnits.current && daysInRange < this.opts.minUnits.current) return false;
+ if (this.opts.maxUnits.current && daysInRange > this.opts.maxUnits.current) return false;
// check for disabled dates in range if excludeDisabled is enabled
if (
this.opts.excludeDisabled.current &&
- this.#hasDisabledDatesInRange(orderedStart, orderedEnd)
+ this.hasDisabledDatesInRange(orderedStart, orderedEnd)
) {
return false;
}
@@ -521,108 +216,9 @@ export class RangeCalendarRootState {
calendarNode: this.opts.ref.current,
isPrevButtonDisabled: this.isPrevButtonDisabled,
isNextButtonDisabled: this.isNextButtonDisabled,
- months: this.months,
- numberOfMonths: this.opts.numberOfMonths.current,
- });
- }
-
- #announceEmpty() {
- this.announcer.announce("Selected date is now empty.", "polite");
- }
-
- #announceSelectedDate(date: DateValue) {
- this.announcer.announce(
- `Selected Date: ${this.formatter.selectedDate(date, false)}`,
- "polite"
- );
- }
-
- #announceSelectedRange(start: DateValue, end: DateValue) {
- this.announcer.announce(
- `Selected Dates: ${this.formatter.selectedDate(start, false)} to ${this.formatter.selectedDate(end, false)}`,
- "polite"
- );
- }
-
- handleCellClick(e: Event, date: DateValue) {
- if (this.isDateDisabled(date) || this.isDateUnavailable(date)) return;
- const prevLastPressedDate = this.lastPressedDateValue;
- this.lastPressedDateValue = date;
-
- if (this.opts.startValue.current && this.highlightedRange === null) {
- if (
- isSameDay(this.opts.startValue.current, date) &&
- !this.opts.preventDeselect.current &&
- !this.opts.endValue.current
- ) {
- this.#setStartValue(undefined);
- this.opts.placeholder.current = date;
- this.#announceEmpty();
- return;
- } else if (!this.opts.endValue.current) {
- e.preventDefault();
- if (prevLastPressedDate && isSameDay(prevLastPressedDate, date)) {
- this.#setStartValue(date);
- this.#announceSelectedDate(date);
- }
- }
- }
-
- if (
- this.opts.startValue.current &&
- this.opts.endValue.current &&
- isSameDay(this.opts.endValue.current, date) &&
- !this.opts.preventDeselect.current
- ) {
- this.#setStartValue(undefined);
- this.#setEndValue(undefined);
- this.opts.placeholder.current = date;
- this.#announceEmpty();
- return;
- }
-
- if (!this.opts.startValue.current) {
- this.#announceSelectedDate(date);
- this.#setStartValue(date);
- } else if (!this.opts.endValue.current) {
- // determine the start and end dates for validation
- const startDate = this.opts.startValue.current;
- const endDate = date;
- const orderedStart = isBefore(endDate, startDate) ? endDate : startDate;
- const orderedEnd = isBefore(endDate, startDate) ? startDate : endDate;
-
- // check if the range violates constraints
- if (!this.#isRangeValid(orderedStart, orderedEnd)) {
- // reset to just the clicked date
- this.#setStartValue(date);
- this.#setEndValue(undefined);
- this.#announceSelectedDate(date);
- } else {
- // ensure start and end are properly ordered
- if (isBefore(endDate, startDate)) {
- // backward selection - reorder the values
- this.#setStartValue(endDate);
- this.#setEndValue(startDate);
- this.#announceSelectedRange(endDate, startDate);
- } else {
- // forward selection - keep original order
- this.#setEndValue(date);
- this.#announceSelectedRange(this.opts.startValue.current, date);
- }
- }
- } else if (this.opts.endValue.current && this.opts.startValue.current) {
- this.#setEndValue(undefined);
- this.#announceSelectedDate(date);
- this.#setStartValue(date);
- }
- }
-
- onkeydown(event: BitsKeyboardEvent) {
- return handleCalendarKeydown({
- event,
- handleCellClick: this.handleCellClick,
- placeholderValue: this.opts.placeholder.current,
- shiftFocus: this.shiftFocus,
+ items: this.months,
+ numberOfUnits: this.opts.numberOfMonths.current,
+ unit: "months",
});
}
@@ -674,42 +270,10 @@ export class RangeCalendarRootState {
this.opts.placeholder.current = this.opts.placeholder.current.set({ month });
}
- getBitsAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
- return calendarAttrs.getAttr(part, "range-calendar");
- };
-
readonly snippetProps = $derived.by(() => ({
months: this.months,
weekdays: this.weekdays,
}));
-
- readonly props = $derived.by(
- () =>
- ({
- ...getCalendarElementProps({
- fullCalendarLabel: this.fullCalendarLabel,
- id: this.opts.id.current,
- isInvalid: this.isInvalid,
- disabled: this.opts.disabled.current,
- readonly: this.opts.readonly.current,
- }),
- [this.getBitsAttr("root")]: "",
- //
- onkeydown: this.onkeydown,
- ...attachRef(this.opts.ref),
- }) as const
- );
-
- #hasDisabledDatesInRange(start: DateValue, end: DateValue): boolean {
- for (
- let date = start;
- isBefore(date, end) || isSameDay(date, end);
- date = date.add({ days: 1 })
- ) {
- if (this.isDateDisabled(date)) return true;
- }
- return false;
- }
}
interface RangeCalendarCellStateOpts
@@ -719,19 +283,16 @@ interface RangeCalendarCellStateOpts
month: DateValue;
}> {}
-export class RangeCalendarCellState {
+export class RangeCalendarCellState extends RangeCalendarBaseCellState<
+ RangeCalendarCellStateOpts,
+ RangeCalendarRootState
+> {
static create(opts: RangeCalendarCellStateOpts) {
return RangeCalendarCellContext.set(
new RangeCalendarCellState(opts, CalendarRootContext.get() as RangeCalendarRootState)
);
}
- readonly opts: RangeCalendarCellStateOpts;
- readonly root: RangeCalendarRootState;
- readonly cellDate = $derived.by(() => toDate(this.opts.date.current));
- readonly isDisabled = $derived.by(() => this.root.isDateDisabled(this.opts.date.current));
- readonly isUnavailable = $derived.by(() =>
- this.root.opts.isDateUnavailable.current(this.opts.date.current)
- );
+
readonly isDateToday = $derived.by(() => isToday(this.opts.date.current, getLocalTimeZone()));
readonly isOutsideMonth = $derived.by(
() => !isSameMonth(this.opts.date.current, this.opts.month.current)
@@ -739,38 +300,6 @@ export class RangeCalendarCellState {
readonly isOutsideVisibleMonths = $derived.by(() =>
this.root.isOutsideVisibleMonths(this.opts.date.current)
);
- readonly isFocusedDate = $derived.by(() =>
- isSameDay(this.opts.date.current, this.root.opts.placeholder.current)
- );
- readonly isSelectedDate = $derived.by(() => this.root.isSelected(this.opts.date.current));
- readonly isSelectionStart = $derived.by(() =>
- this.root.isSelectionStart(this.opts.date.current)
- );
-
- readonly isRangeStart = $derived.by(() => this.root.isSelectionStart(this.opts.date.current));
-
- readonly isRangeEnd = $derived.by(() => {
- if (!this.root.opts.endValue.current)
- return this.root.isSelectionStart(this.opts.date.current);
- return this.root.isSelectionEnd(this.opts.date.current);
- });
-
- readonly isRangeMiddle = $derived.by(() => this.isSelectionMiddle);
-
- readonly isSelectionMiddle = $derived.by(() => {
- return this.isSelectedDate && !this.isSelectionStart && !this.isSelectionEnd;
- });
-
- readonly isSelectionEnd = $derived.by(() => this.root.isSelectionEnd(this.opts.date.current));
- readonly isHighlighted = $derived.by(() =>
- this.root.highlightedRange
- ? isBetweenInclusive(
- this.opts.date.current,
- this.root.highlightedRange.start,
- this.root.highlightedRange.end
- )
- : false
- );
readonly labelText = $derived.by(() =>
this.root.formatter.custom(this.cellDate, {
@@ -781,17 +310,6 @@ export class RangeCalendarCellState {
})
);
- constructor(opts: RangeCalendarCellStateOpts, root: RangeCalendarRootState) {
- this.opts = opts;
- this.root = root;
- }
-
- readonly snippetProps = $derived.by(() => ({
- disabled: this.isDisabled,
- unavailable: this.isUnavailable,
- selected: this.isSelectedDate,
- }));
-
readonly ariaDisabled = $derived.by(() => {
return (
this.isDisabled ||
@@ -807,7 +325,7 @@ export class RangeCalendarCellState {
"data-today": this.isDateToday ? "" : undefined,
"data-outside-month": this.isOutsideMonth ? "" : undefined,
"data-outside-visible-months": this.isOutsideVisibleMonths ? "" : undefined,
- "data-focused": this.isFocusedDate ? "" : undefined,
+ "data-focused": this.isFocusedUnit ? "" : undefined,
"data-selection-start": this.isSelectionStart ? "" : undefined,
"data-selection-end": this.isSelectionEnd ? "" : undefined,
"data-range-start": this.isRangeStart ? "" : undefined,
@@ -840,47 +358,23 @@ export class RangeCalendarCellState {
interface RangeCalendarDayStateOpts extends WithRefOpts {}
-export class RangeCalendarDayState {
+export class RangeCalendarDayState extends RangeCalendarBaseUnitState<
+ RangeCalendarDayStateOpts,
+ RangeCalendarCellState
+> {
static create(opts: RangeCalendarDayStateOpts) {
return new RangeCalendarDayState(opts, RangeCalendarCellContext.get());
}
- readonly opts: RangeCalendarDayStateOpts;
- readonly cell: RangeCalendarCellState;
-
- constructor(opts: RangeCalendarDayStateOpts, cell: RangeCalendarCellState) {
- this.opts = opts;
- this.cell = cell;
-
- this.onclick = this.onclick.bind(this);
- this.onmouseenter = this.onmouseenter.bind(this);
- this.onfocusin = this.onfocusin.bind(this);
- }
-
readonly #tabindex = $derived.by(() =>
(this.cell.isOutsideMonth && this.cell.root.opts.disableDaysOutsideMonth.current) ||
this.cell.isDisabled
? undefined
- : this.cell.isFocusedDate
+ : this.cell.isFocusedUnit
? 0
: -1
);
- onclick(e: BitsMouseEvent) {
- if (this.cell.isDisabled) return;
- this.cell.root.handleCellClick(e, this.cell.opts.date.current);
- }
-
- onmouseenter(_: BitsMouseEvent) {
- if (this.cell.isDisabled) return;
- this.cell.root.focusedValue = this.cell.opts.date.current;
- }
-
- onfocusin(_: BitsFocusEvent) {
- if (this.cell.isDisabled) return;
- this.cell.root.focusedValue = this.cell.opts.date.current;
- }
-
readonly snippetProps = $derived.by(() => ({
disabled: this.cell.isDisabled,
unavailable: this.cell.isUnavailable,
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-cell.svelte b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-cell.svelte
new file mode 100644
index 000000000..8ac6100d7
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-cell.svelte
@@ -0,0 +1,38 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps, ...cellState.snippetProps })}
+{:else}
+
+ {@render children?.(cellState.snippetProps)}
+ |
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-month.svelte b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-month.svelte
new file mode 100644
index 000000000..645cef9af
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar-month.svelte
@@ -0,0 +1,38 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps, ...dayState.snippetProps })}
+{:else}
+
+ {#if children}
+ {@render children?.(dayState.snippetProps)}
+ {:else}
+ {dayState.cell.opts.date.current.day}
+ {/if}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar.svelte b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar.svelte
new file mode 100644
index 000000000..16e1b7010
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/components/range-month-calendar.svelte
@@ -0,0 +1,143 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps, ...rootState.snippetProps })}
+{:else}
+
+ {@render children?.(rootState.snippetProps)}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/exports.ts b/packages/bits-ui/src/lib/bits/range-month-calendar/exports.ts
new file mode 100644
index 000000000..71faeaa4d
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/exports.ts
@@ -0,0 +1,25 @@
+export { default as Root } from "./components/range-month-calendar.svelte";
+export { default as Day } from "./components/range-month-calendar-month.svelte";
+export { default as Cell } from "./components/range-month-calendar-cell.svelte";
+export { default as Grid } from "$lib/bits/calendar/components/calendar-grid.svelte";
+export { default as GridBody } from "$lib/bits/calendar/components/calendar-grid-body.svelte";
+export { default as GridRow } from "$lib/bits/calendar/components/calendar-grid-row.svelte";
+export { default as Header } from "$lib/bits/calendar/components/calendar-header.svelte";
+export { default as Heading } from "$lib/bits/calendar/components/calendar-heading.svelte";
+export { default as NextButton } from "$lib/bits/calendar/components/calendar-next-button.svelte";
+export { default as PrevButton } from "$lib/bits/calendar/components/calendar-prev-button.svelte";
+export { default as YearSelect } from "$lib/bits/calendar/components/calendar-year-select.svelte";
+
+export type {
+ RangeMonthCalendarRootProps as RootProps,
+ RangeMonthCalendarPrevButtonProps as PrevButtonProps,
+ RangeMonthCalendarNextButtonProps as NextButtonProps,
+ RangeMonthCalendarHeadingProps as HeadingProps,
+ RangeMonthCalendarHeaderProps as HeaderProps,
+ RangeMonthCalendarGridProps as GridProps,
+ RangeMonthCalendarGridBodyProps as GridBodyProps,
+ RangeMonthCalendarCellProps as CellProps,
+ RangeMonthCalendarGridRowProps as GridRowProps,
+ RangeMonthCalendarDayProps as DayProps,
+ RangeMonthCalendarYearSelectProps as YearSelectProps,
+} from "./types.js";
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/index.ts b/packages/bits-ui/src/lib/bits/range-month-calendar/index.ts
new file mode 100644
index 000000000..a51b4f71b
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/index.ts
@@ -0,0 +1 @@
+export * as RangeMonthCalendar from "./exports.js";
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/range-month-calendar.svelte.ts b/packages/bits-ui/src/lib/bits/range-month-calendar/range-month-calendar.svelte.ts
new file mode 100644
index 000000000..a7c610530
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/range-month-calendar.svelte.ts
@@ -0,0 +1,355 @@
+import { type DateValue, getLocalTimeZone, isSameMonth, today } from "@internationalized/date";
+import { attachRef, type ReadableBoxedValues } from "svelte-toolbelt";
+import { Context } from "runed";
+import { CalendarRootContext } from "../calendar/calendar.svelte.js";
+import type { WithRefOpts } from "$lib/internal/types.js";
+import {
+ getAriaDisabled,
+ getAriaSelected,
+ getDataDisabled,
+ getDataSelected,
+ getDataUnavailable,
+} from "$lib/internal/attrs.js";
+import { type Formatter, createFormatter } from "$lib/internal/date-time/formatter.js";
+import {
+ createYears,
+ getDefaultYears,
+ getIsNextMonthCalendarButtonDisabled,
+ getIsPrevMonthCalendarButtonDisabled,
+ getMonthCalendarHeadingValue,
+ handleMonthCalendarNextPage,
+ handleMonthCalendarPrevPage,
+ shiftCalendarFocus,
+ useEnsureNonDisabledPlaceholder,
+ useYearViewOptionsSync,
+ useYearViewPlaceholderSync,
+} from "$lib/internal/date-time/calendar-helpers.svelte.js";
+import { getDateValueType, isBefore } from "$lib/internal/date-time/utils.js";
+import type { Year } from "$lib/shared/date/types.js";
+import { untrack } from "svelte";
+import {
+ RangeCalendarBaseCellState,
+ RangeCalendarBaseRootState,
+ RangeCalendarBaseUnitState,
+ type RangeCalendarBaseRootStateOpts,
+} from "$lib/internal/date-time/calendar-range-base.svelte.js";
+
+const RangeMonthCalendarCellContext = new Context(
+ "RangeMonthCalendar.Cell"
+);
+
+interface RangeMonthCalendarRootStateOpts
+ extends RangeCalendarBaseRootStateOpts,
+ ReadableBoxedValues<{
+ numberOfYears: number;
+ monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+ yearFormat: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
+ }> {}
+
+export class RangeMonthCalendarRootState extends RangeCalendarBaseRootState {
+ static create(opts: RangeMonthCalendarRootStateOpts) {
+ return CalendarRootContext.set(
+ new RangeMonthCalendarRootState(opts)
+ ) as RangeMonthCalendarRootState;
+ }
+
+ readonly visibleYears = $derived.by(() => this.years.map((month) => month.value));
+ years: Year[] = $state([]);
+ formatter: Formatter;
+
+ readonly isNextButtonDisabled = $derived.by(() => {
+ return getIsNextMonthCalendarButtonDisabled({
+ maxValue: this.opts.maxValue.current,
+ years: this.years,
+ disabled: this.opts.disabled.current,
+ });
+ });
+
+ readonly isPrevButtonDisabled = $derived.by(() => {
+ return getIsPrevMonthCalendarButtonDisabled({
+ minValue: this.opts.minValue.current,
+ years: this.years,
+ disabled: this.opts.disabled.current,
+ });
+ });
+
+ readonly headingValue = $derived.by(() => {
+ this.opts.monthFormat.current;
+ this.opts.yearFormat.current;
+ return getMonthCalendarHeadingValue({
+ years: this.years,
+ formatter: this.formatter,
+ locale: this.opts.locale.current,
+ });
+ });
+
+ readonly initialPlaceholderYear = $derived.by(() =>
+ untrack(() => this.opts.placeholder.current.year)
+ );
+
+ readonly defaultYears = $derived.by(() => {
+ return getDefaultYears({
+ minValue: this.opts.minValue.current,
+ maxValue: this.opts.maxValue.current,
+ placeholderYear: this.initialPlaceholderYear,
+ });
+ });
+
+ constructor(opts: RangeMonthCalendarRootStateOpts) {
+ super(opts, "day");
+
+ this.formatter = createFormatter({
+ initialLocale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat,
+ yearFormat: this.opts.yearFormat,
+ });
+
+ this.years = createYears({
+ dateObj: this.opts.placeholder.current,
+ monthFormat: this.opts.monthFormat.current,
+ locale: this.opts.locale.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ });
+
+ /**
+ * Updates the displayed years based on changes in the placeholder values,
+ * which determines the year to show in the calendar.
+ */
+ useYearViewPlaceholderSync({
+ placeholder: this.opts.placeholder,
+ getVisibleYears: () => this.visibleYears,
+ locale: this.opts.locale,
+ monthFormat: this.opts.monthFormat,
+ numberOfYears: this.opts.numberOfYears,
+ setYears: (years) => (this.years = years),
+ });
+
+ /**
+ * Updates the displayed years based on changes in the options values,
+ * which determines the year to show in the calendar.
+ */
+ useYearViewOptionsSync({
+ locale: this.opts.locale,
+ numberOfYears: this.opts.numberOfYears,
+ placeholder: this.opts.placeholder,
+ monthFormat: this.opts.monthFormat,
+ setYears: this.setYears,
+ });
+
+ this.nextYear = this.nextYear.bind(this);
+ this.prevYear = this.prevYear.bind(this);
+ this.setYear = this.setYear.bind(this);
+
+ useEnsureNonDisabledPlaceholder({
+ placeholder: opts.placeholder,
+ defaultPlaceholder: opts.defaultPlaceholder,
+ isUnitDisabled: opts.isUnitDisabled,
+ maxValue: opts.maxValue,
+ minValue: opts.minValue,
+ ref: opts.ref,
+ unit: "month",
+ });
+ }
+
+ setYears = (years: Year[]) => {
+ this.years = years;
+ };
+
+ isRangeValid(start: DateValue, end: DateValue): boolean {
+ // Ensure correct order
+ const orderedStart = isBefore(end, start) ? end : start;
+ const orderedEnd = isBefore(end, start) ? start : end;
+
+ // Calculate month difference inline
+ const monthsDifference =
+ (orderedEnd.year - orderedStart.year) * 12 +
+ (orderedEnd.month - orderedStart.month) +
+ 1; // +1 to include both start and end months
+
+ if (this.opts.minUnits.current && monthsDifference < this.opts.minUnits.current)
+ return false;
+ if (this.opts.maxUnits.current && monthsDifference > this.opts.maxUnits.current)
+ return false;
+
+ if (
+ this.opts.excludeDisabled.current &&
+ this.hasDisabledDatesInRange(orderedStart, orderedEnd)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ shiftFocus(node: HTMLElement, add: number) {
+ return shiftCalendarFocus({
+ node,
+ add,
+ placeholder: this.opts.placeholder,
+ calendarNode: this.opts.ref.current,
+ isPrevButtonDisabled: this.isPrevButtonDisabled,
+ isNextButtonDisabled: this.isNextButtonDisabled,
+ items: this.years,
+ numberOfUnits: this.opts.numberOfYears.current,
+ unit: "years",
+ });
+ }
+
+ /**
+ * Navigates to the next page of the calendar.
+ */
+ nextPage() {
+ handleMonthCalendarNextPage({
+ locale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ pagedNavigation: this.opts.pagedNavigation.current,
+ setYears: this.setYears,
+ setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
+ years: this.years,
+ });
+ }
+
+ /**
+ * Navigates to the previous page of the calendar.
+ */
+ prevPage() {
+ handleMonthCalendarPrevPage({
+ locale: this.opts.locale.current,
+ monthFormat: this.opts.monthFormat.current,
+ numberOfYears: this.opts.numberOfYears.current,
+ pagedNavigation: this.opts.pagedNavigation.current,
+ setYears: this.setYears,
+ setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date),
+ years: this.years,
+ });
+ }
+
+ nextYear() {
+ this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 });
+ }
+
+ prevYear() {
+ this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 });
+ }
+
+ setYear(year: number) {
+ this.opts.placeholder.current = this.opts.placeholder.current.set({ year });
+ }
+
+ readonly snippetProps = $derived.by(() => ({
+ years: this.years,
+ }));
+}
+
+interface RangeMonthCalendarCellStateOpts
+ extends WithRefOpts,
+ ReadableBoxedValues<{
+ date: DateValue;
+ year: DateValue;
+ }> {}
+
+export class RangeMonthCalendarCellState extends RangeCalendarBaseCellState<
+ RangeMonthCalendarCellStateOpts,
+ RangeMonthCalendarRootState
+> {
+ static create(opts: RangeMonthCalendarCellStateOpts) {
+ return RangeMonthCalendarCellContext.set(
+ new RangeMonthCalendarCellState(
+ opts,
+ CalendarRootContext.get() as RangeMonthCalendarRootState
+ )
+ );
+ }
+
+ readonly isThisMonth = $derived.by(() =>
+ isSameMonth(today(getLocalTimeZone()), this.opts.date.current)
+ );
+
+ readonly labelText = $derived.by(() =>
+ this.root.formatter.custom(this.cellDate, {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })
+ );
+
+ readonly ariaDisabled = $derived.by(() => {
+ return this.isDisabled || this.isUnavailable;
+ });
+
+ readonly sharedDataAttrs = $derived.by(
+ () =>
+ ({
+ "data-unavailable": getDataUnavailable(this.isUnavailable),
+ "data-this-month": this.isThisMonth ? "" : undefined,
+ "data-focused": this.isFocusedUnit ? "" : undefined,
+ "data-selection-start": this.isSelectionStart ? "" : undefined,
+ "data-selection-end": this.isSelectionEnd ? "" : undefined,
+ "data-range-start": this.isRangeStart ? "" : undefined,
+ "data-range-end": this.isRangeEnd ? "" : undefined,
+ "data-range-middle": this.isRangeMiddle ? "" : undefined,
+ "data-highlighted": this.isHighlighted ? "" : undefined,
+ "data-selected": getDataSelected(this.isSelectedDate),
+ "data-value": this.opts.date.current.toString(),
+ "data-type": getDateValueType(this.opts.date.current),
+ "data-disabled": getDataDisabled(this.isDisabled),
+ }) as const
+ );
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "gridcell",
+ "aria-selected": getAriaSelected(this.isSelectedDate),
+ "aria-disabled": getAriaDisabled(this.ariaDisabled),
+ ...this.sharedDataAttrs,
+ [this.root.getBitsAttr("cell")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+interface RangeMonthCalendarMonthStateOpts extends WithRefOpts {}
+
+export class RangeMonthCalendarMonthState extends RangeCalendarBaseUnitState<
+ RangeMonthCalendarMonthStateOpts,
+ RangeMonthCalendarCellState
+> {
+ static create(opts: RangeMonthCalendarMonthStateOpts) {
+ return new RangeMonthCalendarMonthState(opts, RangeMonthCalendarCellContext.get());
+ }
+
+ readonly #tabindex = $derived.by(() =>
+ this.cell.isDisabled ? undefined : this.cell.isFocusedUnit ? 0 : -1
+ );
+
+ readonly snippetProps = $derived.by(() => ({
+ disabled: this.cell.isDisabled,
+ unavailable: this.cell.isUnavailable,
+ selected: this.cell.isSelectedDate,
+ month: `${this.cell.opts.date.current.day}`,
+ }));
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "button",
+ "aria-label": this.cell.labelText,
+ "aria-disabled": getAriaDisabled(this.cell.ariaDisabled),
+ ...this.cell.sharedDataAttrs,
+ tabindex: this.#tabindex,
+ [this.cell.root.getBitsAttr("month")]: "",
+ // Shared logic for range calendar and calendar
+ "data-bits-month": "",
+ //
+ onclick: this.onclick,
+ onmouseenter: this.onmouseenter,
+ onfocusin: this.onfocusin,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
diff --git a/packages/bits-ui/src/lib/bits/range-month-calendar/types.ts b/packages/bits-ui/src/lib/bits/range-month-calendar/types.ts
new file mode 100644
index 000000000..de1c59f5b
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/range-month-calendar/types.ts
@@ -0,0 +1,259 @@
+import type { DateValue } from "@internationalized/date";
+import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js";
+import type { DateMatcher, DateRange } from "$lib/shared/index.js";
+import type {
+ BitsPrimitiveDivAttributes,
+ BitsPrimitiveTdAttributes,
+} from "$lib/shared/attributes.js";
+import type { Year } from "$lib/shared/date/types.js";
+import type { CalendarCellSnippetProps } from "$lib/types.js";
+
+export type RangeMonthCalendarRootSnippetProps = {
+ years: Year[];
+};
+
+export type RangeMonthCalendarRootPropsWithoutHTML = WithChild<
+ {
+ /**
+ * The value of the selected date range.
+ * @bindable
+ */
+ value?: DateRange;
+
+ /**
+ * A callback function called when the value changes.
+ */
+ onValueChange?: OnChangeFn;
+
+ /**
+ * The placeholder date, used to control the view of the
+ * calendar when no value is present.
+ *
+ * @default the current date
+ */
+ placeholder?: DateValue;
+
+ /**
+ * A callback function called when the placeholder value
+ * changes.
+ */
+ onPlaceholderChange?: OnChangeFn;
+
+ /**
+ * The minimum number of months that can be selected in a range.
+ *
+ * @default undefined
+ */
+ minMonths?: number;
+
+ /**
+ * The maximum number of months that can be selected in a range.
+ *
+ * @default undefined
+ */
+ maxMonths?: number;
+
+ /**
+ * Whether or not users can deselect a date once selected
+ * without selecting another date.
+ *
+ * @default false
+ */
+ preventDeselect?: boolean;
+
+ /**
+ * The minimum date that can be selected in the calendar.
+ */
+ minValue?: DateValue;
+
+ /**
+ * The maximum date that can be selected in the calendar.
+ */
+ maxValue?: DateValue;
+
+ /**
+ * Whether or not the calendar is disabled.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Applicable only when `numberOfMonths` is greater than 1.
+ *
+ * Controls whether to use paged navigation for the next and previous buttons in the
+ * date picker. With paged navigation set to `true`, clicking the next/prev buttons
+ * changes all months in view. When set to `false`, it shifts the view by a single month.
+ *
+ * For example, with `pagedNavigation` set to `true` and 2 months displayed (January and
+ * February), clicking the next button changes the view to March and April. If `pagedNavigation`
+ * is `false`, the view shifts to February and March.
+ *
+ * @default false
+ */
+ pagedNavigation?: boolean;
+
+ /**
+ * A function that receives a date and returns `true` or `false` to indicate whether
+ * the date is disabled.
+ *
+ * @remarks
+ * Disabled dates cannot be focused or selected. Additionally, they are tagged
+ * with a data attribute to enable custom styling.
+ *
+ * `[data-disabled]` - applied to disabled dates
+ *
+ */
+ isMonthDisabled?: DateMatcher;
+
+ /**
+ * Dates matching the provided matchers are marked as "unavailable." Unlike disabled dates,
+ * users can still focus and select unavailable dates. However, selecting an unavailable date
+ * renders the date picker as invalid.
+ *
+ * For example, in a calendar for booking appointments, you might mark already booked dates as
+ * unavailable. These dates could become available again before the appointment date, allowing
+ * users to select them to learn more about the appointment.
+ *
+ * `[data-unavailable]` - applied to unavailable dates
+ *
+ */
+ isMonthUnavailable?: DateMatcher;
+
+ /**
+ * Determines the number of months to display on the calendar simultaneously.
+ * For navigation between months, refer to the `pagedNavigation` prop.
+ *
+ * @default 1
+ */
+ numberOfYears?: number;
+
+ /**
+ * This label is exclusively used for accessibility, remaining hidden from the page.
+ * It's read by screen readers when the calendar is opened. The current month and year
+ * are automatically appended to the label, so you only need to provide the base label.
+ *
+ * For instance:
+ * - 'Date of birth' will be read as 'Date of birth, January 2021' if the current month is January 2021.
+ * - 'Appointment date' will be read as 'Appointment date, January 2021' if the current month is January 2021.
+ * - 'Booking date' will be read as 'Booking date, January 2021' if the current month is January 2021.
+ */
+ calendarLabel?: string;
+
+ /**
+ * The default locale setting.
+ *
+ * @default 'en'
+ */
+ locale?: string;
+
+ /**
+ * Whether the calendar is readonly. When true, the user will be able
+ * to focus and navigate the calendar, but will not be able to select
+ * dates. @see disabled for a similar prop that prevents focusing
+ * and selecting dates.
+ *
+ * @default false
+ */
+ readonly?: boolean;
+
+ /**
+ * Whether to automatically reset the range if any date within the selected range
+ * becomes disabled. When true, the entire range will be cleared if a disabled
+ * date is found between the start and end dates.
+ *
+ * @default false
+ */
+ excludeDisabled?: boolean;
+
+ /**
+ * A callback function called when the start value changes. This doesn't necessarily mean
+ * the `value` has updated and should be used to apply cosmetic changes to the calendar when
+ * only part of the value is changed/completed.
+ */
+ onStartValueChange?: OnChangeFn;
+
+ /**
+ * A callback function called when the end value changes. This doesn't necessarily mean
+ * the `value` has updated and should be used to apply cosmetic changes to the calendar when
+ * only part of the value is changed/completed.
+ */
+ onEndValueChange?: OnChangeFn;
+
+ /**
+ * The format of the month names in the calendar.
+ *
+ * @default "long"
+ */
+ monthFormat?: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+
+ /**
+ * The format of the year names in the calendar.
+ *
+ * @default "numeric"
+ */
+ yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string);
+ },
+ RangeMonthCalendarRootSnippetProps
+>;
+
+export type RangeMonthCalendarRootProps = RangeMonthCalendarRootPropsWithoutHTML &
+ Without;
+
+export type RangeMonthCalendarCellPropsWithoutHTML = WithChild<
+ {
+ /**
+ * The month value of the cell.
+ *
+ * @required
+ */
+ month: DateValue;
+
+ /**
+ * The year DateValue that this cell is being rendered in.
+ */
+ year: DateValue;
+ },
+ CalendarCellSnippetProps
+>;
+
+export type RangeMonthCalendarCellProps = RangeMonthCalendarCellPropsWithoutHTML &
+ Without;
+
+export type RangeMonthCalendarDaySnippetProps = {
+ disabled: boolean;
+ unavailable: boolean;
+ selected: boolean;
+ month: string;
+};
+
+export type RangeMonthCalendarDayPropsWithoutHTML = WithChild<
+ {},
+ RangeMonthCalendarDaySnippetProps
+>;
+
+export type RangeMonthCalendarDayProps = RangeMonthCalendarDayPropsWithoutHTML &
+ Without;
+
+export type {
+ CalendarPrevButtonProps as RangeMonthCalendarPrevButtonProps,
+ CalendarPrevButtonPropsWithoutHTML as RangeMonthCalendarPrevButtonPropsWithoutHTML,
+ CalendarNextButtonProps as RangeMonthCalendarNextButtonProps,
+ CalendarNextButtonPropsWithoutHTML as RangeMonthCalendarNextButtonPropsWithoutHTML,
+ CalendarHeadingProps as RangeMonthCalendarHeadingProps,
+ CalendarHeadingPropsWithoutHTML as RangeMonthCalendarHeadingPropsWithoutHTML,
+ CalendarGridProps as RangeMonthCalendarGridProps,
+ CalendarGridPropsWithoutHTML as RangeMonthCalendarGridPropsWithoutHTML,
+ // CalendarCellProps as RangeMonthCalendarCellProps,
+ // CalendarCellPropsWithoutHTML as RangeMonthCalendarCellPropsWithoutHTML,
+ // CalendarDayProps as RangeMonthCalendarDayProps,
+ // CalendarDayPropsWithoutHTML as RangeMonthCalendarDayPropsWithoutHTML,
+ CalendarGridBodyProps as RangeMonthCalendarGridBodyProps,
+ CalendarGridBodyPropsWithoutHTML as RangeMonthCalendarGridBodyPropsWithoutHTML,
+ CalendarGridRowProps as RangeMonthCalendarGridRowProps,
+ CalendarGridRowPropsWithoutHTML as RangeMonthCalendarGridRowPropsWithoutHTML,
+ CalendarHeaderProps as RangeMonthCalendarHeaderProps,
+ CalendarHeaderPropsWithoutHTML as RangeMonthCalendarHeaderPropsWithoutHTML,
+ CalendarYearSelectProps as RangeMonthCalendarYearSelectProps,
+ CalendarYearSelectPropsWithoutHTML as RangeMonthCalendarYearSelectPropsWithoutHTML,
+} from "../calendar/types.js";
diff --git a/packages/bits-ui/src/lib/index.ts b/packages/bits-ui/src/lib/index.ts
index 81b3ee23c..d2edf7b72 100644
--- a/packages/bits-ui/src/lib/index.ts
+++ b/packages/bits-ui/src/lib/index.ts
@@ -21,6 +21,7 @@ export {
LinkPreview,
Menubar,
Meter,
+ MonthCalendar,
NavigationMenu,
Pagination,
PinInput,
@@ -28,6 +29,7 @@ export {
Progress,
RadioGroup,
RangeCalendar,
+ RangeMonthCalendar,
RatingGroup as unstable_RatingGroup,
ScrollArea,
Select,
diff --git a/packages/bits-ui/src/lib/internal/date-time/calendar-base.svelte.ts b/packages/bits-ui/src/lib/internal/date-time/calendar-base.svelte.ts
new file mode 100644
index 000000000..9b029f6da
--- /dev/null
+++ b/packages/bits-ui/src/lib/internal/date-time/calendar-base.svelte.ts
@@ -0,0 +1,675 @@
+import { type DateValue } from "@internationalized/date";
+import { onMount, untrack } from "svelte";
+import {
+ attachRef,
+ DOMContext,
+ type ReadableBoxedValues,
+ type WritableBoxedValues,
+} from "svelte-toolbelt";
+import { watch } from "runed";
+import {
+ getAriaDisabled,
+ getAriaHidden,
+ getAriaReadonly,
+ getDataDisabled,
+ getDataReadonly,
+} from "$lib/internal/attrs.js";
+import type { BitsKeyboardEvent, BitsMouseEvent, WithRefOpts } from "$lib/internal/types.js";
+import { useId } from "$lib/internal/use-id.js";
+import type { DateMatcher } from "$lib/shared/index.js";
+import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js";
+import { type Formatter } from "$lib/internal/date-time/formatter.js";
+import {
+ calendarAttrs,
+ createAccessibleHeading,
+ getCalendarElementProps,
+ getDateWithPreviousTime,
+ handleCalendarKeydown,
+ SAME_FN_MAP,
+ type CalendarUnit,
+ type SameFn,
+} from "$lib/internal/date-time/calendar-helpers.svelte.js";
+import { isBefore, toDate } from "$lib/internal/date-time/utils.js";
+import type { RangeCalendarBaseRootState } from "./calendar-range-base.svelte.js";
+import { DEV } from "esm-env";
+
+export interface CalendarBaseRootStateOpts
+ extends WithRefOpts,
+ WritableBoxedValues<{
+ value: DateValue | undefined | DateValue[];
+ placeholder: DateValue;
+ }>,
+ ReadableBoxedValues<{
+ preventDeselect: boolean;
+ minValue: DateValue | undefined;
+ maxValue: DateValue | undefined;
+ disabled: boolean;
+ pagedNavigation: boolean;
+ isUnitDisabled: DateMatcher;
+ isUnitUnavailable: DateMatcher;
+ locale: string;
+ calendarLabel: string;
+ type: "single" | "multiple";
+ readonly: boolean;
+ initialFocus: boolean;
+ maxUnits: number | undefined;
+ /**
+ * This is strictly used by the `DatePicker` component to close the popover when a date
+ * is selected. It is not intended to be used by the user.
+ */
+ onDateSelect?: () => void;
+ }> {
+ defaultPlaceholder: DateValue;
+}
+
+export abstract class CalendarBaseRootState<
+ T extends CalendarBaseRootStateOpts = CalendarBaseRootStateOpts,
+> {
+ readonly opts: T;
+ abstract readonly formatter: Formatter;
+ readonly accessibleHeadingId = useId();
+ readonly domContext: DOMContext;
+ readonly isSame: SameFn;
+ announcer: Announcer;
+
+ constructor(opts: T, unit: CalendarUnit) {
+ this.opts = opts;
+ this.domContext = new DOMContext(opts.ref);
+ this.announcer = getAnnouncer(null);
+
+ this.isSame = SAME_FN_MAP[unit];
+
+ this.nextPage = this.nextPage.bind(this);
+ this.prevPage = this.prevPage.bind(this);
+ this.isUnitDisabled = this.isUnitDisabled.bind(this);
+ this.isUnitSelected = this.isUnitSelected.bind(this);
+ this.shiftFocus = this.shiftFocus.bind(this);
+ this.handleCellClick = this.handleCellClick.bind(this);
+ this.handleMultipleUpdate = this.handleMultipleUpdate.bind(this);
+ this.handleSingleUpdate = this.handleSingleUpdate.bind(this);
+ this.onkeydown = this.onkeydown.bind(this);
+ this.getBitsAttr = this.getBitsAttr.bind(this);
+
+ onMount(() => {
+ this.announcer = getAnnouncer(this.domContext.getDocument());
+ });
+
+ this.#setupInitialFocusEffect();
+ this.#setupAccessibleHeadingEffect();
+ this.#setupFormatterEffect();
+
+ /**
+ * Update the accessible heading's text content when the `fullCalendarLabel`
+ * changes.
+ */
+ watch(
+ () => this.fullCalendarLabel,
+ (label) => {
+ const node = this.domContext.getElementById(this.accessibleHeadingId);
+ if (!node) return;
+ node.textContent = label;
+ }
+ );
+
+ /**
+ * Synchronize the placeholder value with the current value.
+ */
+ watch(
+ () => this.opts.value.current,
+ () => {
+ const value = this.opts.value.current;
+ if (Array.isArray(value) && value.length) {
+ const lastValue = value[value.length - 1];
+ if (lastValue && this.opts.placeholder.current !== lastValue) {
+ this.opts.placeholder.current = lastValue;
+ }
+ } else if (
+ !Array.isArray(value) &&
+ value &&
+ this.opts.placeholder.current !== value
+ ) {
+ this.opts.placeholder.current = value;
+ }
+ }
+ );
+ }
+
+ #setupInitialFocusEffect() {
+ $effect(() => {
+ const initialFocus = untrack(() => this.opts.initialFocus.current);
+ if (initialFocus) {
+ // focus the first `data-focused` day node
+ const firstFocusedDay =
+ this.opts.ref.current?.querySelector(`[data-focused]`);
+ if (firstFocusedDay) {
+ firstFocusedDay.focus();
+ }
+ }
+ });
+ }
+
+ #setupAccessibleHeadingEffect() {
+ $effect(() => {
+ if (!this.opts.ref.current) return;
+ const removeHeading = createAccessibleHeading({
+ calendarNode: this.opts.ref.current,
+ label: this.fullCalendarLabel,
+ accessibleHeadingId: this.accessibleHeadingId,
+ });
+ return removeHeading;
+ });
+ }
+
+ #setupFormatterEffect() {
+ $effect(() => {
+ if (this.formatter.getLocale() === this.opts.locale.current) return;
+ this.formatter.setLocale(this.opts.locale.current);
+ });
+ }
+
+ /**
+ * Navigates to the next page of the calendar.
+ */
+ abstract nextPage(): void;
+
+ /**
+ * Navigates to the previous page of the calendar.
+ */
+ abstract prevPage(): void;
+
+ abstract isNextButtonDisabled: boolean;
+ abstract isPrevButtonDisabled: boolean;
+
+ isInvalid = $derived.by(() => {
+ const value = this.opts.value.current;
+ const isUnitDisabled = this.opts.isUnitDisabled.current;
+ const isUnitUnavailable = this.opts.isUnitUnavailable.current;
+ if (Array.isArray(value)) {
+ if (!value.length) return false;
+ for (const date of value) {
+ if (isUnitDisabled(date)) return true;
+ if (isUnitUnavailable(date)) return true;
+ }
+ } else {
+ if (!value) return false;
+ if (isUnitDisabled(value)) return true;
+ if (isUnitUnavailable(value)) return true;
+ }
+ return false;
+ });
+
+ abstract readonly headingValue: string;
+
+ readonly fullCalendarLabel = $derived.by(() => {
+ return `${this.opts.calendarLabel.current} ${this.headingValue}`;
+ });
+
+ isUnitDisabled(date: DateValue) {
+ if (this.opts.isUnitDisabled.current(date) || this.opts.disabled.current) return true;
+ const minValue = this.opts.minValue.current;
+ const maxValue = this.opts.maxValue.current;
+ if (minValue && isBefore(date, minValue)) return true;
+ if (maxValue && isBefore(maxValue, date)) return true;
+ return false;
+ }
+
+ isUnitSelected(date: DateValue) {
+ const value = this.opts.value.current;
+ if (Array.isArray(value)) {
+ return value.some((d) => this.isSame(d, date));
+ } else if (!value) {
+ return false;
+ }
+ return this.isSame(value, date);
+ }
+
+ abstract shiftFocus(node: HTMLElement, add: number): void;
+
+ handleCellClick(_: Event, date: DateValue) {
+ if (
+ this.opts.readonly.current ||
+ this.opts.isUnitDisabled.current?.(date) ||
+ this.opts.isUnitUnavailable.current?.(date)
+ ) {
+ return;
+ }
+
+ const prev = this.opts.value.current;
+ const multiple = this.opts.type.current === "multiple";
+ if (multiple) {
+ if (Array.isArray(prev) || prev === undefined) {
+ this.opts.value.current = this.handleMultipleUpdate(prev, date);
+ }
+ } else if (!Array.isArray(prev)) {
+ const next = this.handleSingleUpdate(prev, date);
+ if (!next) {
+ this.announcer.announce("Selected date is now empty.", "polite", 5000);
+ } else {
+ this.announcer.announce(
+ `Selected Date: ${this.formatter.selectedDate(next, false)}`,
+ "polite"
+ );
+ }
+ this.opts.value.current = getDateWithPreviousTime(next, prev);
+ if (next !== undefined) {
+ this.opts.onDateSelect?.current?.();
+ }
+ }
+ }
+
+ #isMultipleSelectionValid(selectedDates: DateValue[]): boolean {
+ // only validate for multiple type and when maxDays is set
+ if (this.opts.type.current !== "multiple") return true;
+ if (!this.opts.maxUnits.current) return true;
+ const selectedCount = selectedDates.length;
+ if (this.opts.maxUnits.current && selectedCount > this.opts.maxUnits.current) return false;
+ return true;
+ }
+
+ handleMultipleUpdate(prev: DateValue[] | undefined, date: DateValue) {
+ if (!prev) {
+ const newSelection = [date];
+ return this.#isMultipleSelectionValid(newSelection) ? newSelection : [date];
+ }
+ if (!Array.isArray(prev)) {
+ if (DEV) throw new Error("Invalid value for multiple prop.");
+ return;
+ }
+ const index = prev.findIndex((d) => this.isSame(d, date));
+ const preventDeselect = this.opts.preventDeselect.current;
+ if (index === -1) {
+ // adding a new date - check if it would be valid
+ const newSelection = [...prev, date];
+ if (this.#isMultipleSelectionValid(newSelection)) {
+ return newSelection;
+ } else {
+ // reset to just the newly selected date when constraints are violated
+ return [date];
+ }
+ } else if (preventDeselect) {
+ return prev;
+ } else {
+ const next = prev.filter((d) => !this.isSame(d, date));
+ if (!next.length) {
+ this.opts.placeholder.current = date;
+ return undefined;
+ }
+ return next;
+ }
+ }
+
+ handleSingleUpdate(prev: DateValue | undefined, date: DateValue) {
+ if (Array.isArray(prev)) {
+ if (DEV) throw new Error("Invalid value for single prop.");
+ }
+ if (!prev) return date;
+ const preventDeselect = this.opts.preventDeselect.current;
+ if (!preventDeselect && this.isSame(prev, date)) {
+ this.opts.placeholder.current = date;
+ return undefined;
+ }
+ return date;
+ }
+
+ onkeydown(event: BitsKeyboardEvent) {
+ handleCalendarKeydown({
+ event,
+ handleCellClick: this.handleCellClick,
+ shiftFocus: this.shiftFocus,
+ placeholderValue: this.opts.placeholder.current,
+ });
+ }
+
+ getBitsAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
+ return calendarAttrs.getAttr(part);
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ ...getCalendarElementProps({
+ fullCalendarLabel: this.fullCalendarLabel,
+ id: this.opts.id.current,
+ isInvalid: this.isInvalid,
+ disabled: this.opts.disabled.current,
+ readonly: this.opts.readonly.current,
+ }),
+ [this.getBitsAttr("root")]: "",
+ //
+ onkeydown: this.onkeydown,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseHeadingStateOpts extends WithRefOpts {}
+
+export abstract class CalendarBaseHeadingState<
+ T extends CalendarBaseHeadingStateOpts = CalendarBaseHeadingStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "aria-hidden": getAriaHidden(true),
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("heading")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseCellStateOpts
+ extends WithRefOpts,
+ ReadableBoxedValues<{
+ date: DateValue;
+ }> {}
+
+export abstract class CalendarBaseCellState<
+ T extends CalendarBaseCellStateOpts = CalendarBaseCellStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U;
+ readonly cellDate = $derived.by(() => toDate(this.opts.date.current));
+ readonly isDisabled = $derived.by(() => this.root.isUnitDisabled(this.opts.date.current));
+ readonly isUnavailable = $derived.by(() =>
+ this.root.opts.isUnitUnavailable.current(this.opts.date.current)
+ );
+ readonly isSelectedUnit = $derived.by(() => this.root.isUnitSelected(this.opts.date.current));
+ readonly isFocusedUnit = $derived.by(() =>
+ this.root.isSame(this.opts.date.current, this.root.opts.placeholder.current)
+ );
+ abstract readonly labelText: string;
+
+ constructor(opts: T, root: U) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly snippetProps = $derived.by(() => ({
+ disabled: this.isDisabled,
+ unavailable: this.isUnavailable,
+ selected: this.isSelectedUnit,
+ }));
+
+ abstract readonly ariaDisabled: boolean;
+}
+
+export interface CalendarBaseUnitStateOpts extends WithRefOpts {}
+
+export class CalendarBaseUnitState<
+ T extends CalendarBaseUnitStateOpts = CalendarBaseUnitStateOpts,
+ U extends CalendarBaseCellState = CalendarBaseCellState,
+> {
+ readonly opts: T;
+ readonly cell: U;
+
+ constructor(opts: T, cell: U) {
+ this.opts = opts;
+ this.cell = cell;
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick(e: BitsMouseEvent) {
+ if (this.cell.isDisabled) return;
+ this.cell.root.handleCellClick(e, this.cell.opts.date.current);
+ }
+}
+
+export interface CalendarBaseNextButtonStateOpts extends WithRefOpts {}
+
+export class CalendarBaseNextButtonState<
+ T extends CalendarBaseNextButtonStateOpts = CalendarBaseNextButtonStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+ readonly isDisabled = $derived.by(() => this.root.isNextButtonDisabled);
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick(_: BitsMouseEvent) {
+ if (this.isDisabled) return;
+ this.root.nextPage();
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "button",
+ type: "button",
+ "aria-label": "Next",
+ "aria-disabled": getAriaDisabled(this.isDisabled),
+ "data-disabled": getDataDisabled(this.isDisabled),
+ disabled: this.isDisabled,
+ [this.root.getBitsAttr("next-button")]: "",
+ //
+ onclick: this.onclick,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBasePrevButtonStateOpts extends WithRefOpts {}
+
+export class CalendarBasePrevButtonState<
+ T extends CalendarBasePrevButtonStateOpts = CalendarBasePrevButtonStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+ readonly isDisabled = $derived.by(() => this.root.isPrevButtonDisabled);
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick(_: BitsMouseEvent) {
+ if (this.isDisabled) return;
+ this.root.prevPage();
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ role: "button",
+ type: "button",
+ "aria-label": "Previous",
+ "aria-disabled": getAriaDisabled(this.isDisabled),
+ "data-disabled": getDataDisabled(this.isDisabled),
+ disabled: this.isDisabled,
+ [this.root.getBitsAttr("prev-button")]: "",
+ //
+ onclick: this.onclick,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseGridStateOpts extends WithRefOpts {}
+
+export class CalendarBaseGridState<
+ T extends CalendarBaseGridStateOpts = CalendarBaseGridStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ tabindex: -1,
+ role: "grid",
+ "aria-readonly": getAriaReadonly(this.root.opts.readonly.current),
+ "aria-disabled": getAriaDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ [this.root.getBitsAttr("grid")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseGridBodyStateOpts extends WithRefOpts {}
+
+export class CalendarBaseGridBodyState<
+ T extends CalendarBaseGridBodyStateOpts = CalendarBaseGridBodyStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("grid-body")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseGridHeadStateOpts extends WithRefOpts {}
+
+export class CalendarBaseGridHeadState<
+ T extends CalendarBaseGridHeadStateOpts = CalendarBaseGridHeadStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("grid-head")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseGridRowStateOpts extends WithRefOpts {}
+
+export class CalendarBaseGridRowState<
+ T extends CalendarBaseGridRowStateOpts = CalendarBaseGridRowStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("grid-row")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseHeadCellStateOpts extends WithRefOpts {}
+
+export class CalendarBaseHeadCellState<
+ T extends CalendarBaseHeadCellStateOpts = CalendarBaseHeadCellStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("head-cell")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
+
+export interface CalendarBaseHeaderStateOpts extends WithRefOpts {}
+
+export class CalendarBaseHeaderState<
+ T extends CalendarBaseHeaderStateOpts = CalendarBaseHeaderStateOpts,
+ U extends CalendarBaseRootState = CalendarBaseRootState,
+ K extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U | K;
+
+ constructor(opts: T, root: U | K) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ id: this.opts.id.current,
+ "data-disabled": getDataDisabled(this.root.opts.disabled.current),
+ "data-readonly": getDataReadonly(this.root.opts.readonly.current),
+ [this.root.getBitsAttr("header")]: "",
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+}
diff --git a/packages/bits-ui/src/lib/internal/date-time/calendar-helpers.svelte.ts b/packages/bits-ui/src/lib/internal/date-time/calendar-helpers.svelte.ts
index e65844838..386ae9ba0 100644
--- a/packages/bits-ui/src/lib/internal/date-time/calendar-helpers.svelte.ts
+++ b/packages/bits-ui/src/lib/internal/date-time/calendar-helpers.svelte.ts
@@ -1,8 +1,10 @@
import {
+ DateFormatter,
type DateValue,
endOfMonth,
isSameDay,
isSameMonth,
+ isSameYear,
startOfMonth,
} from "@internationalized/date";
import {
@@ -36,6 +38,22 @@ import { isBrowser, isHTMLElement } from "$lib/internal/is.js";
import { kbd } from "$lib/internal/kbd.js";
import type { DateMatcher, Month } from "$lib/shared/index.js";
import { watch } from "runed";
+import type { CalendarView, Year } from "$lib/shared/date/types.js";
+
+export type SameFn = (a: DateValue, b: DateValue) => boolean;
+export type CalendarUnit = "day" | "month" | "year";
+
+export const SAME_FN_MAP: Record = {
+ day: isSameDay,
+ month: isSameMonth,
+ year: isSameYear,
+};
+
+export const CELL_UNIT_MAP: Record = {
+ day: "days",
+ month: "months",
+ year: "years",
+};
/**
* Checks if a given node is a calendar cell element.
@@ -155,50 +173,113 @@ function createMonth(props: CreateMonthProps): Month {
};
}
+export type CreateYearProps = {
+ /**
+ * The date object representing the year's date (usually the first day of the year).
+ */
+ dateObj: DateValue;
+
+ /**
+ * The format to use for displaying month names.
+ */
+ monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+
+ /**
+ * The locale to use when creating the calendar month.
+ */
+ locale: string;
+};
+
+/**
+ * Creates a calendar year object for the month calendar view.
+ *
+ * @remarks
+ * Given a date, this function returns an object containing
+ * the necessary values to render a year in the month calendar,
+ * including the year's date (the first day of that year), an array
+ * of all months in that year (with their formatted names), and an
+ * array of month groups (e.g., for rendering in a grid).
+ *
+ * Each month entry contains the date and its formatted name.
+ */
+function createYear(props: CreateYearProps): Year {
+ const { monthFormat, locale, dateObj } = props;
+ const monthsInYear = 12;
+
+ const datesArray = Array.from({ length: monthsInYear }, (_, i) => {
+ const date = dateObj.set({ month: i + 1 });
+
+ const formattedMonth =
+ typeof monthFormat === "function"
+ ? monthFormat(toDate(date).getMonth() + 1)
+ : new DateFormatter(locale, { month: monthFormat }).format(toDate(date));
+
+ return {
+ value: date,
+ label: formattedMonth,
+ };
+ });
+
+ const months = chunk(datesArray, 4);
+
+ return {
+ value: dateObj,
+ dates: datesArray,
+ months,
+ };
+}
+
type SetMonthProps = CreateMonthProps & {
numberOfMonths: number | undefined;
currentMonths?: Month[];
};
export function createMonths(props: SetMonthProps) {
- const { numberOfMonths, dateObj, ...monthProps } = props;
+ const { numberOfMonths = 1, dateObj, ...monthProps } = props;
const months: Month[] = [];
- if (!numberOfMonths || numberOfMonths === 1) {
+ for (let i = 0; i < numberOfMonths; i++) {
+ const current = dateObj.add({ months: i });
months.push(
createMonth({
...monthProps,
- dateObj,
+ dateObj: current,
})
);
- return months;
}
- months.push(
- createMonth({
- ...monthProps,
- dateObj,
- })
- );
+ return months;
+}
- // Create all the months, starting with the current month
- for (let i = 1; i < numberOfMonths; i++) {
- const nextMonth = dateObj.add({ months: i });
- months.push(
- createMonth({
- ...monthProps,
- dateObj: nextMonth,
+type SetYearProps = CreateYearProps & {
+ numberOfYears: number | undefined;
+ currentYears?: Year[];
+};
+
+export function createYears(props: SetYearProps) {
+ let { numberOfYears = 1, dateObj, ...yearProps } = props;
+ dateObj = dateObj.set({ day: 1 });
+
+ const years: Year[] = [];
+
+ for (let i = 0; i < numberOfYears; i++) {
+ const current = dateObj.add({ years: i });
+ years.push(
+ createYear({
+ ...yearProps,
+ dateObj: current,
})
);
}
- return months;
+ return years;
}
export function getSelectableCells(calendarNode: HTMLElement | null) {
if (!calendarNode) return [];
- const selectableSelector = `[data-bits-day]:not([data-disabled]):not([data-outside-visible-months])`;
+ const selectableSelector = `[data-bits-day]:not([data-disabled]):not([data-outside-visible-months]),
+ [data-bits-month]:not([data-disabled])`;
return Array.from(calendarNode.querySelectorAll(selectableSelector)).filter(
(el): el is HTMLElement => isHTMLElement(el)
@@ -227,7 +308,7 @@ type ShiftCalendarFocusProps = {
node: HTMLElement;
/**
- * The number of days to shift the focus by.
+ * The number of units to shift the focus by.
*/
add: number;
@@ -252,14 +333,19 @@ type ShiftCalendarFocusProps = {
isNextButtonDisabled: boolean;
/**
- * The months array of the calendar.
+ * The items array of the calendar.
*/
- months: Month[];
+ items: CalendarView[];
/**
- * The number of months being displayed in the calendar.
+ * The number of units being displayed in the calendar.
*/
- numberOfMonths: number;
+ numberOfUnits: number;
+
+ /**
+ * The unit type.
+ * */
+ unit: "years" | "months";
};
/**
@@ -273,8 +359,9 @@ export function shiftCalendarFocus({
calendarNode,
isPrevButtonDisabled,
isNextButtonDisabled,
- months,
- numberOfMonths,
+ items,
+ numberOfUnits,
+ unit,
}: ShiftCalendarFocusProps) {
const candidateCells = getSelectableCells(calendarNode);
if (!candidateCells.length) return;
@@ -309,9 +396,9 @@ export function shiftCalendarFocus({
// shift the calendar back a month unless prev month is disabled
if (isPrevButtonDisabled) return;
- const firstMonth = months[0]?.value;
- if (!firstMonth) return;
- placeholder.current = firstMonth.subtract({ months: numberOfMonths });
+ const firstValue = items[0]?.value;
+ if (!firstValue) return;
+ placeholder.current = firstValue.subtract({ [unit]: numberOfUnits });
// Without a tick here, it seems to be too quick for the DOM to update
@@ -344,9 +431,9 @@ export function shiftCalendarFocus({
// shift the calendar forward a month unless next month is disabled
if (isNextButtonDisabled) return;
- const firstMonth = months[0]?.value;
- if (!firstMonth) return;
- placeholder.current = firstMonth.add({ months: numberOfMonths });
+ const firstValue = items[0]?.value;
+ if (!firstValue) return;
+ placeholder.current = firstValue.add({ [unit]: numberOfUnits });
afterTick(() => {
const newCandidateCells = getSelectableCells(calendarNode);
@@ -455,6 +542,44 @@ export function handleCalendarNextPage({
}
}
+type HandleMonthCalendarPageProps = {
+ years: Year[];
+ setYears: (years: Year[]) => void;
+ numberOfYears: number;
+ pagedNavigation: boolean;
+ locale: string;
+ monthFormat: Intl.DateTimeFormatOptions["month"] | ((month: number) => string);
+ setPlaceholder: (date: DateValue) => void;
+};
+
+export function handleMonthCalendarNextPage({
+ years,
+ setYears,
+ numberOfYears,
+ pagedNavigation,
+ locale,
+ monthFormat,
+ setPlaceholder,
+}: HandleMonthCalendarPageProps) {
+ const firstYear = years[0]?.value;
+ if (!firstYear) return;
+ if (pagedNavigation) {
+ setPlaceholder(firstYear.add({ years: numberOfYears }));
+ } else {
+ const newYears = createYears({
+ dateObj: firstYear.add({ years: 1 }),
+ locale,
+ monthFormat,
+ numberOfYears,
+ });
+ setYears(newYears);
+
+ const firstNewMonth = newYears[0];
+ if (!firstNewMonth) return;
+ setPlaceholder(firstNewMonth.value.set({ day: 1, month: 0 }));
+ }
+}
+
export function handleCalendarPrevPage({
months,
setMonths,
@@ -485,6 +610,34 @@ export function handleCalendarPrevPage({
}
}
+export function handleMonthCalendarPrevPage({
+ years,
+ setYears,
+ numberOfYears,
+ pagedNavigation,
+ locale,
+ monthFormat,
+ setPlaceholder,
+}: HandleMonthCalendarPageProps) {
+ const firstYear = years[0]?.value;
+ if (!firstYear) return;
+ if (pagedNavigation) {
+ setPlaceholder(firstYear.subtract({ years: numberOfYears }));
+ } else {
+ const newYears = createYears({
+ dateObj: firstYear.subtract({ years: 1 }),
+ locale,
+ monthFormat,
+ numberOfYears,
+ });
+
+ setYears(newYears);
+ const firstNewYear = newYears[0];
+ if (!firstNewYear) return;
+ setPlaceholder(firstNewYear.value.set({ day: 1, month: 0 }));
+ }
+}
+
type GetWeekdaysProps = {
months: Month[];
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
@@ -534,6 +687,38 @@ export function useMonthViewOptionsSync(props: UseMonthViewSyncProps) {
});
}
+type UseYearViewSyncProps = {
+ locale: ReadableBox;
+ numberOfYears: ReadableBox;
+ placeholder: WritableBox;
+ monthFormat: ReadableBox string)>;
+ setYears: (months: Year[]) => void;
+};
+
+/**
+ * Updates the displayed months based on changes in the options values,
+ * which determines the year to show in the calendar.
+ */
+export function useYearViewOptionsSync(props: UseYearViewSyncProps) {
+ $effect(() => {
+ const locale = props.locale.current;
+ const numberOfYears = props.numberOfYears.current;
+ const monthFormat = props.monthFormat.current;
+
+ untrack(() => {
+ const placeholder = props.placeholder.current;
+ if (!placeholder) return;
+ const defaultMonthProps = {
+ locale,
+ numberOfYears,
+ monthFormat,
+ };
+
+ props.setYears(createYears({ ...defaultMonthProps, dateObj: placeholder }));
+ });
+ });
+}
+
type CreateAccessibleHeadingProps = {
calendarNode: HTMLElement;
label: string;
@@ -621,6 +806,45 @@ export function useMonthViewPlaceholderSync({
});
}
+type UseYearViewPlaceholderSyncProps = {
+ placeholder: WritableBox;
+ getVisibleYears: () => DateValue[];
+ locale: ReadableBox;
+ monthFormat: ReadableBox string)>;
+ numberOfYears: ReadableBox;
+ setYears: (months: Year[]) => void;
+};
+
+export function useYearViewPlaceholderSync({
+ placeholder,
+ locale,
+ monthFormat,
+ getVisibleYears,
+ numberOfYears,
+ setYears,
+}: UseYearViewPlaceholderSyncProps) {
+ $effect(() => {
+ placeholder.current;
+ untrack(() => {
+ /**
+ * If the placeholder's month is already in this visible months,
+ * we don't need to do anything.
+ */
+ if (getVisibleYears().some((month) => isSameYear(month, placeholder.current))) {
+ return;
+ }
+
+ const defaultMonthProps = {
+ monthFormat: monthFormat.current,
+ locale: locale.current,
+ numberOfYears: numberOfYears.current,
+ };
+
+ setYears(createYears({ ...defaultMonthProps, dateObj: placeholder.current }));
+ });
+ });
+}
+
type GetIsNextButtonDisabledProps = {
maxValue: DateValue | undefined;
months: Month[];
@@ -644,6 +868,29 @@ export function getIsNextButtonDisabled({
return isAfter(firstMonthOfNextPage, maxValue);
}
+type GetIsNextMonthCalendarButtonDisabledProps = {
+ maxValue: DateValue | undefined;
+ years: Year[];
+ disabled: boolean;
+};
+
+export function getIsNextMonthCalendarButtonDisabled({
+ maxValue,
+ years,
+ disabled,
+}: GetIsNextMonthCalendarButtonDisabledProps) {
+ if (!maxValue || !years.length) return false;
+ if (disabled) return true;
+ const lastYearInView = years[years.length - 1]?.value;
+ if (!lastYearInView) return false;
+ const firstYearOfNextPage = lastYearInView
+ .add({
+ years: 1,
+ })
+ .set({ day: 1, month: 0 });
+ return isAfter(firstYearOfNextPage, maxValue);
+}
+
type GetIsPrevButtonDisabledProps = {
minValue: DateValue | undefined;
months: Month[];
@@ -667,6 +914,29 @@ export function getIsPrevButtonDisabled({
return isBefore(lastMonthOfPrevPage, minValue);
}
+type GetIsPrevMonthCalendarButtonDisabledProps = {
+ minValue: DateValue | undefined;
+ years: Year[];
+ disabled: boolean;
+};
+
+export function getIsPrevMonthCalendarButtonDisabled({
+ minValue,
+ years,
+ disabled,
+}: GetIsPrevMonthCalendarButtonDisabledProps) {
+ if (!minValue || !years.length) return false;
+ if (disabled) return true;
+ const firstYearInView = years[0]?.value;
+ if (!firstYearInView) return false;
+ const lastYearOfPrevPage = firstYearInView
+ .subtract({
+ years: 1,
+ })
+ .set({ day: 35, month: 12 });
+ return isBefore(lastYearOfPrevPage, minValue);
+}
+
type GetCalendarHeadingValueProps = {
months: Month[];
formatter: Formatter;
@@ -704,6 +974,35 @@ export function getCalendarHeadingValue({
return content;
}
+type GetMonthCalendarHeadingValueProps = {
+ years: Year[];
+ formatter: Formatter;
+ locale: string;
+};
+
+export function getMonthCalendarHeadingValue({
+ years,
+ locale,
+ formatter,
+}: GetMonthCalendarHeadingValueProps) {
+ if (!years.length) return "";
+ if (locale !== formatter.getLocale()) {
+ formatter.setLocale(locale);
+ }
+ if (years.length === 1) {
+ const year = toDate(years[0]!.value);
+ return `${formatter.fullYear(year)}`;
+ }
+
+ const startYear = toDate(years[0]!.value);
+ const endYear = toDate(years[years.length - 1]!.value);
+
+ const startYearName = formatter.fullYear(startYear);
+ const endYearName = formatter.fullYear(endYear);
+
+ return startYearName === endYearName ? `${endYearName}` : `${startYearName} - ${endYearName}`;
+}
+
type GetCalendarElementProps = {
fullCalendarLabel: string;
id: string;
@@ -778,17 +1077,21 @@ export function useEnsureNonDisabledPlaceholder({
defaultPlaceholder,
minValue,
maxValue,
- isDateDisabled,
+ isUnitDisabled,
+ unit,
}: {
ref: WritableBox;
placeholder: WritableBox;
- isDateDisabled: ReadableBox;
+ isUnitDisabled: ReadableBox;
minValue: ReadableBox;
maxValue: ReadableBox;
defaultPlaceholder: DateValue;
+ unit: CalendarUnit;
}) {
+ const isSameUnit = SAME_FN_MAP[unit];
+
function isDisabled(date: DateValue) {
- if (isDateDisabled.current(date)) return true;
+ if (isUnitDisabled.current(date)) return true;
if (minValue.current && isBefore(date, minValue.current)) return true;
if (maxValue.current && isBefore(maxValue.current, date)) return true;
return false;
@@ -812,7 +1115,7 @@ export function useEnsureNonDisabledPlaceholder({
*/
if (
placeholder.current &&
- isSameDay(placeholder.current, defaultPlaceholder) &&
+ isSameUnit(placeholder.current, defaultPlaceholder) &&
isDisabled(defaultPlaceholder)
) {
placeholder.current =
@@ -846,6 +1149,7 @@ export const calendarAttrs = createBitsAttrs({
"next-button",
"prev-button",
"day",
+ "month",
"grid-body",
"grid-head",
"grid-row",
diff --git a/packages/bits-ui/src/lib/internal/date-time/calendar-range-base.svelte.ts b/packages/bits-ui/src/lib/internal/date-time/calendar-range-base.svelte.ts
new file mode 100644
index 000000000..8aaff794e
--- /dev/null
+++ b/packages/bits-ui/src/lib/internal/date-time/calendar-range-base.svelte.ts
@@ -0,0 +1,596 @@
+import { type DateValue } from "@internationalized/date";
+import {
+ attachRef,
+ DOMContext,
+ type ReadableBoxedValues,
+ type WritableBoxedValues,
+} from "svelte-toolbelt";
+import { watch } from "runed";
+import type { DateRange } from "$lib/shared/index.js";
+import type {
+ BitsFocusEvent,
+ BitsKeyboardEvent,
+ BitsMouseEvent,
+ WithRefOpts,
+} from "$lib/internal/types.js";
+import { useId } from "$lib/internal/use-id.js";
+import { type Announcer, getAnnouncer } from "$lib/internal/date-time/announcer.js";
+import { type Formatter } from "$lib/internal/date-time/formatter.js";
+import {
+ calendarAttrs,
+ CELL_UNIT_MAP,
+ getCalendarElementProps,
+ handleCalendarKeydown,
+ SAME_FN_MAP,
+ type CalendarUnit,
+ type SameFn,
+} from "$lib/internal/date-time/calendar-helpers.svelte.js";
+import {
+ areAllDaysBetweenValid,
+ isAfter,
+ isBefore,
+ isBetweenInclusive,
+ toDate,
+} from "$lib/internal/date-time/utils.js";
+import { onMount } from "svelte";
+
+export interface RangeCalendarBaseRootStateOpts
+ extends WithRefOpts,
+ WritableBoxedValues<{
+ value: DateRange;
+ placeholder: DateValue;
+ startValue: DateValue | undefined;
+ endValue: DateValue | undefined;
+ }>,
+ ReadableBoxedValues<{
+ preventDeselect: boolean;
+ minValue: DateValue | undefined;
+ maxValue: DateValue | undefined;
+ disabled: boolean;
+ pagedNavigation: boolean;
+ isUnitDisabled: (date: DateValue) => boolean;
+ isUnitUnavailable: (date: DateValue) => boolean;
+ locale: string;
+ calendarLabel: string;
+ readonly: boolean;
+ excludeDisabled: boolean;
+ minUnits: number | undefined;
+ maxUnits: number | undefined;
+ /**
+ * This is strictly used by the `DateRangePicker` component to close the popover when a date range
+ * is selected. It is not intended to be used by the user.
+ */
+ onRangeSelect?: () => void;
+ }> {
+ defaultPlaceholder: DateValue;
+}
+
+export abstract class RangeCalendarBaseRootState<
+ T extends RangeCalendarBaseRootStateOpts = RangeCalendarBaseRootStateOpts,
+> {
+ readonly opts: T;
+ announcer: Announcer;
+ abstract readonly formatter: Formatter;
+ readonly accessibleHeadingId = useId();
+ focusedValue = $state(undefined);
+ lastPressedUnitValue: DateValue | undefined = undefined;
+ readonly domContext: DOMContext;
+
+ readonly isSame: SameFn;
+ readonly cellUnit: "months" | "days" | "years";
+
+ readonly isStartInvalid = $derived.by(() => {
+ if (!this.opts.startValue.current) return false;
+ return (
+ this.isUnitUnavailable(this.opts.startValue.current) ||
+ this.isUnitDisabled(this.opts.startValue.current)
+ );
+ });
+
+ readonly isEndInvalid = $derived.by(() => {
+ if (!this.opts.endValue.current) return false;
+ return (
+ this.isUnitUnavailable(this.opts.endValue.current) ||
+ this.isUnitDisabled(this.opts.endValue.current)
+ );
+ });
+
+ readonly isInvalid = $derived.by(() => {
+ if (this.isStartInvalid || this.isEndInvalid) return true;
+
+ if (
+ this.opts.endValue.current &&
+ this.opts.startValue.current &&
+ isBefore(this.opts.endValue.current, this.opts.startValue.current)
+ )
+ return true;
+
+ return false;
+ });
+
+ abstract isNextButtonDisabled: boolean;
+ abstract isPrevButtonDisabled: boolean;
+
+ abstract readonly headingValue: string;
+
+ readonly fullCalendarLabel = $derived.by(
+ () => `${this.opts.calendarLabel.current} ${this.headingValue}`
+ );
+
+ readonly highlightedRange = $derived.by(() => {
+ if (this.opts.startValue.current && this.opts.endValue.current) return null;
+ if (!this.opts.startValue.current || !this.focusedValue) return null;
+
+ const isStartBeforeFocused = isBefore(this.opts.startValue.current, this.focusedValue);
+ const start = isStartBeforeFocused ? this.opts.startValue.current : this.focusedValue;
+ const end = isStartBeforeFocused ? this.focusedValue : this.opts.startValue.current;
+ const range = { start, end };
+
+ if (this.isSame(start.add({ [this.cellUnit]: 1 }), end) || this.isSame(start, end)) {
+ return range;
+ }
+
+ const isValid = areAllDaysBetweenValid(
+ start,
+ end,
+ this.isUnitUnavailable,
+ this.isUnitDisabled
+ );
+
+ if (isValid) return range;
+ return null;
+ });
+
+ constructor(opts: T, unit: CalendarUnit) {
+ this.opts = opts;
+ this.domContext = new DOMContext(opts.ref);
+ this.announcer = getAnnouncer(null);
+
+ this.isSame = SAME_FN_MAP[unit];
+ this.cellUnit = CELL_UNIT_MAP[unit];
+
+ $effect(() => {
+ if (this.formatter.getLocale() === this.opts.locale.current) return;
+ this.formatter.setLocale(this.opts.locale.current);
+ });
+
+ onMount(() => {
+ this.announcer = getAnnouncer(this.domContext.getDocument());
+ });
+
+ /**
+ * Update the accessible heading's text content when the `fullCalendarLabel`
+ * changes.
+ */
+ watch(
+ () => this.fullCalendarLabel,
+ (label) => {
+ const node = this.domContext.getElementById(this.accessibleHeadingId);
+ if (!node) return;
+ node.textContent = label;
+ }
+ );
+
+ /**
+ * Synchronize the start and end values with the `value` in case
+ * it is updated externally.
+ */
+ watch(
+ () => this.opts.value.current,
+ (value) => {
+ if (value.start && value.end) {
+ this.opts.startValue.current = value.start;
+ this.opts.endValue.current = value.end;
+ } else if (value.start) {
+ this.opts.startValue.current = value.start;
+ this.opts.endValue.current = undefined;
+ } else if (value.start === undefined && value.end === undefined) {
+ this.opts.startValue.current = undefined;
+ this.opts.endValue.current = undefined;
+ }
+ }
+ );
+
+ /**
+ * Synchronize the placeholder value with the current start value
+ */
+ watch(
+ () => this.opts.value.current,
+ (value) => {
+ const startValue = value.start;
+ if (startValue && this.opts.placeholder.current !== startValue) {
+ this.opts.placeholder.current = startValue;
+ }
+ }
+ );
+
+ /**
+ * Check for disabled dates in the selected range when excludeDisabled is enabled
+ */
+ watch(
+ [
+ () => this.opts.startValue.current,
+ () => this.opts.endValue.current,
+ () => this.opts.excludeDisabled.current,
+ ],
+ ([startValue, endValue, excludeDisabled]) => {
+ if (!excludeDisabled || !startValue || !endValue) return;
+
+ if (this.hasDisabledDatesInRange(startValue, endValue)) {
+ this.#setStartValue(undefined);
+ this.#setEndValue(undefined);
+ this.#announceEmpty();
+ }
+ }
+ );
+
+ watch(
+ [() => this.opts.startValue.current, () => this.opts.endValue.current],
+ ([startValue, endValue]) => {
+ if (
+ this.opts.value.current &&
+ this.opts.value.current.start === startValue &&
+ this.opts.value.current.end === endValue
+ ) {
+ return;
+ }
+
+ if (startValue && endValue) {
+ this.#updateValue((prev) => {
+ if (prev.start === startValue && prev.end === endValue) {
+ return prev;
+ }
+ if (isBefore(endValue, startValue)) {
+ const start = startValue;
+ const end = endValue;
+ this.#setStartValue(end);
+ this.#setEndValue(start);
+ if (!this.isRangeValid(endValue, startValue)) {
+ this.#setStartValue(startValue);
+ this.#setEndValue(undefined);
+ return { start: startValue, end: undefined };
+ }
+ return { start: endValue, end: startValue };
+ } else {
+ if (!this.isRangeValid(startValue, endValue)) {
+ this.#setStartValue(endValue);
+ this.#setEndValue(undefined);
+ return { start: endValue, end: undefined };
+ }
+ return {
+ start: startValue,
+ end: endValue,
+ };
+ }
+ });
+ } else if (
+ this.opts.value.current &&
+ this.opts.value.current.start &&
+ this.opts.value.current.end
+ ) {
+ this.opts.value.current.start = undefined;
+ this.opts.value.current.end = undefined;
+ }
+ }
+ );
+
+ this.shiftFocus = this.shiftFocus.bind(this);
+ this.handleCellClick = this.handleCellClick.bind(this);
+ this.onkeydown = this.onkeydown.bind(this);
+ this.nextPage = this.nextPage.bind(this);
+ this.prevPage = this.prevPage.bind(this);
+ this.isUnitDisabled = this.isUnitDisabled.bind(this);
+ this.isUnitUnavailable = this.isUnitUnavailable.bind(this);
+ this.isSelected = this.isSelected.bind(this);
+ }
+
+ #updateValue(cb: (value: DateRange) => DateRange) {
+ const value = this.opts.value.current;
+ const newValue = cb(value);
+ this.opts.value.current = newValue;
+ if (newValue.start && newValue.end) {
+ this.opts.onRangeSelect?.current?.();
+ }
+ }
+
+ #setStartValue(value: DateValue | undefined) {
+ this.opts.startValue.current = value;
+ // update the main value prop immediately for external consumers
+ this.#updateValue((prev) => ({
+ ...prev,
+ start: value,
+ }));
+ }
+
+ #setEndValue(value: DateValue | undefined) {
+ this.opts.endValue.current = value;
+ // update the main value prop immediately for external consumers
+ this.#updateValue((prev) => ({
+ ...prev,
+ end: value,
+ }));
+ }
+
+ isUnitDisabled(date: DateValue) {
+ if (this.opts.isUnitDisabled.current(date) || this.opts.disabled.current) return true;
+ const minValue = this.opts.minValue.current;
+ const maxValue = this.opts.maxValue.current;
+ if (minValue && isBefore(date, minValue)) return true;
+ if (maxValue && isAfter(date, maxValue)) return true;
+ return false;
+ }
+
+ isUnitUnavailable(date: DateValue) {
+ if (this.opts.isUnitUnavailable.current(date)) return true;
+ return false;
+ }
+
+ isSelectionStart(date: DateValue) {
+ if (!this.opts.startValue.current) return false;
+ return this.isSame(date, this.opts.startValue.current);
+ }
+
+ isSelectionEnd(date: DateValue) {
+ if (!this.opts.endValue.current) return false;
+ return this.isSame(date, this.opts.endValue.current);
+ }
+
+ isSelected(date: DateValue) {
+ if (this.opts.startValue.current && this.isSame(this.opts.startValue.current, date))
+ return true;
+ if (this.opts.endValue.current && this.isSame(this.opts.endValue.current, date))
+ return true;
+ if (this.opts.startValue.current && this.opts.endValue.current) {
+ return isBetweenInclusive(
+ date,
+ this.opts.startValue.current,
+ this.opts.endValue.current
+ );
+ }
+ return false;
+ }
+
+ abstract isRangeValid(start: DateValue, end: DateValue): boolean;
+
+ abstract shiftFocus(node: HTMLElement, add: number): void;
+
+ #announceEmpty() {
+ this.announcer.announce("Selected date is now empty.", "polite");
+ }
+
+ #announceSelectedDate(date: DateValue) {
+ this.announcer.announce(
+ `Selected Date: ${this.formatter.selectedDate(date, false)}`,
+ "polite"
+ );
+ }
+
+ #announceSelectedRange(start: DateValue, end: DateValue) {
+ this.announcer.announce(
+ `Selected Dates: ${this.formatter.selectedDate(start, false)} to ${this.formatter.selectedDate(end, false)}`,
+ "polite"
+ );
+ }
+
+ handleCellClick(e: Event, date: DateValue) {
+ if (this.isUnitDisabled(date) || this.isUnitUnavailable(date)) return;
+ const prevLastPressedDate = this.lastPressedUnitValue;
+ this.lastPressedUnitValue = date;
+
+ if (this.opts.startValue.current && this.highlightedRange === null) {
+ if (
+ this.isSame(this.opts.startValue.current, date) &&
+ !this.opts.preventDeselect.current &&
+ !this.opts.endValue.current
+ ) {
+ this.#setStartValue(undefined);
+ this.opts.placeholder.current = date;
+ this.#announceEmpty();
+ return;
+ } else if (!this.opts.endValue.current) {
+ e.preventDefault();
+ if (prevLastPressedDate && this.isSame(prevLastPressedDate, date)) {
+ this.#setStartValue(date);
+ this.#announceSelectedDate(date);
+ }
+ }
+ }
+
+ if (
+ this.opts.startValue.current &&
+ this.opts.endValue.current &&
+ this.isSame(this.opts.endValue.current, date) &&
+ !this.opts.preventDeselect.current
+ ) {
+ this.#setStartValue(undefined);
+ this.#setEndValue(undefined);
+ this.opts.placeholder.current = date;
+ this.#announceEmpty();
+ return;
+ }
+
+ if (!this.opts.startValue.current) {
+ this.#announceSelectedDate(date);
+ this.#setStartValue(date);
+ } else if (!this.opts.endValue.current) {
+ // determine the start and end dates for validation
+ const startDate = this.opts.startValue.current;
+ const endDate = date;
+ const orderedStart = isBefore(endDate, startDate) ? endDate : startDate;
+ const orderedEnd = isBefore(endDate, startDate) ? startDate : endDate;
+
+ // check if the range violates constraints
+ if (!this.isRangeValid(orderedStart, orderedEnd)) {
+ // reset to just the clicked date
+ this.#setStartValue(date);
+ this.#setEndValue(undefined);
+ this.#announceSelectedDate(date);
+ } else {
+ // ensure start and end are properly ordered
+ if (isBefore(endDate, startDate)) {
+ // backward selection - reorder the values
+ this.#setStartValue(endDate);
+ this.#setEndValue(startDate);
+ this.#announceSelectedRange(endDate, startDate);
+ } else {
+ // forward selection - keep original order
+ this.#setEndValue(date);
+ this.#announceSelectedRange(this.opts.startValue.current, date);
+ }
+ }
+ } else if (this.opts.endValue.current && this.opts.startValue.current) {
+ this.#setEndValue(undefined);
+ this.#announceSelectedDate(date);
+ this.#setStartValue(date);
+ }
+ }
+
+ onkeydown(event: BitsKeyboardEvent) {
+ return handleCalendarKeydown({
+ event,
+ handleCellClick: this.handleCellClick,
+ placeholderValue: this.opts.placeholder.current,
+ shiftFocus: this.shiftFocus,
+ });
+ }
+
+ /**
+ * Navigates to the next page of the calendar.
+ */
+ abstract nextPage(): void;
+
+ /**
+ * Navigates to the previous page of the calendar.
+ */
+ abstract prevPage(): void;
+
+ getBitsAttr: (typeof calendarAttrs)["getAttr"] = (part) => {
+ return calendarAttrs.getAttr(part, "range-calendar");
+ };
+
+ readonly props = $derived.by(
+ () =>
+ ({
+ ...getCalendarElementProps({
+ fullCalendarLabel: this.fullCalendarLabel,
+ id: this.opts.id.current,
+ isInvalid: this.isInvalid,
+ disabled: this.opts.disabled.current,
+ readonly: this.opts.readonly.current,
+ }),
+ [this.getBitsAttr("root")]: "",
+ //
+ onkeydown: this.onkeydown,
+ ...attachRef(this.opts.ref),
+ }) as const
+ );
+
+ hasDisabledDatesInRange(start: DateValue, end: DateValue): boolean {
+ for (
+ let date = start;
+ isBefore(date, end) || this.isSame(date, end);
+ date = date.add({ [this.cellUnit]: 1 })
+ ) {
+ if (this.isUnitDisabled(date)) return true;
+ }
+ return false;
+ }
+}
+
+export interface RangeCalendarBaseCellStateOpts
+ extends WithRefOpts,
+ ReadableBoxedValues<{
+ date: DateValue;
+ }> {}
+
+export abstract class RangeCalendarBaseCellState<
+ T extends RangeCalendarBaseCellStateOpts = RangeCalendarBaseCellStateOpts,
+ U extends RangeCalendarBaseRootState = RangeCalendarBaseRootState,
+> {
+ readonly opts: T;
+ readonly root: U;
+ readonly cellDate = $derived.by(() => toDate(this.opts.date.current));
+ readonly isDisabled = $derived.by(() => this.root.isUnitDisabled(this.opts.date.current));
+ readonly isUnavailable = $derived.by(() =>
+ this.root.opts.isUnitUnavailable.current(this.opts.date.current)
+ );
+ readonly isFocusedUnit = $derived.by(() =>
+ this.root.isSame(this.opts.date.current, this.root.opts.placeholder.current)
+ );
+ readonly isSelectedDate = $derived.by(() => this.root.isSelected(this.opts.date.current));
+ readonly isSelectionStart = $derived.by(() =>
+ this.root.isSelectionStart(this.opts.date.current)
+ );
+
+ readonly isRangeStart = $derived.by(() => this.root.isSelectionStart(this.opts.date.current));
+
+ readonly isRangeEnd = $derived.by(() => {
+ if (!this.root.opts.endValue.current)
+ return this.root.isSelectionStart(this.opts.date.current);
+ return this.root.isSelectionEnd(this.opts.date.current);
+ });
+
+ readonly isRangeMiddle = $derived.by(() => this.isSelectionMiddle);
+
+ readonly isSelectionMiddle = $derived.by(() => {
+ return this.isSelectedDate && !this.isSelectionStart && !this.isSelectionEnd;
+ });
+
+ readonly isSelectionEnd = $derived.by(() => this.root.isSelectionEnd(this.opts.date.current));
+ readonly isHighlighted = $derived.by(() =>
+ this.root.highlightedRange
+ ? isBetweenInclusive(
+ this.opts.date.current,
+ this.root.highlightedRange.start,
+ this.root.highlightedRange.end
+ )
+ : false
+ );
+
+ constructor(opts: T, root: U) {
+ this.opts = opts;
+ this.root = root;
+ }
+
+ readonly snippetProps = $derived.by(() => ({
+ disabled: this.isDisabled,
+ unavailable: this.isUnavailable,
+ selected: this.isSelectedDate,
+ }));
+
+ abstract readonly ariaDisabled: boolean;
+}
+
+export interface RangeCalendarUnitStateOpts extends WithRefOpts {}
+
+export abstract class RangeCalendarBaseUnitState<
+ T extends RangeCalendarUnitStateOpts = RangeCalendarUnitStateOpts,
+ U extends RangeCalendarBaseCellState = RangeCalendarBaseCellState,
+> {
+ readonly opts: T;
+ readonly cell: U;
+
+ constructor(opts: T, cell: U) {
+ this.opts = opts;
+ this.cell = cell;
+
+ this.onclick = this.onclick.bind(this);
+ this.onmouseenter = this.onmouseenter.bind(this);
+ this.onfocusin = this.onfocusin.bind(this);
+ }
+
+ onclick(e: BitsMouseEvent) {
+ if (this.cell.isDisabled) return;
+ this.cell.root.handleCellClick(e, this.cell.opts.date.current);
+ }
+
+ onmouseenter(_: BitsMouseEvent) {
+ if (this.cell.isDisabled) return;
+ this.cell.root.focusedValue = this.cell.opts.date.current;
+ }
+
+ onfocusin(_: BitsFocusEvent) {
+ if (this.cell.isDisabled) return;
+ this.cell.root.focusedValue = this.cell.opts.date.current;
+ }
+}
diff --git a/packages/bits-ui/src/lib/shared/date/types.ts b/packages/bits-ui/src/lib/shared/date/types.ts
index b417bfe6f..ae5983320 100644
--- a/packages/bits-ui/src/lib/shared/date/types.ts
+++ b/packages/bits-ui/src/lib/shared/date/types.ts
@@ -59,15 +59,24 @@ export type TimeRange = {
end: T | undefined;
};
-export type Month = {
+export interface CalendarView {
/**
- * A `DateValue` used to represent the month. Since days
- * from the previous and next months may be included in the
- * calendar grid, we need a source of truth for the value
- * the grid is representing.
+ * A `DateValue` representing the main reference point for the view.
+ * For example, in a monthly calendar, this would be a date in the
+ * month being displayed. It acts as the source of truth for the view's scope.
*/
value: DateValue;
+ /**
+ * A flat array of all the dates shown in the view, including dates from
+ * adjacent periods (e.g., previous/next months) that are displayed to fill
+ * the grid. Useful for rendering the calendar layout consistently.
+ */
+ dates: T[];
+}
+
+export interface Month extends CalendarView {
+ value: DateValue;
/**
* An array of arrays representing the weeks in the calendar.
* Each sub-array represents a week, and contains the dates for each
@@ -76,16 +85,17 @@ export type Month = {
* represents a day.
*/
weeks: T[][];
+}
+export interface Year extends CalendarView<{ value: T; label: string }> {
/**
- * An array of all the dates in the current month, including dates from
- * the previous and next months that are used to fill out the calendar grid.
- * This array is useful for rendering the calendar grid in a customizable way,
- * as it provides all the dates that should be displayed in the grid in a flat
- * array.
+ * A 2D array representing the months in the year view.
+ * Each sub-array represents a row of months, and each entry contains
+ * the value and label of a month. This structure supports rendering
+ * a grid-based year view (e.g., 3x4 layout for 12 months).
*/
- dates: T[];
-};
+ months: { value: T; label: string }[][];
+}
export type DateSegmentPart = (typeof DATE_SEGMENT_PARTS)[number];
export type EditableTimeSegmentPart = (typeof EDITABLE_TIME_SEGMENT_PARTS)[number];
diff --git a/packages/bits-ui/src/lib/types.ts b/packages/bits-ui/src/lib/types.ts
index 3a403961b..792a2b559 100644
--- a/packages/bits-ui/src/lib/types.ts
+++ b/packages/bits-ui/src/lib/types.ts
@@ -20,6 +20,7 @@ export type * from "$lib/bits/link-preview/types.js";
export type * from "$lib/bits/select/types.js";
export type * from "$lib/bits/menubar/types.js";
export type * from "$lib/bits/meter/types.js";
+export type * from "$lib/bits/month-calendar/types.js";
export type * from "$lib/bits/navigation-menu/types.js";
export type * from "$lib/bits/pagination/types.js";
export type * from "$lib/bits/pin-input/types.js";