From 5d5531ceec19075b76270666272366208e01478c Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Fri, 17 Apr 2026 17:49:20 -0500 Subject: [PATCH 1/7] feat(Calendar): add showWeekNumbers prop with WeekNumberCell and WeekNumberHeadCell primitives Adds ISO 8601 week number display support to Calendar and RangeCalendar. Users opt in by setting showWeekNumbers on Root and placing the new WeekNumberCell/WeekNumberHeadCell primitives in their template. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/bits/calendar/calendar.svelte.ts | 80 ++++++++++++++++++- .../calendar-week-number-cell.svelte | 45 +++++++++++ .../calendar-week-number-head-cell.svelte | 39 +++++++++ .../bits/calendar/components/calendar.svelte | 2 + .../bits-ui/src/lib/bits/calendar/exports.ts | 4 + .../bits-ui/src/lib/bits/calendar/types.ts | 35 ++++++++ .../components/date-picker-calendar.svelte | 1 + .../date-range-picker-calendar.svelte | 1 + .../components/range-calendar.svelte | 2 + .../src/lib/bits/range-calendar/exports.ts | 4 + .../range-calendar/range-calendar.svelte.ts | 1 + .../src/lib/bits/range-calendar/types.ts | 13 +++ .../date-time/calendar-helpers.svelte.ts | 6 +- .../src/lib/internal/date-time/utils.ts | 8 ++ 14 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-cell.svelte create mode 100644 packages/bits-ui/src/lib/bits/calendar/components/calendar-week-number-head-cell.svelte 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..cf3fda32f 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,7 @@ 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, getISOWeekNumber, isBefore, toDate } from "$lib/internal/date-time/utils.js"; import type { WeekStartsOn } from "$lib/shared/date/types.js"; interface CalendarRootStateOpts @@ -69,6 +69,7 @@ interface CalendarRootStateOpts locale: string; calendarLabel: string; type: "single" | "multiple"; + showWeekNumbers: boolean; readonly: boolean; disableDaysOutsideMonth: boolean; initialFocus: boolean; @@ -1173,3 +1174,80 @@ 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(() => { + const firstDay = this.opts.week.current[0]; + if (!firstDay) return 0; + return getISOWeekNumber(firstDay); + }); + + 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..72c4d5838 --- /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} + {cellState.weekNumber} + {/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..efb9f71f4 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: boxWith(() => false), isDateDisabled: datePickerRootState.opts.isDateDisabled, isDateUnavailable: datePickerRootState.opts.isDateUnavailable, locale: datePickerRootState.opts.locale, 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..54e2cb142 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: boxWith(() => false), isDateDisabled: dateRangePickerRootState.opts.isDateDisabled, isDateUnavailable: dateRangePickerRootState.opts.isDateUnavailable, locale: dateRangePickerRootState.opts.locale, 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.ts b/packages/bits-ui/src/lib/internal/date-time/utils.ts index d1bb356ad..9226c2548 100644 --- a/packages/bits-ui/src/lib/internal/date-time/utils.ts +++ b/packages/bits-ui/src/lib/internal/date-time/utils.ts @@ -127,6 +127,14 @@ export function toDate(dateValue: DateValue, tz: string = getLocalTimeZone()) { } } +export function getISOWeekNumber(dateValue: DateValue): number { + const d = toDate(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 getDateValueType(date: DateValue): string { if (date instanceof CalendarDate) return "date"; if (date instanceof CalendarDateTime) return "datetime"; From c1dd76c45b2a456409b90b34ab01e5120f6b87d3 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Fri, 17 Apr 2026 19:14:04 -0500 Subject: [PATCH 2/7] docs(Calendar): add showWeekNumbers, WeekNumberCell, and WeekNumberHeadCell documentation Co-Authored-By: Claude Sonnet 4.6 --- docs/content/components/calendar.md | 50 +++++++++++++ .../lib/content/api-reference/calendar.api.ts | 71 +++++++++++++++++++ .../api-reference/range-calendar.api.ts | 7 ++ 3 files changed, 128 insertions(+) diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index a263d097c..3613f1edf 100644 --- a/docs/content/components/calendar.md +++ b/docs/content/components/calendar.md @@ -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,52 @@ 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} + +``` + ### Multiple Months You can use the `numberOfMonths` prop to render multiple months at once. 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/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, ]; From 9474d3318cfb1a66cbb6aa5acae3fa402da94989 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Fri, 17 Apr 2026 19:17:16 -0500 Subject: [PATCH 3/7] docs(Calendar): add week numbers demo previews for Calendar and RangeCalendar Co-Authored-By: Claude Sonnet 4.6 --- docs/content/components/calendar.md | 10 ++- docs/content/components/range-calendar.md | 20 ++++- .../demos/calendar-demo-week-numbers.svelte | 81 ++++++++++++++++++ docs/src/lib/components/demos/index.ts | 2 + .../range-calendar-demo-week-numbers.svelte | 83 +++++++++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 docs/src/lib/components/demos/calendar-demo-week-numbers.svelte create mode 100644 docs/src/lib/components/demos/range-calendar-demo-week-numbers.svelte diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index 3613f1edf..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 --- @@ -357,6 +357,14 @@ The week number is computed using the ISO 8601 standard (week 1 is the week cont ``` + + +{#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} +
From 47920c2fb5acf86d7ae181bc4db578640028ca61 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Fri, 17 Apr 2026 20:12:07 -0500 Subject: [PATCH 4/7] test(calendar): add week number browser tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify showWeekNumbers=false hides cells, showWeekNumbers=true renders them, ISO week numbers are computed correctly (Jan 20→W3), and year boundaries work (Dec 30, 2024→W01 of 2025 with weekStartsOn=1). Co-Authored-By: Claude Sonnet 4.6 --- tests/src/tests/calendar/calendar-test.svelte | 7 ++++ .../tests/calendar/calendar.browser.test.ts | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+) 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): + // Row 0: Dec 30 (Sun) → week[0]=Dec 30 → Thursday=Jan 3, 1980 → W1 + // Row 1: Jan 6 (Sun) → week[0]=Jan 6 → Thursday=Jan 10 → W2 + // Row 2: Jan 13 (Sun) → week[0]=Jan 13 → Thursday=Jan 17 → W3 + // Row 3: Jan 20 (Sun) → week[0]=Jan 20 → Thursday=Jan 17 → W3 + // (getISOWeekNumber uses week[0]; Jan 20 → day=7 → +4-7=Jan 17 → W3) + setup({ placeholder: calendarDate, showWeekNumbers: true }); + const weekCell = page.getByTestId("week-number-cell-1-3"); + await expect.element(weekCell).toHaveTextContent("3"); + }); + + 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("1"); + }); + }); + describe("Availability and Interaction", () => { it("should handle unavailable dates appropriately", async () => { setup({ From 04c03e0eee2f912e71fcd81b2129a425d5abb456 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Sat, 18 Apr 2026 21:02:56 -0500 Subject: [PATCH 5/7] add leading zero --- .../bits/calendar/components/calendar-week-number-cell.svelte | 2 +- tests/src/tests/calendar/calendar.browser.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 72c4d5838..cdae9d738 100644 --- 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 @@ -38,7 +38,7 @@ {#if children} {@render children(cellState.snippetProps)} {:else} - {cellState.weekNumber} + {String(cellState.weekNumber).padStart(2, "0")} {/if} {/if} diff --git a/tests/src/tests/calendar/calendar.browser.test.ts b/tests/src/tests/calendar/calendar.browser.test.ts index 6f1d5011c..309de12c5 100644 --- a/tests/src/tests/calendar/calendar.browser.test.ts +++ b/tests/src/tests/calendar/calendar.browser.test.ts @@ -520,7 +520,7 @@ describe("type='single'", () => { // (getISOWeekNumber uses week[0]; Jan 20 → day=7 → +4-7=Jan 17 → W3) setup({ placeholder: calendarDate, showWeekNumbers: true }); const weekCell = page.getByTestId("week-number-cell-1-3"); - await expect.element(weekCell).toHaveTextContent("3"); + await expect.element(weekCell).toHaveTextContent("03"); }); it("should display correct ISO week numbers at year boundaries (Dec 30, 2024 → W01 of 2025)", async () => { @@ -529,7 +529,7 @@ describe("type='single'", () => { 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("1"); + await expect.element(lastWeekCell).toHaveTextContent("01"); }); }); From abd3b1705d7e532a2ef6b3224702bb455157c529 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Apr 2026 10:01:00 -0400 Subject: [PATCH 6/7] improve: showWeekNumbers and Thursday-anchored ISO week numbers --- .../src/lib/bits/calendar/calendar.svelte.ts | 11 ++++--- .../components/date-picker-calendar.svelte | 2 +- .../date-picker/components/date-picker.svelte | 2 ++ .../bits/date-picker/date-picker.svelte.ts | 1 + .../src/lib/bits/date-picker/exports.ts | 4 +++ .../bits-ui/src/lib/bits/date-picker/types.ts | 13 ++++++++ .../date-range-picker-calendar.svelte | 2 +- .../components/date-range-picker.svelte | 2 ++ .../date-range-picker.svelte.ts | 1 + .../src/lib/bits/date-range-picker/exports.ts | 4 +++ .../src/lib/bits/date-range-picker/types.ts | 13 ++++++++ .../src/lib/internal/date-time/utils.test.ts | 24 ++++++++++++++ .../src/lib/internal/date-time/utils.ts | 22 ++++++++++++- .../tests/calendar/calendar.browser.test.ts | 11 +++---- .../tests/date-picker/date-picker-test.svelte | 7 +++++ .../date-picker/date-picker.browser.test.ts | 24 ++++++++++++++ .../date-range-picker-test.svelte | 7 +++++ .../date-range-picker.browser.test.ts | 24 ++++++++++++++ .../range-calendar/range-calendar-test.svelte | 7 +++++ .../range-calendar.browser.test.ts | 31 +++++++++++++++++++ 20 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 packages/bits-ui/src/lib/internal/date-time/utils.test.ts 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 cf3fda32f..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, getISOWeekNumber, 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 @@ -1200,9 +1205,7 @@ export class CalendarWeekNumberCellState { } readonly weekNumber = $derived.by(() => { - const firstDay = this.opts.week.current[0]; - if (!firstDay) return 0; - return getISOWeekNumber(firstDay); + return getISOWeekNumberForWeek(this.opts.week.current); }); readonly snippetProps = $derived.by(() => ({ weekNumber: this.weekNumber })); 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 efb9f71f4..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,7 +25,7 @@ ), calendarLabel: datePickerRootState.opts.calendarLabel, fixedWeeks: datePickerRootState.opts.fixedWeeks, - showWeekNumbers: boxWith(() => false), + 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 54e2cb142..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,7 +25,7 @@ ), calendarLabel: dateRangePickerRootState.opts.calendarLabel, fixedWeeks: dateRangePickerRootState.opts.fixedWeeks, - showWeekNumbers: boxWith(() => false), + 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/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 9226c2548..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,14 +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 = toDate(dateValue); + 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.browser.test.ts b/tests/src/tests/calendar/calendar.browser.test.ts index 309de12c5..e9078cab7 100644 --- a/tests/src/tests/calendar/calendar.browser.test.ts +++ b/tests/src/tests/calendar/calendar.browser.test.ts @@ -512,15 +512,12 @@ describe("type='single'", () => { }); it("should display the correct ISO 8601 week number", async () => { - // calendarDate = Jan 20, 1980 (Sunday). With weekStartsOn=0 (default): - // Row 0: Dec 30 (Sun) → week[0]=Dec 30 → Thursday=Jan 3, 1980 → W1 - // Row 1: Jan 6 (Sun) → week[0]=Jan 6 → Thursday=Jan 10 → W2 - // Row 2: Jan 13 (Sun) → week[0]=Jan 13 → Thursday=Jan 17 → W3 - // Row 3: Jan 20 (Sun) → week[0]=Jan 20 → Thursday=Jan 17 → W3 - // (getISOWeekNumber uses week[0]; Jan 20 → day=7 → +4-7=Jan 17 → W3) + // 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("03"); + await expect.element(weekCell).toHaveTextContent("04"); }); it("should display correct ISO week numbers at year boundaries (Dec 30, 2024 → W01 of 2025)", async () => { 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({ From e1de45c5059812683079f6f63a01601bd16a94c3 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Sat, 9 May 2026 13:42:48 -0500 Subject: [PATCH 7/7] fix(docs): add missing showWeekNumbers prop to DatePicker and DateRangePicker API schemas Co-Authored-By: Claude Sonnet 4.6 --- docs/src/lib/content/api-reference/date-picker.api.ts | 1 + docs/src/lib/content/api-reference/date-range-picker.api.ts | 1 + 2 files changed, 2 insertions(+) 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: [