feat: replace custom date picker with shadcn/ui Calendar + Popover (#98)
Replace the native HTML5 date input (hidden <input type="date"> 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 <noreply@anthropic.com>
This commit is contained in:
285
design/date-picker-shadcn.pen
Normal file
285
design/date-picker-shadcn.pen
Normal file
@@ -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": {}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -700,10 +700,12 @@ describe('DynamicPropertiesPanel', () => {
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -712,9 +714,10 @@ describe('DynamicPropertiesPanel', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const formatted = formatDateValue(value)
|
||||
const isoValue = toISODate(value)
|
||||
const selectedDate = parseDateValue(value)
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<span className="relative inline-flex min-w-0 items-center" data-testid="date-display">
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={handleClick}
|
||||
title={value}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="date"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||
value={isoValue}
|
||||
onChange={handleDateChange}
|
||||
tabIndex={-1}
|
||||
data-testid="date-picker-input"
|
||||
/>
|
||||
</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{formatted}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end" data-testid="date-picker-popover">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
defaultMonth={selectedDate}
|
||||
data-testid="date-picker-calendar"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 border-none bg-transparent text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
data-testid="date-picker-clear"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Clear date
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
220
src/components/ui/calendar.tsx
Normal file
220
src/components/ui/calendar.tsx
Normal file
@@ -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<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>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 (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
87
src/components/ui/popover.tsx
Normal file
87
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
Reference in New Issue
Block a user