diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index a263d097c..2c1334325 100644 --- a/docs/content/components/calendar.md +++ b/docs/content/components/calendar.md @@ -4,7 +4,7 @@ description: Displays dates and days of the week, facilitating date-related inte --- @@ -41,6 +41,8 @@ Before diving into this component, it's important to understand how dates/times + + {#each weekdays as day} {day} @@ -51,6 +53,8 @@ Before diving into this component, it's important to understand how dates/times {#each month.weeks as weekDates} + + {#each weekDates as date} @@ -307,6 +311,60 @@ You can use the `fixedWeeks` prop to ensure that the calendar renders a fixed nu ``` +### Week Numbers + +Set `showWeekNumbers` on `Calendar.Root` to display ISO 8601 week numbers. You also need to include `` in the grid head row and `` as the first child of each body grid row. Both components render nothing when `showWeekNumbers` is `false`, so you can leave them in your template and toggle the prop to reveal or hide the column. + +```svelte + + {#snippet children({ months, weekdays })} + + {#each months as month} + + + + Wk + {#each weekdays as day} + {day} + {/each} + + + + {#each month.weeks as weekDates} + + + {#each weekDates as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + +``` + +The week number is computed using the ISO 8601 standard (week 1 is the week containing the first Thursday of the year, weeks start on Monday) regardless of the `weekStartsOn` prop. You can customize the cell content via the `children` snippet, which receives `{ weekNumber }`: + +```svelte + + {#snippet children({ weekNumber })} + W{weekNumber} + {/snippet} + +``` + + + +{#snippet preview()} + +{/snippet} + + + ### Multiple Months You can use the `numberOfMonths` prop to render multiple months at once. diff --git a/docs/content/components/range-calendar.md b/docs/content/components/range-calendar.md index f6dbfcacc..a50c23479 100644 --- a/docs/content/components/range-calendar.md +++ b/docs/content/components/range-calendar.md @@ -4,7 +4,7 @@ description: Enables users to select a range of dates using a calendar interface --- @@ -120,6 +120,24 @@ You can set both `minDays` and `maxDays` to limit the number of days that can be +### Week Numbers + +Set `showWeekNumbers` on `RangeCalendar.Root` to display ISO 8601 week numbers. Include `` in the grid head row and `` as the first child of each body grid row. + +```svelte + + + +``` + + + +{#snippet preview()} + +{/snippet} + + + ### Exclude Disabled You can set the `excludeDisabled` prop to automatically reset the range if any date within the selected range becomes disabled. diff --git a/docs/src/lib/components/demos/calendar-demo-week-numbers.svelte b/docs/src/lib/components/demos/calendar-demo-week-numbers.svelte new file mode 100644 index 000000000..1b1fa7f28 --- /dev/null +++ b/docs/src/lib/components/demos/calendar-demo-week-numbers.svelte @@ -0,0 +1,81 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + + + + +
+ {#each months as month, i (i)} + + + + +
Wk
+
+ {#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/lib/components/demos/index.ts b/docs/src/lib/components/demos/index.ts index 736bdbbd1..4edefc0c0 100644 --- a/docs/src/lib/components/demos/index.ts +++ b/docs/src/lib/components/demos/index.ts @@ -11,6 +11,7 @@ export { default as ButtonDemo } from "./button-demo.svelte"; export { default as CalendarDemo } from "./calendar-demo.svelte"; export { default as CalendarDemoSelects } from "./calendar-demo-selects.svelte"; export { default as CalendarDemoPresets } from "./calendar-demo-presets.svelte"; +export { default as CalendarDemoWeekNumbers } from "./calendar-demo-week-numbers.svelte"; export { default as CheckboxDemo } from "./checkbox-demo.svelte"; export { default as CheckboxDemoCustom } from "./checkbox-demo-custom.svelte"; export { default as CheckboxDemoGroup } from "./checkbox-demo-group.svelte"; @@ -106,3 +107,4 @@ export { default as RangeCalendarDemoMax } from "./range-calendar-demo-max.svelt export { default as RangeCalendarDemoMinMax } from "./range-calendar-demo-min-max.svelte"; export { default as CalendarDemoMax } from "./calendar-demo-max.svelte"; export { default as RangeCalendarDemoExcludeDisabled } from "./range-calendar-demo-exclude-disabled.svelte"; +export { default as RangeCalendarDemoWeekNumbers } from "./range-calendar-demo-week-numbers.svelte"; diff --git a/docs/src/lib/components/demos/range-calendar-demo-week-numbers.svelte b/docs/src/lib/components/demos/range-calendar-demo-week-numbers.svelte new file mode 100644 index 000000000..9ddf21bc6 --- /dev/null +++ b/docs/src/lib/components/demos/range-calendar-demo-week-numbers.svelte @@ -0,0 +1,83 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + + + + +
+ {#each months as month (month.value.month)} + + + + +
Wk
+
+ {#each weekdays as day (day)} + +
{day.slice(0, 2)}
+
+ {/each} +
+
+ + {#each month.weeks as weekDates, i (i)} + + + {#each weekDates as date, d (d)} + + + + {date.day} + + + {/each} + + {/each} + +
+ {/each} +
+ {/snippet} +
diff --git a/docs/src/lib/content/api-reference/calendar.api.ts b/docs/src/lib/content/api-reference/calendar.api.ts index dd8b52b74..5e2762ba3 100644 --- a/docs/src/lib/content/api-reference/calendar.api.ts +++ b/docs/src/lib/content/api-reference/calendar.api.ts @@ -12,6 +12,8 @@ import type { CalendarNextButtonPropsWithoutHTML, CalendarPrevButtonPropsWithoutHTML, CalendarRootPropsWithoutHTML, + CalendarWeekNumberCellPropsWithoutHTML, + CalendarWeekNumberHeadCellPropsWithoutHTML, CalendarYearSelectPropsWithoutHTML, } from "bits-ui"; import { @@ -134,6 +136,11 @@ export const root = defineComponentApiSchema({ description: "Whether or not to always display 6 weeks in the calendar.", default: false, }), + showWeekNumbers: defineBooleanProp({ + description: + "Whether or not to display ISO 8601 week numbers. When enabled, include `` in the grid head row and `` as the first child of each body grid row.", + default: false, + }), isDateDisabled: defineFunctionProp({ definition: DateMatcherProp, description: "A function that returns whether or not a date is disabled.", @@ -654,6 +661,68 @@ export function createCalendarYearSelectSchema(isRange: boolean) { export const yearSelect = createCalendarYearSelectSchema(false); +export function createCalendarWeekNumberCellSchema(isRange: boolean) { + return defineComponentApiSchema({ + title: "WeekNumberCell", + description: + "A table cell that displays the ISO 8601 week number for its row. Only renders when `showWeekNumbers` is enabled on the root.", + props: { + week: defineSimplePropSchema({ + type: "DateValue[]", + description: + "The dates for the week row, passed from the `{#each month.weeks as weekDates}` loop. The ISO week number is derived from the first day of the array.", + required: true, + }), + ...withChildProps({ elType: "HTMLTableCellElement" }), + }, + dataAttributes: [ + defineSimpleDataAttr({ + name: "disabled", + description: "Present on the week number cell element when the calendar is disabled.", + }), + defineSimpleDataAttr({ + name: "readonly", + description: "Present on the week number cell element when the calendar is readonly.", + }), + defineSimpleDataAttr({ + name: isRange ? "range-calendar-week-number-cell" : "calendar-week-number-cell", + description: "Present on the week number cell element.", + }), + ], + }); +} + +export const weekNumberCell = createCalendarWeekNumberCellSchema(false); + +export function createCalendarWeekNumberHeadCellSchema(isRange: boolean) { + return defineComponentApiSchema({ + title: "WeekNumberHeadCell", + description: + "A table header cell for the week number column. Only renders when `showWeekNumbers` is enabled on the root. Place this as the first child of the `GridRow` inside `GridHead`.", + props: withChildProps({ elType: "HTMLTableCellElement" }), + dataAttributes: [ + defineSimpleDataAttr({ + name: "disabled", + description: + "Present on the week number head cell element when the calendar is disabled.", + }), + defineSimpleDataAttr({ + name: "readonly", + description: + "Present on the week number head cell element when the calendar is readonly.", + }), + defineSimpleDataAttr({ + name: isRange + ? "range-calendar-week-number-head-cell" + : "calendar-week-number-head-cell", + description: "Present on the week number head cell element.", + }), + ], + }); +} + +export const weekNumberHeadCell = createCalendarWeekNumberHeadCellSchema(false); + export const calendar = [ root, header, @@ -667,6 +736,8 @@ export const calendar = [ gridHead, gridRow, headCell, + weekNumberCell, + weekNumberHeadCell, monthSelect, yearSelect, ]; diff --git a/docs/src/lib/content/api-reference/date-picker.api.ts b/docs/src/lib/content/api-reference/date-picker.api.ts index 7a0c8b4e9..e7dae3ed5 100644 --- a/docs/src/lib/content/api-reference/date-picker.api.ts +++ b/docs/src/lib/content/api-reference/date-picker.api.ts @@ -85,6 +85,7 @@ export const root = defineComponentApiSchema({ initialFocus: calendarRoot.props!.initialFocus, monthFormat: calendarRoot.props!.monthFormat, yearFormat: calendarRoot.props!.yearFormat, + showWeekNumbers: calendarRoot.props!.showWeekNumbers, children: childrenSnippet(), }, dataAttributes: [], diff --git a/docs/src/lib/content/api-reference/date-range-picker.api.ts b/docs/src/lib/content/api-reference/date-range-picker.api.ts index 9b1538a15..6eb2a9998 100644 --- a/docs/src/lib/content/api-reference/date-range-picker.api.ts +++ b/docs/src/lib/content/api-reference/date-range-picker.api.ts @@ -78,6 +78,7 @@ const root = defineComponentApiSchema({ excludeDisabled: rangeCalendarRoot.props!.excludeDisabled, monthFormat: rangeCalendarRoot.props!.monthFormat, yearFormat: rangeCalendarRoot.props!.yearFormat, + showWeekNumbers: rangeCalendarRoot.props!.showWeekNumbers, ...withChildProps({ elType: "HTMLDivElement" }), }, dataAttributes: [ diff --git a/docs/src/lib/content/api-reference/range-calendar.api.ts b/docs/src/lib/content/api-reference/range-calendar.api.ts index 65160dd5e..bcd802bbe 100644 --- a/docs/src/lib/content/api-reference/range-calendar.api.ts +++ b/docs/src/lib/content/api-reference/range-calendar.api.ts @@ -14,6 +14,8 @@ import { createCalendarMonthSelectSchema, createCalendarNextButtonSchema, createCalendarPrevButtonSchema, + createCalendarWeekNumberCellSchema, + createCalendarWeekNumberHeadCellSchema, createCalendarYearSelectSchema, } from "./calendar.api.js"; import { root as rangeFieldRoot } from "./date-range-field.api.js"; @@ -39,6 +41,7 @@ export const root = defineComponentApiSchema( weekStartsOn: calendarRoot.props!.weekStartsOn, calendarLabel: calendarRoot.props!.calendarLabel, fixedWeeks: calendarRoot.props!.fixedWeeks, + showWeekNumbers: calendarRoot.props!.showWeekNumbers, isDateDisabled: calendarRoot.props!.isDateDisabled, isDateUnavailable: calendarRoot.props!.isDateUnavailable, maxValue: calendarRoot.props!.maxValue, @@ -132,6 +135,8 @@ export const nextButton = createCalendarNextButtonSchema(true); export const prevButton = createCalendarPrevButtonSchema(true); export const monthSelect = createCalendarMonthSelectSchema(true); export const yearSelect = createCalendarYearSelectSchema(true); +export const weekNumberCell = createCalendarWeekNumberCellSchema(true); +export const weekNumberHeadCell = createCalendarWeekNumberHeadCellSchema(true); export const rangeCalendar = [ root, @@ -146,6 +151,8 @@ export const rangeCalendar = [ gridHead, gridRow, headCell, + weekNumberCell, + weekNumberHeadCell, monthSelect, yearSelect, ]; 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..f05f63b1a 100644 --- a/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts +++ b/packages/bits-ui/src/lib/bits/calendar/calendar.svelte.ts @@ -45,7 +45,12 @@ 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, + getISOWeekNumberForWeek, + isBefore, + toDate, +} from "$lib/internal/date-time/utils.js"; import type { WeekStartsOn } from "$lib/shared/date/types.js"; interface CalendarRootStateOpts @@ -69,6 +74,7 @@ interface CalendarRootStateOpts locale: string; calendarLabel: string; type: "single" | "multiple"; + showWeekNumbers: boolean; readonly: boolean; disableDaysOutsideMonth: boolean; initialFocus: boolean; @@ -1173,3 +1179,78 @@ export class CalendarYearSelectState { }) as const ); } + +interface CalendarWeekNumberCellStateOpts + extends WithRefOpts, + ReadableBoxedValues<{ + week: DateValue[]; + }> {} + +export class CalendarWeekNumberCellState { + static create(opts: CalendarWeekNumberCellStateOpts) { + return new CalendarWeekNumberCellState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarWeekNumberCellStateOpts; + readonly root: CalendarRootState | RangeCalendarRootState; + readonly attachment: RefAttachment; + + constructor( + opts: CalendarWeekNumberCellStateOpts, + root: CalendarRootState | RangeCalendarRootState + ) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly weekNumber = $derived.by(() => { + return getISOWeekNumberForWeek(this.opts.week.current); + }); + + readonly snippetProps = $derived.by(() => ({ weekNumber: this.weekNumber })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getBitsAttr("week-number-cell")]: "", + ...this.attachment, + }) as const + ); +} + +interface CalendarWeekNumberHeadCellStateOpts extends WithRefOpts {} + +export class CalendarWeekNumberHeadCellState { + static create(opts: CalendarWeekNumberHeadCellStateOpts) { + return new CalendarWeekNumberHeadCellState(opts, CalendarRootContext.get()); + } + + readonly opts: CalendarWeekNumberHeadCellStateOpts; + readonly root: CalendarRootState | RangeCalendarRootState; + readonly attachment: RefAttachment; + + constructor( + opts: CalendarWeekNumberHeadCellStateOpts, + root: CalendarRootState | RangeCalendarRootState + ) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(this.opts.ref); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + scope: "col", + "data-disabled": boolToEmptyStrOrUndef(this.root.opts.disabled.current), + "data-readonly": boolToEmptyStrOrUndef(this.root.opts.readonly.current), + [this.root.getBitsAttr("week-number-head-cell")]: "", + ...this.attachment, + }) as const + ); +} diff --git a/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-cell.svelte b/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-cell.svelte new file mode 100644 index 000000000..cdae9d738 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-cell.svelte @@ -0,0 +1,45 @@ + + +{#if root.opts.showWeekNumbers.current} + {#if child} + {@render child({ props: mergedProps, ...cellState.snippetProps })} + {:else} + + {#if children} + {@render children(cellState.snippetProps)} + {:else} + {String(cellState.weekNumber).padStart(2, "0")} + {/if} + + {/if} +{/if} diff --git a/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-head-cell.svelte b/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-head-cell.svelte new file mode 100644 index 000000000..f5e7e04a1 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-head-cell.svelte @@ -0,0 +1,39 @@ + + +{#if root.opts.showWeekNumbers.current} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {@render children?.()} + + {/if} +{/if} 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 d26170ec4..8866772cd 100644 --- a/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte +++ b/packages/bits-ui/src/lib/bits/calendar/components/calendar.svelte @@ -24,6 +24,7 @@ isDateDisabled = () => false, isDateUnavailable = () => false, fixedWeeks = false, + showWeekNumbers = false, numberOfMonths = 1, locale, calendarLabel = "Event", @@ -89,6 +90,7 @@ isDateDisabled: boxWith(() => isDateDisabled), isDateUnavailable: boxWith(() => isDateUnavailable), fixedWeeks: boxWith(() => fixedWeeks), + showWeekNumbers: boxWith(() => showWeekNumbers), numberOfMonths: boxWith(() => numberOfMonths), locale: resolveLocaleProp(() => locale), calendarLabel: boxWith(() => calendarLabel), diff --git a/packages/bits-ui/src/lib/bits/calendar/exports.ts b/packages/bits-ui/src/lib/bits/calendar/exports.ts index 72c6b6a0b..61f1a083d 100644 --- a/packages/bits-ui/src/lib/bits/calendar/exports.ts +++ b/packages/bits-ui/src/lib/bits/calendar/exports.ts @@ -12,6 +12,8 @@ export { default as MonthSelect } from "./components/calendar-month-select.svelt export { default as NextButton } from "./components/calendar-next-button.svelte"; export { default as PrevButton } from "./components/calendar-prev-button.svelte"; export { default as YearSelect } from "./components/calendar-year-select.svelte"; +export { default as WeekNumberCell } from "./components/calendar-week-number-cell.svelte"; +export { default as WeekNumberHeadCell } from "./components/calendar-week-number-head-cell.svelte"; export type { CalendarRootProps as RootProps, @@ -28,4 +30,6 @@ export type { CalendarDayProps as DayProps, CalendarMonthSelectProps as MonthSelectProps, CalendarYearSelectProps as YearSelectProps, + CalendarWeekNumberCellProps as WeekNumberCellProps, + CalendarWeekNumberHeadCellProps as WeekNumberHeadCellProps, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/calendar/types.ts b/packages/bits-ui/src/lib/bits/calendar/types.ts index e93a46e7e..0d9a4ce8f 100644 --- a/packages/bits-ui/src/lib/bits/calendar/types.ts +++ b/packages/bits-ui/src/lib/bits/calendar/types.ts @@ -138,6 +138,15 @@ type CalendarBaseRootPropsWithoutHTML = { */ fixedWeeks?: boolean; + /** + * Whether to display ISO 8601 week numbers in the calendar. When enabled, include + * `` in the grid head row and + * `` as the first child of each grid row. + * + * @default false + */ + showWeekNumbers?: boolean; + /** * Determines the number of months to display on the calendar simultaneously. * For navigation between months, refer to the `pagedNavigation` prop. @@ -425,3 +434,29 @@ export type CalendarYearSelectPropsWithoutHTML = WithChild< export type CalendarYearSelectProps = CalendarYearSelectPropsWithoutHTML & Without; + +export type CalendarWeekNumberCellSnippetProps = { + weekNumber: number; +}; + +export type CalendarWeekNumberCellPropsWithoutHTML = WithChild< + { + /** + * The dates for the week row. Pass the `weekDates` value from the + * `{#each month.weeks as weekDates}` loop. The ISO week number is + * derived from the first day of the array. + * + * @required + */ + week: DateValue[]; + }, + CalendarWeekNumberCellSnippetProps +>; + +export type CalendarWeekNumberCellProps = CalendarWeekNumberCellPropsWithoutHTML & + Without; + +export type CalendarWeekNumberHeadCellPropsWithoutHTML = WithChild; + +export type CalendarWeekNumberHeadCellProps = CalendarWeekNumberHeadCellPropsWithoutHTML & + Without; 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..894b6af12 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,6 +25,7 @@ ), calendarLabel: datePickerRootState.opts.calendarLabel, fixedWeeks: datePickerRootState.opts.fixedWeeks, + showWeekNumbers: datePickerRootState.opts.showWeekNumbers, isDateDisabled: datePickerRootState.opts.isDateDisabled, isDateUnavailable: datePickerRootState.opts.isDateUnavailable, locale: datePickerRootState.opts.locale, 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 630e460b6..50bc0c8d4 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 @@ -41,6 +41,7 @@ weekdayFormat = "narrow", isDateDisabled = () => false, fixedWeeks = false, + showWeekNumbers = false, numberOfMonths = 1, closeOnDateSelect = true, initialFocus = false, @@ -124,6 +125,7 @@ weekdayFormat: boxWith(() => weekdayFormat), isDateDisabled: boxWith(() => isDateDisabled), fixedWeeks: boxWith(() => fixedWeeks), + showWeekNumbers: boxWith(() => showWeekNumbers), numberOfMonths: boxWith(() => numberOfMonths), initialFocus: boxWith(() => initialFocus), onDateSelect: boxWith(() => onDateSelect), 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..3408a1ec1 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 @@ -30,6 +30,7 @@ interface DatePickerRootStateOpts weekStartsOn: WeekStartsOn | undefined; weekdayFormat: Intl.DateTimeFormatOptions["weekday"]; fixedWeeks: boolean; + showWeekNumbers: boolean; numberOfMonths: number; calendarLabel: string; disableDaysOutsideMonth: boolean; diff --git a/packages/bits-ui/src/lib/bits/date-picker/exports.ts b/packages/bits-ui/src/lib/bits/date-picker/exports.ts index 8a1b7330c..e45db5997 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/exports.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/exports.ts @@ -19,6 +19,8 @@ export { default as NextButton } from "$lib/bits/calendar/components/calendar-ne export { default as PrevButton } from "$lib/bits/calendar/components/calendar-prev-button.svelte"; export { default as MonthSelect } from "$lib/bits/calendar/components/calendar-month-select.svelte"; export { default as YearSelect } from "$lib/bits/calendar/components/calendar-year-select.svelte"; +export { default as WeekNumberCell } from "$lib/bits/calendar/components/calendar-week-number-cell.svelte"; +export { default as WeekNumberHeadCell } from "$lib/bits/calendar/components/calendar-week-number-head-cell.svelte"; export { default as Cell } from "$lib/bits/calendar/components/calendar-cell.svelte"; export { default as Day } from "$lib/bits/calendar/components/calendar-day.svelte"; export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; @@ -48,4 +50,6 @@ export type { DatePickerPortalProps as PortalProps, DatePickerMonthSelectProps as MonthSelectProps, DatePickerYearSelectProps as YearSelectProps, + DatePickerWeekNumberCellProps as WeekNumberCellProps, + DatePickerWeekNumberHeadCellProps as WeekNumberHeadCellProps, } from "./types.js"; 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..6c1a32264 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/types.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/types.ts @@ -213,6 +213,15 @@ export type DatePickerRootPropsWithoutHTML = WithChildren<{ */ fixedWeeks?: boolean; + /** + * Whether to display ISO 8601 week numbers in the calendar. When enabled, include + * `` in the grid head row and + * `` as the first child of each grid row. + * + * @defaultValue false + */ + showWeekNumbers?: boolean; + /** * Determines the number of months to display on the calendar simultaneously. * For navigation between months, refer to the `pagedNavigation` prop. @@ -342,4 +351,8 @@ export type { CalendarMonthSelectPropsWithoutHTML as DatePickerMonthSelectPropsWithoutHTML, CalendarYearSelectProps as DatePickerYearSelectProps, CalendarYearSelectPropsWithoutHTML as DatePickerYearSelectPropsWithoutHTML, + CalendarWeekNumberCellProps as DatePickerWeekNumberCellProps, + CalendarWeekNumberCellPropsWithoutHTML as DatePickerWeekNumberCellPropsWithoutHTML, + CalendarWeekNumberHeadCellProps as DatePickerWeekNumberHeadCellProps, + CalendarWeekNumberHeadCellPropsWithoutHTML as DatePickerWeekNumberHeadCellPropsWithoutHTML, } from "$lib/bits/calendar/types.js"; 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..d14c6c14b 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,6 +25,7 @@ ), calendarLabel: dateRangePickerRootState.opts.calendarLabel, fixedWeeks: dateRangePickerRootState.opts.fixedWeeks, + showWeekNumbers: dateRangePickerRootState.opts.showWeekNumbers, isDateDisabled: dateRangePickerRootState.opts.isDateDisabled, isDateUnavailable: dateRangePickerRootState.opts.isDateUnavailable, locale: dateRangePickerRootState.opts.locale, 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 cb7324aa9..4100c50a7 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 @@ -43,6 +43,7 @@ weekdayFormat = "narrow", isDateDisabled = () => false, fixedWeeks = false, + showWeekNumbers = false, numberOfMonths = 1, closeOnRangeSelect = true, onStartValueChange = noop, @@ -158,6 +159,7 @@ weekdayFormat: boxWith(() => weekdayFormat), isDateDisabled: boxWith(() => isDateDisabled), fixedWeeks: boxWith(() => fixedWeeks), + showWeekNumbers: boxWith(() => showWeekNumbers), numberOfMonths: boxWith(() => numberOfMonths), excludeDisabled: boxWith(() => excludeDisabled), onRangeSelect: boxWith(() => onRangeSelect), 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..a8e13b647 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 @@ -36,6 +36,7 @@ interface DateRangePickerRootStateOpts weekStartsOn: WeekStartsOn | undefined; weekdayFormat: Intl.DateTimeFormatOptions["weekday"]; fixedWeeks: boolean; + showWeekNumbers: boolean; numberOfMonths: number; calendarLabel: string; disableDaysOutsideMonth: boolean; diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/exports.ts b/packages/bits-ui/src/lib/bits/date-range-picker/exports.ts index a2bfe81d3..53962fe64 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/exports.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/exports.ts @@ -18,6 +18,8 @@ export { default as NextButton } from "$lib/bits/calendar/components/calendar-ne export { default as PrevButton } from "$lib/bits/calendar/components/calendar-prev-button.svelte"; export { default as MonthSelect } from "$lib/bits/calendar/components/calendar-month-select.svelte"; export { default as YearSelect } from "$lib/bits/calendar/components/calendar-year-select.svelte"; +export { default as WeekNumberCell } from "$lib/bits/calendar/components/calendar-week-number-cell.svelte"; +export { default as WeekNumberHeadCell } from "$lib/bits/calendar/components/calendar-week-number-head-cell.svelte"; export { default as Cell } from "$lib/bits/range-calendar/components/range-calendar-cell.svelte"; export { default as Day } from "$lib/bits/range-calendar/components/range-calendar-day.svelte"; @@ -44,4 +46,6 @@ export type { DateRangePickerPrevButtonProps as PrevButtonProps, DateRangePickerMonthSelectProps as MonthSelectProps, DateRangePickerYearSelectProps as YearSelectProps, + DateRangePickerWeekNumberCellProps as WeekNumberCellProps, + DateRangePickerWeekNumberHeadCellProps as WeekNumberHeadCellProps, } from "./types.js"; 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..3ab828f1b 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 @@ -213,6 +213,15 @@ export type DateRangePickerRootPropsWithoutHTML = WithChild<{ */ fixedWeeks?: boolean; + /** + * Whether to display ISO 8601 week numbers in the calendar. When enabled, include + * `` in the grid head row and + * `` as the first child of each grid row. + * + * @defaultValue false + */ + showWeekNumbers?: boolean; + /** * Determines the number of months to display on the calendar simultaneously. * For navigation between months, refer to the `pagedNavigation` prop. @@ -370,4 +379,8 @@ export type { RangeCalendarMonthSelectPropsWithoutHTML as DateRangePickerMonthSelectPropsWithoutHTML, RangeCalendarYearSelectProps as DateRangePickerYearSelectProps, RangeCalendarYearSelectPropsWithoutHTML as DateRangePickerYearSelectPropsWithoutHTML, + RangeCalendarWeekNumberCellProps as DateRangePickerWeekNumberCellProps, + RangeCalendarWeekNumberCellPropsWithoutHTML as DateRangePickerWeekNumberCellPropsWithoutHTML, + RangeCalendarWeekNumberHeadCellProps as DateRangePickerWeekNumberHeadCellProps, + RangeCalendarWeekNumberHeadCellPropsWithoutHTML as DateRangePickerWeekNumberHeadCellPropsWithoutHTML, } from "$lib/bits/range-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 3d7833435..25a13d319 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 @@ -26,6 +26,7 @@ isDateDisabled = () => false, isDateUnavailable = () => false, fixedWeeks = false, + showWeekNumbers = false, numberOfMonths = 1, locale, calendarLabel = "Event", @@ -118,6 +119,7 @@ locale: resolveLocaleProp(() => locale), calendarLabel: boxWith(() => calendarLabel), fixedWeeks: boxWith(() => fixedWeeks), + showWeekNumbers: boxWith(() => showWeekNumbers), disableDaysOutsideMonth: boxWith(() => disableDaysOutsideMonth), minDays: boxWith(() => minDays), maxDays: boxWith(() => maxDays), diff --git a/packages/bits-ui/src/lib/bits/range-calendar/exports.ts b/packages/bits-ui/src/lib/bits/range-calendar/exports.ts index 3a1d7a026..b39869283 100644 --- a/packages/bits-ui/src/lib/bits/range-calendar/exports.ts +++ b/packages/bits-ui/src/lib/bits/range-calendar/exports.ts @@ -12,6 +12,8 @@ export { default as NextButton } from "$lib/bits/calendar/components/calendar-ne export { default as PrevButton } from "$lib/bits/calendar/components/calendar-prev-button.svelte"; export { default as MonthSelect } from "$lib/bits/calendar/components/calendar-month-select.svelte"; export { default as YearSelect } from "$lib/bits/calendar/components/calendar-year-select.svelte"; +export { default as WeekNumberCell } from "$lib/bits/calendar/components/calendar-week-number-cell.svelte"; +export { default as WeekNumberHeadCell } from "$lib/bits/calendar/components/calendar-week-number-head-cell.svelte"; export type { RangeCalendarRootProps as RootProps, @@ -28,4 +30,6 @@ export type { RangeCalendarDayProps as DayProps, RangeCalendarMonthSelectProps as MonthSelectProps, RangeCalendarYearSelectProps as YearSelectProps, + RangeCalendarWeekNumberCellProps as WeekNumberCellProps, + RangeCalendarWeekNumberHeadCellProps as WeekNumberHeadCellProps, } from "./types.js"; 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..8cb5f75c7 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 @@ -78,6 +78,7 @@ interface RangeCalendarRootStateOpts locale: string; calendarLabel: string; readonly: boolean; + showWeekNumbers: boolean; disableDaysOutsideMonth: boolean; excludeDisabled: boolean; minDays: number | undefined; 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..efed0ff5e 100644 --- a/packages/bits-ui/src/lib/bits/range-calendar/types.ts +++ b/packages/bits-ui/src/lib/bits/range-calendar/types.ts @@ -153,6 +153,15 @@ export type RangeCalendarRootPropsWithoutHTML = WithChild< */ fixedWeeks?: boolean; + /** + * Whether to display ISO 8601 week numbers in the calendar. When enabled, include + * `` in the grid head row and + * `` as the first child of each grid row. + * + * @default false + */ + showWeekNumbers?: boolean; + /** * Determines the number of months to display on the calendar simultaneously. * For navigation between months, refer to the `pagedNavigation` prop. @@ -269,4 +278,8 @@ export type { CalendarMonthSelectPropsWithoutHTML as RangeCalendarMonthSelectPropsWithoutHTML, CalendarYearSelectProps as RangeCalendarYearSelectProps, CalendarYearSelectPropsWithoutHTML as RangeCalendarYearSelectPropsWithoutHTML, + CalendarWeekNumberCellProps as RangeCalendarWeekNumberCellProps, + CalendarWeekNumberCellPropsWithoutHTML as RangeCalendarWeekNumberCellPropsWithoutHTML, + CalendarWeekNumberHeadCellProps as RangeCalendarWeekNumberHeadCellProps, + CalendarWeekNumberHeadCellPropsWithoutHTML as RangeCalendarWeekNumberHeadCellPropsWithoutHTML, } from "../calendar/types.js"; 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 aaae746f5..9c4dc6aa8 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 @@ -742,7 +742,9 @@ export type CalendarParts = | "header" | "heading" | "month-select" - | "year-select"; + | "year-select" + | "week-number-cell" + | "week-number-head-cell"; export function pickerOpenFocus(e: Event) { const doc = getDocument(e.target as HTMLElement); @@ -853,6 +855,8 @@ export const calendarAttrs = createBitsAttrs({ "heading", "month-select", "year-select", + "week-number-cell", + "week-number-head-cell", ], }); diff --git a/packages/bits-ui/src/lib/internal/date-time/utils.test.ts b/packages/bits-ui/src/lib/internal/date-time/utils.test.ts new file mode 100644 index 000000000..601ad8932 --- /dev/null +++ b/packages/bits-ui/src/lib/internal/date-time/utils.test.ts @@ -0,0 +1,24 @@ +import { CalendarDate, parseZonedDateTime } from "@internationalized/date"; +import { describe, expect, it, vi } from "vitest"; +import { getISOWeekNumber } from "./utils.js"; + +describe("getISOWeekNumber", () => { + it("returns ISO week 1 for a known CalendarDate in early January", () => { + expect(getISOWeekNumber(new CalendarDate(2024, 1, 4))).toBe(1); + }); + + it("uses the zoned calendar day at year boundaries", () => { + const sydneyMidnight = parseZonedDateTime("2024-01-01T00:00[Australia/Sydney]"); + + // Jan 1, 2024 in Australia/Sydney is ISO week 1. + expect(getISOWeekNumber(sydneyMidnight)).toBe(1); + }); + + it("returns the same ISO week for CalendarDate in UTC+ local timezones", async () => { + const previousTz = process.env.TZ; + process.env.TZ = "Australia/Sydney"; + vi.resetModules(); + expect(getISOWeekNumber(new CalendarDate(2024, 1, 1))).toBe(1); + process.env.TZ = previousTz; + }); +}); diff --git a/packages/bits-ui/src/lib/internal/date-time/utils.ts b/packages/bits-ui/src/lib/internal/date-time/utils.ts index d1bb356ad..5703a994a 100644 --- a/packages/bits-ui/src/lib/internal/date-time/utils.ts +++ b/packages/bits-ui/src/lib/internal/date-time/utils.ts @@ -2,6 +2,7 @@ import { CalendarDate, CalendarDateTime, type DateValue, + GregorianCalendar, Time, ZonedDateTime, getDayOfWeek, @@ -127,6 +128,33 @@ export function toDate(dateValue: DateValue, tz: string = getLocalTimeZone()) { } } +function toGregorianUTCDate(dateValue: DateValue): Date { + const gregorianDate = toCalendar(dateValue, new GregorianCalendar()); + return new Date(Date.UTC(gregorianDate.year, gregorianDate.month - 1, gregorianDate.day)); +} + +export function getISOWeekNumber(dateValue: DateValue): number { + const d = toGregorianUTCDate(dateValue); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +export function getISOWeekNumberForWeek(week: DateValue[]): number { + let weekThursday: DateValue | undefined; + for (const date of week) { + if (toGregorianUTCDate(date).getUTCDay() === 4) { + weekThursday = date; + break; + } + } + + const anchorDate = weekThursday ?? week[0]; + if (!anchorDate) return 0; + return getISOWeekNumber(anchorDate); +} + export function getDateValueType(date: DateValue): string { if (date instanceof CalendarDate) return "date"; if (date instanceof CalendarDateTime) return "datetime"; diff --git a/tests/src/tests/calendar/calendar-test.svelte b/tests/src/tests/calendar/calendar-test.svelte index 95ed29916..e940af816 100644 --- a/tests/src/tests/calendar/calendar-test.svelte +++ b/tests/src/tests/calendar/calendar-test.svelte @@ -37,6 +37,9 @@ + {#each weekdays as day, i (day + i)} {day} @@ -47,6 +50,10 @@ {#each month.weeks as weekDates, i (i)} + {#each weekDates as date, d (d)} { }); }); + describe("Week Numbers", () => { + it("should not render week number cells when `showWeekNumbers` is false (default)", async () => { + setup({ placeholder: calendarDate }); + const cell = page.getByTestId("week-number-cell-1-0"); + const headCell = page.getByTestId("week-number-head-cell-1"); + await expect.element(cell).not.toBeInTheDocument(); + await expect.element(headCell).not.toBeInTheDocument(); + }); + + it("should render week number cells when `showWeekNumbers` is true", async () => { + setup({ placeholder: calendarDate, showWeekNumbers: true }); + // calendarDate is January 20, 1980. January 1980 has 5 weeks in the grid (no fixedWeeks). + // Check that head cell and at least the first body cell render. + const headCell = page.getByTestId("week-number-head-cell-1"); + await expect.element(headCell).toBeInTheDocument(); + const firstWeekCell = page.getByTestId("week-number-cell-1-0"); + await expect.element(firstWeekCell).toBeInTheDocument(); + }); + + it("should display the correct ISO 8601 week number", async () => { + // calendarDate = Jan 20, 1980 (Sunday). With weekStartsOn=0 (default), + // week numbers are anchored to each row's Thursday. + // Row 3 spans Jan 20-26, and its Thursday is Jan 24, 1980 => ISO week 4. + setup({ placeholder: calendarDate, showWeekNumbers: true }); + const weekCell = page.getByTestId("week-number-cell-1-3"); + await expect.element(weekCell).toHaveTextContent("04"); + }); + + it("should display correct ISO week numbers at year boundaries (Dec 30, 2024 → W01 of 2025)", async () => { + // With weekStartsOn=1 (Monday), December 2024 grid: + // Row 5: Dec 30 (Mon) → week[0]=Dec 30 → day=1 → +4-1=Jan 2, 2025 → yearStart=Jan1,2025 → W1 + const decDate = new CalendarDate(2024, 12, 1); + setup({ placeholder: decDate, showWeekNumbers: true, weekStartsOn: 1 }); + const lastWeekCell = page.getByTestId("week-number-cell-12-5"); + await expect.element(lastWeekCell).toHaveTextContent("01"); + }); + }); + describe("Availability and Interaction", () => { it("should handle unavailable dates appropriately", async () => { setup({ diff --git a/tests/src/tests/date-picker/date-picker-test.svelte b/tests/src/tests/date-picker/date-picker-test.svelte index e565a8326..c4fec0aa6 100644 --- a/tests/src/tests/date-picker/date-picker-test.svelte +++ b/tests/src/tests/date-picker/date-picker-test.svelte @@ -45,6 +45,9 @@ + {#each weekdays as day, i (i)} {day} @@ -58,6 +61,10 @@ data-testid="grid-row-{m}-{i}" data-week > + {#each weekDates as date, d (d)} { + it("should not render week number cells when `showWeekNumbers` is false (default)", async () => { + await open({ placeholder: testDate }); + const cell = page.getByTestId("week-number-cell-1-0"); + const headCell = page.getByTestId("week-number-head-cell-1"); + await expect.element(cell).not.toBeInTheDocument(); + await expect.element(headCell).not.toBeInTheDocument(); + }); + + it("should render week number cells when `showWeekNumbers` is true", async () => { + await open({ placeholder: testDate, showWeekNumbers: true }); + const headCell = page.getByTestId("week-number-head-cell-1"); + const firstWeekCell = page.getByTestId("week-number-cell-1-0"); + await expect.element(headCell).toBeInTheDocument(); + await expect.element(firstWeekCell).toBeInTheDocument(); + }); + + it("should anchor row week numbers to the row's Thursday", async () => { + await open({ placeholder: testDate, showWeekNumbers: true }); + const weekCell = page.getByTestId("week-number-cell-1-3"); + await expect.element(weekCell).toHaveTextContent("04"); + }); +}); + it("should not allow navigation before the `minValue` (prev button)", async () => { await open({ value: testDate, diff --git a/tests/src/tests/date-range-picker/date-range-picker-test.svelte b/tests/src/tests/date-range-picker/date-range-picker-test.svelte index 9e6d7b709..da1865963 100644 --- a/tests/src/tests/date-range-picker/date-range-picker-test.svelte +++ b/tests/src/tests/date-range-picker/date-range-picker-test.svelte @@ -72,6 +72,9 @@ + {#each weekdays as day, i (i)} {day} @@ -85,6 +88,10 @@ data-testid="grid-row-{m}-{i}" data-week > + {#each weekDates as date, d (d)} { + it("should not render week number cells when `showWeekNumbers` is false (default)", async () => { + await open({ placeholder: calendarDateRange.start }); + const cell = page.getByTestId("week-number-cell-1-0"); + const headCell = page.getByTestId("week-number-head-cell-1"); + await expect.element(cell).not.toBeInTheDocument(); + await expect.element(headCell).not.toBeInTheDocument(); + }); + + it("should render week number cells when `showWeekNumbers` is true", async () => { + await open({ placeholder: calendarDateRange.start, showWeekNumbers: true }); + const headCell = page.getByTestId("week-number-head-cell-1"); + const firstWeekCell = page.getByTestId("week-number-cell-1-0"); + await expect.element(headCell).toBeInTheDocument(); + await expect.element(firstWeekCell).toBeInTheDocument(); + }); + + it("should anchor row week numbers to the row's Thursday", async () => { + await open({ placeholder: calendarDateRange.start, showWeekNumbers: true }); + const weekCell = page.getByTestId("week-number-cell-1-3"); + await expect.element(weekCell).toHaveTextContent("04"); + }); +}); + it("should not allow navigation before the `minValue` (prev button)", async () => { await open({ value: calendarDateRange, diff --git a/tests/src/tests/range-calendar/range-calendar-test.svelte b/tests/src/tests/range-calendar/range-calendar-test.svelte index ec4180b35..96a962c27 100644 --- a/tests/src/tests/range-calendar/range-calendar-test.svelte +++ b/tests/src/tests/range-calendar/range-calendar-test.svelte @@ -32,6 +32,9 @@ + {#each weekdays as day, i (i)} {day} @@ -42,6 +45,10 @@ {#each month.weeks as weekDates, i (i)} + {#each weekDates as date, d (d)} { + it("should not render week number cells when `showWeekNumbers` is false (default)", async () => { + setup({ placeholder: calendarDateRange.start }); + const cell = page.getByTestId("week-number-cell-1-0"); + const headCell = page.getByTestId("week-number-head-cell-1"); + await expect.element(cell).not.toBeInTheDocument(); + await expect.element(headCell).not.toBeInTheDocument(); + }); + + it("should render week number cells when `showWeekNumbers` is true", async () => { + setup({ placeholder: calendarDateRange.start, showWeekNumbers: true }); + const headCell = page.getByTestId("week-number-head-cell-1"); + const firstWeekCell = page.getByTestId("week-number-cell-1-0"); + await expect.element(headCell).toBeInTheDocument(); + await expect.element(firstWeekCell).toBeInTheDocument(); + }); + + it("should anchor row week numbers to the row's Thursday", async () => { + setup({ placeholder: calendarDateRange.start, showWeekNumbers: true }); + const weekCell = page.getByTestId("week-number-cell-1-3"); + await expect.element(weekCell).toHaveTextContent("04"); + }); + + it("should display correct ISO week numbers at year boundaries", async () => { + const decDate = new CalendarDate(2024, 12, 1); + setup({ placeholder: decDate, showWeekNumbers: true, weekStartsOn: 1 }); + const lastWeekCell = page.getByTestId("week-number-cell-12-5"); + await expect.element(lastWeekCell).toHaveTextContent("01"); + }); +}); + describe("minDays and maxDays constraints", () => { it("should reset range when selection violates minDays constraint", async () => { setup({