diff --git a/.changeset/seven-dancers-admire.md b/.changeset/seven-dancers-admire.md new file mode 100644 index 000000000..594696760 --- /dev/null +++ b/.changeset/seven-dancers-admire.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +add onVisibleMonthsChange callback to calendars and date pickers diff --git a/docs/src/routes/api/demos.json/demos.json b/docs/src/routes/api/demos.json/demos.json new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docs/src/routes/api/demos.json/demos.json @@ -0,0 +1 @@ + diff --git a/docs/src/routes/api/demos.json/stackblitz-files.json b/docs/src/routes/api/demos.json/stackblitz-files.json new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docs/src/routes/api/demos.json/stackblitz-files.json @@ -0,0 +1 @@ + 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 a258344e7..68910ff4f 100644 --- a/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts +++ b/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts @@ -53,6 +53,7 @@ interface CalendarRootStateOpts WritableBoxedValues<{ value: DateValue | undefined | DateValue[]; placeholder: DateValue; + months: Month[]; }>, ReadableBoxedValues<{ preventDeselect: boolean; @@ -94,7 +95,7 @@ export class CalendarRootState { } readonly opts: CalendarRootStateOpts; - readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value)); + readonly visibleMonths = $derived.by(() => this.#months.map((month) => month.value)); readonly formatter: Formatter; readonly accessibleHeadingId = useId(); readonly domContext: DOMContext; @@ -134,7 +135,7 @@ export class CalendarRootState { this.announcer = getAnnouncer(this.domContext.getDocument()); }); - this.months = createMonths({ + this.opts.months.current = createMonths({ dateObj: this.opts.placeholder.current, weekStartsOn: this.opts.weekStartsOn.current, locale: this.opts.locale.current, @@ -156,7 +157,7 @@ export class CalendarRootState { locale: this.opts.locale, fixedWeeks: this.opts.fixedWeeks, numberOfMonths: this.opts.numberOfMonths, - setMonths: (months: Month[]) => (this.months = months), + setMonths: (months: Month[]) => (this.opts.months.current = months), }); /** @@ -217,8 +218,24 @@ export class CalendarRootState { }); } + /** + * Currently displayed months, with default value fallback for SSR, + * as boxes don't update server-side. + */ + get #months() { + return this.opts.months.current.length + ? this.opts.months.current + : createMonths({ + dateObj: this.opts.placeholder.current, + weekStartsOn: this.opts.weekStartsOn.current, + locale: this.opts.locale.current, + fixedWeeks: this.opts.fixedWeeks.current, + numberOfMonths: this.opts.numberOfMonths.current, + }); + } + setMonths(months: Month[]) { - this.months = months; + this.opts.months.current = months; } /** @@ -230,7 +247,7 @@ export class CalendarRootState { */ readonly weekdays = $derived.by(() => { return getWeekdays({ - months: this.months, + months: this.#months, formatter: this.formatter, weekdayFormat: this.opts.weekdayFormat.current, }); @@ -293,7 +310,7 @@ export class CalendarRootState { setMonths: this.setMonths, setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), weekStartsOn: this.opts.weekStartsOn.current, - months: this.months, + months: this.#months, }); } @@ -309,7 +326,7 @@ export class CalendarRootState { setMonths: this.setMonths, setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), weekStartsOn: this.opts.weekStartsOn.current, - months: this.months, + months: this.#months, }); } @@ -332,7 +349,7 @@ export class CalendarRootState { isNextButtonDisabled = $derived.by(() => { return getIsNextButtonDisabled({ maxValue: this.opts.maxValue.current, - months: this.months, + months: this.#months, disabled: this.opts.disabled.current, }); }); @@ -340,7 +357,7 @@ export class CalendarRootState { isPrevButtonDisabled = $derived.by(() => { return getIsPrevButtonDisabled({ minValue: this.opts.minValue.current, - months: this.months, + months: this.#months, disabled: this.opts.disabled.current, }); }); @@ -367,7 +384,7 @@ export class CalendarRootState { this.opts.monthFormat.current; this.opts.yearFormat.current; return getCalendarHeadingValue({ - months: this.months, + months: this.#months, formatter: this.formatter, locale: this.opts.locale.current, }); @@ -408,7 +425,7 @@ export class CalendarRootState { calendarNode: this.opts.ref.current, isPrevButtonDisabled: this.isPrevButtonDisabled, isNextButtonDisabled: this.isNextButtonDisabled, - months: this.months, + months: this.#months, numberOfMonths: this.opts.numberOfMonths.current, }); } @@ -509,7 +526,7 @@ export class CalendarRootState { } readonly snippetProps = $derived.by(() => ({ - months: this.months, + months: this.#months, weekdays: this.weekdays, })); 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 0386f8b1b..316eccc44 100644 --- a/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte +++ b/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte @@ -8,6 +8,7 @@ import { noop } from "$lib/internal/noop.js"; import { getDefaultDate } from "$lib/internal/date-time/utils.js"; import { resolveLocaleProp } from "$lib/bits/utilities/config/prop-resolvers.js"; + import type { Month } from "$lib/shared/index.js"; let { child, @@ -38,9 +39,12 @@ maxDays, monthFormat = "long", yearFormat = "numeric", + onVisibleMonthsChange = noop, ...restProps }: CalendarRootProps = $props(); + let months = $state[]>([]); + const defaultPlaceholder = getDefaultDate({ defaultValue: value, }); @@ -117,6 +121,13 @@ monthFormat: boxWith(() => monthFormat), yearFormat: boxWith(() => yearFormat), defaultPlaceholder, + months: boxWith( + () => months, + (v) => { + months = v; + onVisibleMonthsChange(v); + } + ), }); const mergedProps = $derived(mergeProps(restProps, rootState.props)); diff --git a/packages/bits-ui/src/lib/bits/calendar/types.ts b/packages/bits-ui/src/lib/bits/calendar/types.ts index e93a46e7e..6b21323ef 100644 --- a/packages/bits-ui/src/lib/bits/calendar/types.ts +++ b/packages/bits-ui/src/lib/bits/calendar/types.ts @@ -34,6 +34,11 @@ type CalendarBaseRootPropsWithoutHTML = { */ onPlaceholderChange?: OnChangeFn; + /** + * A callback function called when the currently displayed month(s) changes. + */ + onVisibleMonthsChange?: OnChangeFn[]>; + /** * Whether or not users can deselect a date once selected * without selecting another date. 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 1fc92004a..1a3c6c13d 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 @@ -41,6 +41,7 @@ minValue: datePickerRootState.opts.minValue, placeholder: datePickerRootState.opts.placeholder, value: datePickerRootState.opts.value, + months: datePickerRootState.opts.months, onDateSelect: datePickerRootState.opts.onDateSelect, initialFocus: datePickerRootState.opts.initialFocus, defaultPlaceholder: datePickerRootState.opts.defaultPlaceholder, diff --git a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte index b4c5c9958..065d81255 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte +++ b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte @@ -11,6 +11,7 @@ import { FloatingLayer } from "$lib/bits/utilities/floating-layer/index.js"; import { getDefaultDate } from "$lib/internal/date-time/utils.js"; import { resolveLocaleProp } from "$lib/bits/utilities/config/prop-resolvers.js"; + import type { Month } from "$lib/shared/index.js"; let { open = $bindable(false), @@ -48,8 +49,11 @@ children, monthFormat = "long", yearFormat = "numeric", + onVisibleMonthsChange = noop, }: DatePickerRootProps = $props(); + let months = $state[]>([]); + const defaultPlaceholder = getDefaultDate({ granularity, defaultValue: value, @@ -125,6 +129,13 @@ numberOfMonths: boxWith(() => numberOfMonths), initialFocus: boxWith(() => initialFocus), onDateSelect: boxWith(() => onDateSelect), + months: boxWith( + () => months, + (v) => { + months = v; + onVisibleMonthsChange(v); + } + ), defaultPlaceholder, monthFormat: boxWith(() => monthFormat), yearFormat: boxWith(() => yearFormat), diff --git a/packages/bits-ui/src/lib/bits/date-picker/date-picker.svelte.ts b/packages/bits-ui/src/lib/bits/date-picker/date-picker.svelte.ts index 5da2704f0..67b238992 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/date-picker.svelte.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/date-picker.svelte.ts @@ -2,7 +2,7 @@ import type { DateValue } from "@internationalized/date"; import { Context } from "runed"; import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt"; import type { DateMatcher, SegmentPart } from "$lib/shared/index.js"; -import type { Granularity, HourCycle, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { Granularity, HourCycle, Month, WeekStartsOn } from "$lib/shared/date/types.js"; export const DatePickerRootContext = new Context("DatePicker.Root"); @@ -11,6 +11,7 @@ interface DatePickerRootStateOpts value: DateValue | undefined; open: boolean; placeholder: DateValue; + months: Month[]; }>, ReadableBoxedValues<{ readonlySegments: SegmentPart[]; diff --git a/packages/bits-ui/src/lib/bits/date-picker/types.ts b/packages/bits-ui/src/lib/bits/date-picker/types.ts index 2a93f2688..a04ead2ce 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/types.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/types.ts @@ -8,7 +8,7 @@ import type { DateValidator, EditableSegmentPart, } from "$lib/shared/index.js"; -import type { Granularity, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { Granularity, Month, WeekStartsOn } from "$lib/shared/date/types.js"; import type { PortalProps } from "$lib/bits/utilities/portal/index.js"; export type DatePickerRootPropsWithoutHTML = WithChildren<{ @@ -24,6 +24,11 @@ export type DatePickerRootPropsWithoutHTML = WithChildren<{ */ onValueChange?: OnChangeFn; + /** + * A callback function called when the currently displayed month(s) changes. + */ + onVisibleMonthsChange?: OnChangeFn[]>; + /** * The placeholder value of the date field. This determines the format * and what date the field starts at when it is empty. 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 e1184f5b8..81d9fdbf7 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 @@ -42,6 +42,7 @@ value: dateRangePickerRootState.opts.value, excludeDisabled: dateRangePickerRootState.opts.excludeDisabled, onRangeSelect: dateRangePickerRootState.opts.onRangeSelect, + months: dateRangePickerRootState.opts.months, startValue: dateRangePickerRootState.opts.startValue, endValue: dateRangePickerRootState.opts.endValue, defaultPlaceholder: dateRangePickerRootState.opts.defaultPlaceholder, diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte index 8e1828dbe..928da6533 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte +++ b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte @@ -9,7 +9,7 @@ import { DateRangeFieldRootState } from "$lib/bits/date-range-field/date-range-field.svelte.js"; import FloatingLayer from "$lib/bits/utilities/floating-layer/components/floating-layer.svelte"; import { useId } from "$lib/internal/use-id.js"; - import type { DateRange } from "$lib/shared/index.js"; + import type { DateRange, Month } from "$lib/shared/index.js"; import { getDefaultDate } from "$lib/internal/date-time/utils.js"; import { resolveLocaleProp } from "$lib/bits/utilities/config/prop-resolvers.js"; @@ -47,6 +47,7 @@ closeOnRangeSelect = true, onStartValueChange = noop, onEndValueChange = noop, + onVisibleMonthsChange = noop, validate = noop, errorMessageId, minDays, @@ -61,6 +62,7 @@ let startValue = $state(value?.start); let endValue = $state(value?.end); + let months = $state.raw[]>([]); function handleDefaultValue() { if (value !== undefined) return; @@ -159,6 +161,13 @@ numberOfMonths: boxWith(() => numberOfMonths), excludeDisabled: boxWith(() => excludeDisabled), onRangeSelect: boxWith(() => onRangeSelect), + months: boxWith( + () => months, + (v) => { + months = v; + onVisibleMonthsChange(v); + } + ), startValue: boxWith( () => startValue, (v) => { diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/date-range-picker.svelte.ts b/packages/bits-ui/src/lib/bits/date-range-picker/date-range-picker.svelte.ts index 4e7e6697f..dd2fe3780 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/date-range-picker.svelte.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/date-range-picker.svelte.ts @@ -2,7 +2,7 @@ import type { DateValue } from "@internationalized/date"; import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt"; import { Context } from "runed"; import type { DateMatcher, DateRange, SegmentPart } from "$lib/shared/index.js"; -import type { Granularity, HourCycle, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { Granularity, HourCycle, Month, WeekStartsOn } from "$lib/shared/date/types.js"; export const DateRangePickerRootContext = new Context( "DateRangePicker.Root" @@ -13,6 +13,7 @@ interface DateRangePickerRootStateOpts value: DateRange; startValue: DateValue | undefined; endValue: DateValue | undefined; + months: Month[]; open: boolean; placeholder: DateValue; }>, diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/types.ts b/packages/bits-ui/src/lib/bits/date-range-picker/types.ts index 6c3113776..c54bd9047 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/types.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/types.ts @@ -9,7 +9,7 @@ import type { EditableSegmentPart, } from "$lib/shared/index.js"; import type { CalendarRootSnippetProps } from "$lib/types.js"; -import type { Granularity, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { Granularity, Month, WeekStartsOn } from "$lib/shared/date/types.js"; export type DateRangePickerRootPropsWithoutHTML = WithChild<{ /** @@ -271,6 +271,11 @@ export type DateRangePickerRootPropsWithoutHTML = WithChild<{ */ onEndValueChange?: OnChangeFn; + /** + * A callback function called when the currently displayed month(s) changes. + */ + onVisibleMonthsChange?: OnChangeFn[]>; + /** * The `id` of the element which contains the error messages for the date field when the * date is invalid. 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 4b046a820..12a4f9db2 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 @@ -8,6 +8,7 @@ import { createId } from "$lib/internal/create-id.js"; import { getDefaultDate } from "$lib/internal/date-time/utils.js"; import { resolveLocaleProp } from "$lib/bits/utilities/config/prop-resolvers.js"; + import type { Month } from "$lib/shared/index.js"; const uid = $props.id(); @@ -42,11 +43,13 @@ excludeDisabled = false, monthFormat = "long", yearFormat = "numeric", + onVisibleMonthsChange = noop, ...restProps }: RangeCalendarRootProps = $props(); let startValue = $state(value?.start); let endValue = $state(value?.end); + let months = $state.raw[]>([]); const defaultPlaceholder = getDefaultDate({ defaultValue: value?.start, @@ -120,6 +123,13 @@ minDays: boxWith(() => minDays), maxDays: boxWith(() => maxDays), excludeDisabled: boxWith(() => excludeDisabled), + months: boxWith( + () => months, + (v) => { + months = v; + onVisibleMonthsChange(v); + } + ), startValue: boxWith( () => startValue, (v) => { 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 5c2bd53fc..bb3d400ca 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 @@ -6,10 +6,13 @@ import { isToday, } from "@internationalized/date"; import { + box, + useRefById, attachRef, DOMContext, type ReadableBoxedValues, type WritableBoxedValues, + type WritableBox, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { CalendarRootContext } from "../calendar/calendar.svelte.js"; @@ -60,6 +63,7 @@ interface RangeCalendarRootStateOpts WritableBoxedValues<{ value: DateRange; placeholder: DateValue; + months: Month[]; startValue: DateValue | undefined; endValue: DateValue | undefined; }>, @@ -100,7 +104,7 @@ export class RangeCalendarRootState { readonly opts: RangeCalendarRootStateOpts; readonly attachment: RefAttachment; - readonly visibleMonths = $derived.by(() => this.months.map((month) => month.value)); + readonly visibleMonths = $derived.by(() => this.#months.map((month) => month.value)); months: Month[] = $state([]); announcer: Announcer; formatter: Formatter; @@ -118,7 +122,7 @@ export class RangeCalendarRootState { */ readonly weekdays = $derived.by(() => { return getWeekdays({ - months: this.months, + months: this.#months, formatter: this.formatter, weekdayFormat: this.opts.weekdayFormat.current, }); @@ -156,7 +160,7 @@ export class RangeCalendarRootState { readonly isNextButtonDisabled = $derived.by(() => { return getIsNextButtonDisabled({ maxValue: this.opts.maxValue.current, - months: this.months, + months: this.#months, disabled: this.opts.disabled.current, }); }); @@ -164,7 +168,7 @@ export class RangeCalendarRootState { readonly isPrevButtonDisabled = $derived.by(() => { return getIsPrevButtonDisabled({ minValue: this.opts.minValue.current, - months: this.months, + months: this.#months, disabled: this.opts.disabled.current, }); }); @@ -173,7 +177,7 @@ export class RangeCalendarRootState { this.opts.monthFormat.current; this.opts.yearFormat.current; return getCalendarHeadingValue({ - months: this.months, + months: this.#months, formatter: this.formatter, locale: this.opts.locale.current, }); @@ -230,7 +234,7 @@ export class RangeCalendarRootState { yearFormat: this.opts.yearFormat, }); - this.months = createMonths({ + this.opts.months.current = createMonths({ dateObj: this.opts.placeholder.current, weekStartsOn: this.opts.weekStartsOn.current, locale: this.opts.locale.current, @@ -411,6 +415,22 @@ export class RangeCalendarRootState { }); } + /** + * Currently displayed months, with default value fallback for SSR, + * as boxes don't update server-side. + */ + get #months() { + return this.opts.months.current.length + ? this.opts.months.current + : createMonths({ + dateObj: this.opts.placeholder.current, + weekStartsOn: this.opts.weekStartsOn.current, + locale: this.opts.locale.current, + fixedWeeks: this.opts.fixedWeeks.current, + numberOfMonths: this.opts.numberOfMonths.current, + }); + } + #updateValue(cb: (value: DateRange) => DateRange) { const value = this.opts.value.current; const newValue = cb(value); @@ -439,7 +459,7 @@ export class RangeCalendarRootState { } setMonths = (months: Month[]) => { - this.months = months; + this.opts.months.current = months; }; isOutsideVisibleMonths(date: DateValue) { @@ -518,7 +538,7 @@ export class RangeCalendarRootState { calendarNode: this.opts.ref.current, isPrevButtonDisabled: this.isPrevButtonDisabled, isNextButtonDisabled: this.isNextButtonDisabled, - months: this.months, + months: this.#months, numberOfMonths: this.opts.numberOfMonths.current, }); } @@ -635,7 +655,7 @@ export class RangeCalendarRootState { setMonths: this.setMonths, setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), weekStartsOn: this.opts.weekStartsOn.current, - months: this.months, + months: this.#months, }); } @@ -651,7 +671,7 @@ export class RangeCalendarRootState { setMonths: this.setMonths, setPlaceholder: (date: DateValue) => (this.opts.placeholder.current = date), weekStartsOn: this.opts.weekStartsOn.current, - months: this.months, + months: this.#months, }); } @@ -675,8 +695,8 @@ export class RangeCalendarRootState { return calendarAttrs.getAttr(part, "range-calendar"); }; - readonly snippetProps = $derived.by(() => ({ - months: this.months, + snippetProps = $derived.by(() => ({ + months: this.#months, weekdays: this.weekdays, })); diff --git a/packages/bits-ui/src/lib/bits/range-calendar/types.ts b/packages/bits-ui/src/lib/bits/range-calendar/types.ts index c312d2a30..619a37ce2 100644 --- a/packages/bits-ui/src/lib/bits/range-calendar/types.ts +++ b/packages/bits-ui/src/lib/bits/range-calendar/types.ts @@ -235,6 +235,11 @@ export type RangeCalendarRootPropsWithoutHTML = WithChild< * @default "numeric" */ yearFormat?: Intl.DateTimeFormatOptions["year"] | ((year: number) => string); + + /* + * A callback function called when the currently displayed month(s) changes. + */ + onVisibleMonthsChange?: OnChangeFn[]>; }, RangeCalendarRootSnippetProps >;