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"]); + }); +});