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