From d5d908ae95ee267129021fff3950f29ba22e595e Mon Sep 17 00:00:00 2001 From: Rashmi Basavaraj <222939087+rashmi09-sys@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:57:35 +0000 Subject: [PATCH] fix(calendar): merge consumer classNames and formatters with defaults Calendar built its classNames from its own default keys only, so a class for any DayPicker part without a coss default (root, weekdays, selected, month_grid, ...) was silently dropped. Passing any formatters also replaced the default formatMonthDropdown, so the month dropdown switched from short to long names. Start the classNames merge from the consumer's classes and spread consumer formatters over the default. Parts coss styles merge exactly as before; a key set to undefined counts as not provided. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011axzxSXXTqZnzgb8feHHdD --- apps/ui/public/r/calendar.json | 2 +- apps/ui/registry/default/ui/calendar.tsx | 23 ++- packages/ui/src/components/calendar.tsx | 23 ++- packages/ui/test/components/calendar.test.tsx | 164 ++++++++++++++++++ 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 packages/ui/test/components/calendar.test.tsx diff --git a/apps/ui/public/r/calendar.json b/apps/ui/public/r/calendar.json index 955a66ce0..8880e5ef5 100644 --- a/apps/ui/public/r/calendar.json +++ b/apps/ui/public/r/calendar.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/default/ui/calendar.tsx", - "content": "\"use client\";\n\nimport { type DateLib, DayPicker } from \"@daypicker/react\";\nimport {\n ChevronLeftIcon,\n ChevronRightIcon,\n ChevronsUpDownIcon,\n} from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nconst buttonClassNames =\n \"relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\";\n\nexport function Calendar({\n className,\n classNames,\n showOutsideDays = true,\n components: userComponents,\n mode = \"single\",\n ...props\n}: React.ComponentProps): React.ReactElement {\n const defaultClassNames = {\n button_next: buttonClassNames,\n button_previous: buttonClassNames,\n caption_label:\n \"text-base sm:text-sm font-medium flex items-center gap-2 h-full\",\n day: \"size-(--cell-size) text-sm py-px\",\n day_button: cn(\n buttonClassNames,\n \"in-data-disabled:pointer-events-none in-[.range-middle]:rounded-none in-[.range-end:not(.range-start)]:rounded-s-none in-[.range-start:not(.range-end)]:rounded-e-none in-[.range-middle]:in-data-selected:bg-accent in-data-selected:bg-primary in-[.range-middle]:in-data-selected:text-foreground in-data-disabled:text-muted-foreground/72 in-data-outside:text-muted-foreground/72 in-data-selected:in-data-outside:text-primary-foreground in-data-selected:text-primary-foreground in-data-disabled:line-through outline-none in-[[data-selected]:not(.range-middle)]:transition-[border-radius,box-shadow] focus-visible:z-1 focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n ),\n dropdown: \"absolute bg-popover inset-0 opacity-0\",\n dropdown_root:\n \"relative has-focus:border-ring has-focus:ring-ring/50 has-focus:ring-[3px] border border-input shadow-xs/5 rounded-lg px-[calc(--spacing(3)-1px)] h-9 sm:h-8 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-me-1\",\n dropdowns:\n \"w-full flex items-center text-base sm:text-sm justify-center h-(--cell-size) gap-1.5 *:[span]:font-medium\",\n hidden: \"invisible\",\n month: \"w-full\",\n month_caption:\n \"relative mx-(--cell-size) px-1 mb-1 flex h-(--cell-size) items-center justify-center z-2\",\n months: \"relative flex flex-col sm:flex-row gap-2\",\n nav: \"absolute top-0 flex w-full justify-between z-1\",\n outside:\n \"text-muted-foreground data-selected:bg-accent/50 data-selected:text-muted-foreground\",\n range_end: \"range-end\",\n range_middle: \"range-middle\",\n range_start: \"range-start\",\n today:\n \"*:after:pointer-events-none *:after:absolute *:after:bottom-1 *:after:start-1/2 *:after:z-1 *:after:size-[3px] *:after:-translate-x-1/2 *:after:rounded-full *:after:bg-primary [&[data-selected]:not(.range-middle)>*]:after:bg-background [&[data-disabled]>*]:after:bg-foreground/30\",\n week_number:\n \"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72\",\n weekday:\n \"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72\",\n };\n const mergedClassNames: typeof defaultClassNames = Object.keys(\n defaultClassNames,\n ).reduce(\n (acc, key) => {\n const userClass = classNames?.[key as keyof typeof classNames];\n const baseClass =\n defaultClassNames[key as keyof typeof defaultClassNames];\n\n acc[key as keyof typeof defaultClassNames] = userClass\n ? cn(baseClass, userClass)\n : baseClass;\n\n return acc;\n },\n { ...defaultClassNames } as typeof defaultClassNames,\n );\n\n const defaultComponents = {\n Chevron: ({\n className,\n orientation,\n ...props\n }: {\n className?: string;\n orientation?: \"left\" | \"right\" | \"up\" | \"down\";\n }): React.ReactElement => {\n if (orientation === \"left\") {\n return (\n \n );\n }\n\n if (orientation === \"right\") {\n return (\n \n );\n }\n\n return (\n \n );\n },\n };\n\n const mergedComponents = {\n ...defaultComponents,\n ...userComponents,\n };\n\n const dayPickerProps = {\n className: cn(\n \"w-fit [--cell-size:--spacing(10)] sm:[--cell-size:--spacing(9)]\",\n className,\n ),\n classNames: mergedClassNames,\n components: mergedComponents,\n \"data-slot\": \"calendar\",\n formatters: {\n formatMonthDropdown: (month: Date, dateLib: DateLib) =>\n dateLib.format(month, \"LLL\"),\n } as React.ComponentProps[\"formatters\"],\n mode,\n showOutsideDays,\n ...props,\n };\n\n return (\n )}\n />\n );\n}\n", + "content": "\"use client\";\n\nimport { type DateLib, DayPicker } from \"@daypicker/react\";\nimport {\n ChevronLeftIcon,\n ChevronRightIcon,\n ChevronsUpDownIcon,\n} from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nconst buttonClassNames =\n \"relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\";\n\ntype CalendarClassNames = NonNullable<\n React.ComponentProps[\"classNames\"]\n>;\ntype CalendarFormatters = NonNullable<\n React.ComponentProps[\"formatters\"]\n>;\n\n// A key set to `undefined` counts as not provided, so it never clears a default.\nfunction withoutUndefined(value: T | undefined): Partial {\n return Object.fromEntries(\n Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined),\n ) as Partial;\n}\n\nexport function Calendar({\n className,\n classNames,\n showOutsideDays = true,\n components: userComponents,\n formatters: userFormatters,\n mode = \"single\",\n ...props\n}: React.ComponentProps): React.ReactElement {\n const defaultClassNames = {\n button_next: buttonClassNames,\n button_previous: buttonClassNames,\n caption_label:\n \"text-base sm:text-sm font-medium flex items-center gap-2 h-full\",\n day: \"size-(--cell-size) text-sm py-px\",\n day_button: cn(\n buttonClassNames,\n \"in-data-disabled:pointer-events-none in-[.range-middle]:rounded-none in-[.range-end:not(.range-start)]:rounded-s-none in-[.range-start:not(.range-end)]:rounded-e-none in-[.range-middle]:in-data-selected:bg-accent in-data-selected:bg-primary in-[.range-middle]:in-data-selected:text-foreground in-data-disabled:text-muted-foreground/72 in-data-outside:text-muted-foreground/72 in-data-selected:in-data-outside:text-primary-foreground in-data-selected:text-primary-foreground in-data-disabled:line-through outline-none in-[[data-selected]:not(.range-middle)]:transition-[border-radius,box-shadow] focus-visible:z-1 focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n ),\n dropdown: \"absolute bg-popover inset-0 opacity-0\",\n dropdown_root:\n \"relative has-focus:border-ring has-focus:ring-ring/50 has-focus:ring-[3px] border border-input shadow-xs/5 rounded-lg px-[calc(--spacing(3)-1px)] h-9 sm:h-8 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-me-1\",\n dropdowns:\n \"w-full flex items-center text-base sm:text-sm justify-center h-(--cell-size) gap-1.5 *:[span]:font-medium\",\n hidden: \"invisible\",\n month: \"w-full\",\n month_caption:\n \"relative mx-(--cell-size) px-1 mb-1 flex h-(--cell-size) items-center justify-center z-2\",\n months: \"relative flex flex-col sm:flex-row gap-2\",\n nav: \"absolute top-0 flex w-full justify-between z-1\",\n outside:\n \"text-muted-foreground data-selected:bg-accent/50 data-selected:text-muted-foreground\",\n range_end: \"range-end\",\n range_middle: \"range-middle\",\n range_start: \"range-start\",\n today:\n \"*:after:pointer-events-none *:after:absolute *:after:bottom-1 *:after:start-1/2 *:after:z-1 *:after:size-[3px] *:after:-translate-x-1/2 *:after:rounded-full *:after:bg-primary [&[data-selected]:not(.range-middle)>*]:after:bg-background [&[data-disabled]>*]:after:bg-foreground/30\",\n week_number:\n \"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72\",\n weekday:\n \"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72\",\n };\n const mergedClassNames: CalendarClassNames = Object.keys(\n defaultClassNames,\n ).reduce(\n (acc, key) => {\n const userClass = classNames?.[key as keyof typeof classNames];\n const baseClass =\n defaultClassNames[key as keyof typeof defaultClassNames];\n\n acc[key as keyof typeof defaultClassNames] = userClass\n ? cn(baseClass, userClass)\n : baseClass;\n\n return acc;\n },\n // Parts without a coss default keep the consumer's classes as passed.\n withoutUndefined(classNames),\n );\n\n const defaultComponents = {\n Chevron: ({\n className,\n orientation,\n ...props\n }: {\n className?: string;\n orientation?: \"left\" | \"right\" | \"up\" | \"down\";\n }): React.ReactElement => {\n if (orientation === \"left\") {\n return (\n \n );\n }\n\n if (orientation === \"right\") {\n return (\n \n );\n }\n\n return (\n \n );\n },\n };\n\n const mergedComponents = {\n ...defaultComponents,\n ...userComponents,\n };\n\n const dayPickerProps = {\n className: cn(\n \"w-fit [--cell-size:--spacing(10)] sm:[--cell-size:--spacing(9)]\",\n className,\n ),\n classNames: mergedClassNames,\n components: mergedComponents,\n \"data-slot\": \"calendar\",\n formatters: {\n formatMonthDropdown: (month: Date, dateLib: DateLib) =>\n dateLib.format(month, \"LLL\"),\n ...withoutUndefined(userFormatters),\n } as CalendarFormatters,\n mode,\n showOutsideDays,\n ...props,\n };\n\n return (\n )}\n />\n );\n}\n", "type": "registry:ui" } ], diff --git a/apps/ui/registry/default/ui/calendar.tsx b/apps/ui/registry/default/ui/calendar.tsx index a255edd45..67d16c3a9 100644 --- a/apps/ui/registry/default/ui/calendar.tsx +++ b/apps/ui/registry/default/ui/calendar.tsx @@ -12,11 +12,26 @@ import { cn } from "@/registry/default/lib/utils"; const buttonClassNames = "relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"; +type CalendarClassNames = NonNullable< + React.ComponentProps["classNames"] +>; +type CalendarFormatters = NonNullable< + React.ComponentProps["formatters"] +>; + +// A key set to `undefined` counts as not provided, so it never clears a default. +function withoutUndefined(value: T | undefined): Partial { + return Object.fromEntries( + Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined), + ) as Partial; +} + export function Calendar({ className, classNames, showOutsideDays = true, components: userComponents, + formatters: userFormatters, mode = "single", ...props }: React.ComponentProps): React.ReactElement { @@ -53,7 +68,7 @@ export function Calendar({ weekday: "size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72", }; - const mergedClassNames: typeof defaultClassNames = Object.keys( + const mergedClassNames: CalendarClassNames = Object.keys( defaultClassNames, ).reduce( (acc, key) => { @@ -67,7 +82,8 @@ export function Calendar({ return acc; }, - { ...defaultClassNames } as typeof defaultClassNames, + // Parts without a coss default keep the consumer's classes as passed. + withoutUndefined(classNames), ); const defaultComponents = { @@ -125,7 +141,8 @@ export function Calendar({ formatters: { formatMonthDropdown: (month: Date, dateLib: DateLib) => dateLib.format(month, "LLL"), - } as React.ComponentProps["formatters"], + ...withoutUndefined(userFormatters), + } as CalendarFormatters, mode, showOutsideDays, ...props, diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx index 4678a53a6..26ac3b38b 100644 --- a/packages/ui/src/components/calendar.tsx +++ b/packages/ui/src/components/calendar.tsx @@ -12,11 +12,26 @@ import type * as React from "react"; const buttonClassNames = "relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"; +type CalendarClassNames = NonNullable< + React.ComponentProps["classNames"] +>; +type CalendarFormatters = NonNullable< + React.ComponentProps["formatters"] +>; + +// A key set to `undefined` counts as not provided, so it never clears a default. +function withoutUndefined(value: T | undefined): Partial { + return Object.fromEntries( + Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined), + ) as Partial; +} + export function Calendar({ className, classNames, showOutsideDays = true, components: userComponents, + formatters: userFormatters, mode = "single", ...props }: React.ComponentProps): React.ReactElement { @@ -53,7 +68,7 @@ export function Calendar({ weekday: "size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72", }; - const mergedClassNames: typeof defaultClassNames = Object.keys( + const mergedClassNames: CalendarClassNames = Object.keys( defaultClassNames, ).reduce( (acc, key) => { @@ -67,7 +82,8 @@ export function Calendar({ return acc; }, - { ...defaultClassNames } as typeof defaultClassNames, + // Parts without a coss default keep the consumer's classes as passed. + withoutUndefined(classNames), ); const defaultComponents = { @@ -125,7 +141,8 @@ export function Calendar({ formatters: { formatMonthDropdown: (month: Date, dateLib: DateLib) => dateLib.format(month, "LLL"), - } as React.ComponentProps["formatters"], + ...withoutUndefined(userFormatters), + } as CalendarFormatters, mode, showOutsideDays, ...props, diff --git a/packages/ui/test/components/calendar.test.tsx b/packages/ui/test/components/calendar.test.tsx new file mode 100644 index 000000000..86b59259e --- /dev/null +++ b/packages/ui/test/components/calendar.test.tsx @@ -0,0 +1,164 @@ +import { describe, expect, mock, test } from "bun:test"; +import { type DayPicker, defaultDateLib } from "@daypicker/react"; +import { type ClassValue, clsx } from "clsx"; +import type * as React from "react"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { twMerge } from "tailwind-merge"; + +// Other suites mock `cn` as a plain join, and Bun module mocks persist across files. +// Restore the real implementation (clsx + tailwind-merge) for Calendar's class merging. +mock.module("@coss/ui/lib/utils", () => ({ + cn: (...inputs: ClassValue[]) => twMerge(clsx(inputs)), +})); + +const { Calendar } = await import("../../src/components/calendar"); + +type DayPickerProps = React.ComponentProps; +type CalendarProps = Partial; + +const month = new Date(2026, 9, 1); + +// The props Calendar hands to DayPicker. +function dayPickerProps(props: CalendarProps = {}): DayPickerProps { + const element = Calendar(props as DayPickerProps); + return element.props as DayPickerProps; +} + +function render(props: CalendarProps): string { + return renderToStaticMarkup( + createElement(Calendar, { month, ...props } as DayPickerProps), + ); +} + +function monthOptions(html: string): string[] { + return [...html.matchAll(/]*>([^<]*)<\/option>/g)].map( + (match) => match[1] ?? "", + ); +} + +const dropdownCaption = { + captionLayout: "dropdown", + endMonth: new Date(2026, 11, 1), + startMonth: new Date(2026, 0, 1), +} as const; + +describe("Calendar classNames", () => { + test("keeps classes for parts that have no coss default", () => { + const classNames = { + month_grid: "custom-grid", + root: "custom-root", + selected: "custom-selected", + weekdays: "custom-weekdays", + }; + const merged = dayPickerProps({ classNames }).classNames; + + expect(merged?.month_grid).toBe("custom-grid"); + expect(merged?.root).toBe("custom-root"); + expect(merged?.selected).toBe("custom-selected"); + expect(merged?.weekdays).toBe("custom-weekdays"); + + const html = render({ + classNames, + mode: "single", + selected: new Date(2026, 9, 14), + }); + for (const className of Object.values(classNames)) { + expect(html).toContain(className); + } + }); + + test("merges custom classes with the coss defaults of styled parts", () => { + const merged = dayPickerProps({ + classNames: { weekday: "custom-weekday" }, + }).classNames; + + expect(merged?.weekday).toContain("custom-weekday"); + expect(merged?.weekday).toContain("text-muted-foreground/72"); + }); + + test("lets a conflicting utility override the default one", () => { + const merged = dayPickerProps({ + classNames: { day: "text-lg" }, + }).classNames; + + expect(merged?.day).toContain("text-lg"); + expect(merged?.day).not.toContain("text-sm"); + expect(merged?.day).toContain("size-(--cell-size)"); + }); + + test("uses the defaults when classNames is undefined, empty, or has undefined values", () => { + const defaults = dayPickerProps().classNames; + + expect(dayPickerProps({ classNames: {} }).classNames).toEqual(defaults); + expect( + dayPickerProps({ classNames: { weekday: undefined } }).classNames, + ).toEqual(defaults); + // An undefined value for an unstyled part must not clear DayPicker's own class. + expect( + dayPickerProps({ classNames: { weekdays: undefined } }).classNames, + ).not.toHaveProperty("weekdays"); + }); + + test("keeps custom classes in range mode", () => { + const html = render({ + classNames: { + range_middle: "custom-range-middle", + selected: "custom-selected", + }, + mode: "range", + selected: { from: new Date(2026, 9, 12), to: new Date(2026, 9, 16) }, + }); + + expect(html).toContain("custom-range-middle"); + expect(html).toContain("range-middle"); + expect(html).toContain("custom-selected"); + }); +}); + +describe("Calendar formatters", () => { + test("keeps the short month dropdown labels when another formatter is passed", () => { + const formatWeekdayName = (weekday: Date) => `W${weekday.getDay()}`; + const formatters = dayPickerProps({ + formatters: { formatWeekdayName }, + }).formatters; + + expect(formatters?.formatWeekdayName).toBe(formatWeekdayName); + expect( + formatters?.formatMonthDropdown?.(new Date(2026, 0, 15), defaultDateLib), + ).toBe("Jan"); + + const html = render({ + ...dropdownCaption, + formatters: { formatWeekdayName }, + }); + expect(monthOptions(html).slice(0, 3)).toEqual(["Jan", "Feb", "Mar"]); + expect(html).toContain("W0"); + }); + + test("lets the consumer replace the default month dropdown formatter", () => { + const html = render({ + ...dropdownCaption, + formatters: { + formatMonthDropdown: (date: Date) => `Month ${date.getMonth() + 1}`, + }, + }); + + expect(monthOptions(html).slice(0, 2)).toEqual(["Month 1", "Month 2"]); + }); + + test("uses the default when formatters is undefined or a formatter is undefined", () => { + expect(monthOptions(render(dropdownCaption)).slice(0, 2)).toEqual([ + "Jan", + "Feb", + ]); + expect( + monthOptions( + render({ + ...dropdownCaption, + formatters: { formatMonthDropdown: undefined }, + }), + ).slice(0, 2), + ).toEqual(["Jan", "Feb"]); + }); +});