From 97d23cbf5204176511abb190b7c495a67f5221fe Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 26 Feb 2026 19:17:40 +0100 Subject: [PATCH] feat: replace custom date picker with shadcn/ui Calendar + Popover (#98) Replace the native HTML5 date input (hidden with showPicker()) with a proper shadcn/ui date picker using Calendar and Popover components. The new implementation provides: - Calendar popup with month navigation and keyboard support - Clear date button in the popover footer - Calendar icon in the trigger button - Proper date parsing via parseDateValue helper Also adds the design file design/date-picker-shadcn.pen with closed and open state frames. Co-authored-by: Claude Opus 4.6 --- design/date-picker-shadcn.pen | 285 ++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 34 +++ .../DynamicPropertiesPanel.test.tsx | 19 +- src/components/DynamicPropertiesPanel.tsx | 80 +++-- src/components/ui/calendar.tsx | 220 ++++++++++++++ src/components/ui/popover.tsx | 87 ++++++ 7 files changed, 695 insertions(+), 32 deletions(-) create mode 100644 design/date-picker-shadcn.pen create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/popover.tsx diff --git a/design/date-picker-shadcn.pen b/design/date-picker-shadcn.pen new file mode 100644 index 00000000..6f60223a --- /dev/null +++ b/design/date-picker-shadcn.pen @@ -0,0 +1,285 @@ +{ + "children": [ + { + "type": "frame", + "id": "datePickerClosed", + "x": 0, + "y": 0, + "name": "Date Picker — closed state (showing formatted date as button)", + "width": 320, + "height": 40, + "fill": "#ffffff", + "gap": 8, + "padding": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dateClosedLabel", + "name": "label", + "fill": "#6b7280", + "content": "deadline", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "frame", + "id": "dateClosedButton", + "name": "date-button", + "width": "fill_container", + "justifyContent": "flex-end", + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dateClosedIcon", + "name": "calendar-icon", + "fill": "#9ca3af", + "content": "📅", + "fontSize": 12 + }, + { + "type": "text", + "id": "dateClosedValue", + "name": "date-value", + "fill": "#374151", + "content": "Mar 31, 2026", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "datePickerOpen", + "x": 0, + "y": 160, + "name": "Date Picker — open state (calendar popup below button)", + "width": 320, + "height": 380, + "fill": "#ffffff", + "layout": "vertical", + "gap": 4, + "padding": 0, + "children": [ + { + "type": "frame", + "id": "dateOpenRow", + "name": "property-row", + "width": "fill_container", + "height": 40, + "gap": 8, + "padding": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dateOpenLabel", + "name": "label", + "fill": "#6b7280", + "content": "deadline", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "frame", + "id": "dateOpenButton", + "name": "date-button-active", + "width": "fill_container", + "justifyContent": "flex-end", + "alignItems": "center", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dateOpenIcon", + "name": "calendar-icon", + "fill": "#6366f1", + "content": "📅", + "fontSize": 12 + }, + { + "type": "text", + "id": "dateOpenValue", + "name": "date-value", + "fill": "#111827", + "content": "Mar 31, 2026", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "calendarPopup", + "name": "calendar-popup", + "width": 280, + "height": 320, + "fill": "#ffffff", + "layout": "vertical", + "cornerRadius": 8, + "padding": 12, + "gap": 8, + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#e5e7eb" + }, + "children": [ + { + "type": "frame", + "id": "calendarHeader", + "name": "calendar-header", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { + "type": "text", + "id": "calPrevBtn", + "name": "prev-month", + "fill": "#6b7280", + "content": "◀", + "fontSize": 12 + }, + { + "type": "text", + "id": "calMonthYear", + "name": "month-year", + "fill": "#111827", + "content": "March 2026", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "calNextBtn", + "name": "next-month", + "fill": "#6b7280", + "content": "▶", + "fontSize": 12 + } + ] + }, + { + "type": "frame", + "id": "calWeekdayRow", + "name": "weekday-headers", + "width": "fill_container", + "height": 24, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "wd1", "fill": "#9ca3af", "content": "Su", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd2", "fill": "#9ca3af", "content": "Mo", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd3", "fill": "#9ca3af", "content": "Tu", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd4", "fill": "#9ca3af", "content": "We", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd5", "fill": "#9ca3af", "content": "Th", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd6", "fill": "#9ca3af", "content": "Fr", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" }, + { "type": "text", "id": "wd7", "fill": "#9ca3af", "content": "Sa", "fontSize": 11, "fontFamily": "Inter", "fontWeight": "500" } + ] + }, + { + "type": "frame", + "id": "calGrid", + "name": "calendar-grid", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "calRow1", + "name": "week-1", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "d1", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d2", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d3", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d4", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d5", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d6", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d7", "fill": "#374151", "content": "1", "fontSize": 12, "fontFamily": "Inter" } + ] + }, + { + "type": "frame", + "id": "calRow5", + "name": "week-5-selected", + "width": "fill_container", + "height": 32, + "alignItems": "center", + "justifyContent": "space-between", + "children": [ + { "type": "text", "id": "d29", "fill": "#374151", "content": "29", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d30", "fill": "#374151", "content": "30", "fontSize": 12, "fontFamily": "Inter" }, + { + "type": "frame", + "id": "selectedDay", + "name": "selected-day-31", + "width": 28, + "height": 28, + "fill": "#111827", + "cornerRadius": 14, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { "type": "text", "id": "d31", "fill": "#ffffff", "content": "31", "fontSize": 12, "fontFamily": "Inter", "fontWeight": "600" } + ] + }, + { "type": "text", "id": "d32", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d33", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d34", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" }, + { "type": "text", "id": "d35", "fill": "#d1d5db", "content": " ", "fontSize": 12, "fontFamily": "Inter" } + ] + } + ] + }, + { + "type": "frame", + "id": "calFooter", + "name": "calendar-footer", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "flex-end", + "gap": 8, + "children": [ + { + "type": "text", + "id": "calClearBtn", + "name": "clear-button", + "fill": "#6b7280", + "content": "Clear", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/package.json b/package.json index b95b66a7..5813ec41 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,13 @@ "@tauri-apps/plugin-updater": "^2.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "katex": "^0.16.28", "lowlight": "^3.3.0", "lucide-react": "^0.564.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-day-picker": "^9.13.2", "react-dom": "^19.2.0", "react-virtuoso": "^4.18.1", "tailwind-merge": "^3.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468f68ab..79885c3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 katex: specifier: ^0.16.28 version: 0.16.28 @@ -95,6 +98,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.4 + react-day-picker: + specifier: ^9.13.2 + version: 9.13.2(react@19.2.4) react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) @@ -346,6 +352,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -2172,6 +2181,12 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3071,6 +3086,12 @@ packages: '@types/react-dom': optional: true + react-day-picker@9.13.2: + resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3874,6 +3895,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -5630,6 +5653,10 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6817,6 +6844,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + react-day-picker@9.13.2(react@19.2.4): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 489d6884..b77e8261 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -24,8 +24,8 @@ beforeAll(() => { Element.prototype.hasPointerCapture = () => false Element.prototype.setPointerCapture = vi.fn() Element.prototype.releasePointerCapture = vi.fn() - // showPicker is not available in JSDOM - HTMLInputElement.prototype.showPicker = vi.fn() + // Radix Popover needs getComputedStyle in JSDOM + if (!window.getComputedStyle) window.getComputedStyle = vi.fn().mockReturnValue({}) as never }) const makeEntry = (overrides: Partial = {}): VaultEntry => ({ @@ -691,7 +691,7 @@ describe('DynamicPropertiesPanel', () => { expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument() }) - it('renders hidden date picker input', () => { + it('renders calendar icon in date trigger button', () => { render( { onUpdateProperty={onUpdateProperty} /> ) - expect(screen.getByTestId('date-picker-input')).toBeInTheDocument() + const trigger = screen.getByTestId('date-display') + expect(trigger.tagName).toBe('BUTTON') + expect(trigger.querySelector('svg')).toBeInTheDocument() }) - it('calls onUpdateProperty when date picker value changes', () => { + it('opens calendar popover when date button clicked', () => { render( { onUpdateProperty={onUpdateProperty} /> ) - const dateInput = screen.getByTestId('date-picker-input') - fireEvent.change(dateInput, { target: { value: '2026-04-15' } }) - expect(onUpdateProperty).toHaveBeenCalledWith('deadline', '2026-04-15') + fireEvent.click(screen.getByTestId('date-display')) + expect(screen.getByTestId('date-picker-popover')).toBeInTheDocument() + // Clear button is inside the popover portal + expect(screen.getByText('Clear date')).toBeInTheDocument() }) }) diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index b9feba82..1c93a361 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -5,7 +5,10 @@ import type { ParsedFrontmatter } from '../utils/frontmatter' import { EditableValue, TagPillList, UrlValue } from './EditableValue' import { isUrlValue } from '../utils/url' import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' +import { CalendarIcon, XIcon } from 'lucide-react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { countWords } from '../utils/wikilinks' import { Check, X, Type, Calendar, ToggleLeft, Circle, Link } from 'lucide-react' @@ -103,40 +106,69 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo ) } +function parseDateValue(value: string): Date | undefined { + const iso = toISODate(value) + const d = new Date(iso + 'T00:00:00') + return isNaN(d.getTime()) ? undefined : d +} + function DateValue({ value, onSave }: { value: string; onSave: (newValue: string) => void }) { - const inputRef = useRef(null) + const [open, setOpen] = useState(false) const formatted = formatDateValue(value) - const isoValue = toISODate(value) + const selectedDate = parseDateValue(value) - const handleDateChange = (e: React.ChangeEvent) => { - if (e.target.value) onSave(e.target.value) + const handleSelect = (day: Date | undefined) => { + if (day) { + const yyyy = day.getFullYear() + const mm = String(day.getMonth() + 1).padStart(2, '0') + const dd = String(day.getDate()).padStart(2, '0') + onSave(`${yyyy}-${mm}-${dd}`) + } + setOpen(false) } - const handleClick = () => { - inputRef.current?.showPicker() + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + onSave('') + setOpen(false) } return ( - - - {formatted} - - - + + + + + + + {selectedDate && ( +
+ +
+ )} +
+
) } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..9fb18ca9 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +