From 0a7b24c7f97e8be46eb58153ed75eefaad908528 Mon Sep 17 00:00:00 2001 From: Carla Goncalves Date: Tue, 24 Feb 2026 14:59:08 +0000 Subject: [PATCH 1/5] First stage of date picker- no design update Add calendar Add popover Add presets --- .../design-system/molecules/datepicker.mdx | 450 +++++ .../content/design-system/molecules/meta.json | 1 + .../src/components/date-picker-examples.tsx | 47 + packages/eclipse/package.json | 3 + .../eclipse/src/components/date-picker.tsx | 204 +++ packages/eclipse/src/components/index.ts | 13 + .../eclipse/src/components/ui/calendar.tsx | 218 +++ .../eclipse/src/components/ui/popover.tsx | 87 + packages/eclipse/src/index.ts | 9 + packages/eclipse/src/lib/date-presets.ts | 114 ++ pnpm-lock.yaml | 1441 +++++++++++------ 11 files changed, 2047 insertions(+), 540 deletions(-) create mode 100644 apps/eclipse/content/design-system/molecules/datepicker.mdx create mode 100644 apps/eclipse/src/components/date-picker-examples.tsx create mode 100644 packages/eclipse/src/components/date-picker.tsx create mode 100644 packages/eclipse/src/components/ui/calendar.tsx create mode 100644 packages/eclipse/src/components/ui/popover.tsx create mode 100644 packages/eclipse/src/lib/date-presets.ts diff --git a/apps/eclipse/content/design-system/molecules/datepicker.mdx b/apps/eclipse/content/design-system/molecules/datepicker.mdx new file mode 100644 index 0000000000..1832d1ceba --- /dev/null +++ b/apps/eclipse/content/design-system/molecules/datepicker.mdx @@ -0,0 +1,450 @@ +--- +title: Date Picker +description: A flexible date picker component with support for single date selection, date ranges, and preset options. +--- + +import { + DatePickerSingleExample, + DatePickerRangeExample, + DatePickerRangeWithPresetsExample, +} from "../../../src/components/date-picker-examples"; + +### Usage + +**Single Date Picker** + +```tsx +import { DatePickerSingle } from "@prisma-docs/eclipse"; +import { useState } from "react"; + +export function SingleDateExample() { + const [date, setDate] = useState(); + + return ( + + ); +} +``` + +**Live Example:** + +
+ +
+ +**Date Range Picker** + +```tsx +import { DatePickerRange } from "@prisma-docs/eclipse"; +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; + +export function RangeDateExample() { + const [dateRange, setDateRange] = useState(); + + return ( + + ); +} +``` + +**Live Example:** + +
+ +
+ +**Date Range Picker with Presets** + +```tsx +import { + DatePickerRange, + createDateRangePresets, +} from "@prisma-docs/eclipse"; +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; + +export function RangeWithPresets() { + const [dateRange, setDateRange] = useState(); + const presets = createDateRangePresets(); + + return ( + + ); +} +``` + +**Live Example:** + +
+ +
+ +**Unified Component** + +The unified `DatePicker` component supports both modes: + +```tsx +import { DatePicker } from "@prisma-docs/eclipse"; +import { useState } from "react"; + +export function UnifiedExample() { + const [date, setDate] = useState(); + + return ( + + ); +} +``` + +### DatePicker Props + +#### Common Props + +- `placeholder` - Placeholder text when no date is selected (optional) +- `disabled` - Disabled dates (can be a function, date, or array) (optional) +- `className` - Custom className for the trigger button (optional) +- `align` - Align popover content: `"start"`, `"center"`, or `"end"` (default: `"start"`) + +#### Single Date Mode Props + +- `mode` - Set to `"single"` for single date selection +- `date` - The selected date (optional) +- `onDateChange` - Callback when date changes: `(date: Date | undefined) => void` (optional) + +#### Range Date Mode Props + +- `mode` - Set to `"range"` for date range selection +- `dateRange` - The selected date range (optional) +- `onDateRangeChange` - Callback when date range changes: `(range: DateRange | undefined) => void` (optional) +- `presets` - Array of preset date ranges (optional) + +### Component Variants + +#### DatePickerSingle + +Convenience component that automatically sets `mode="single"`: + +```tsx + +``` + +#### DatePickerRange + +Convenience component that automatically sets `mode="range"`: + +```tsx + +``` + +### Preset Helper + +Use `createDateRangePresets()` to generate common date range presets: + +```tsx +import { createDateRangePresets } from "@prisma-docs/eclipse"; + +const presets = createDateRangePresets(); +// Returns: Today, Last 7 days, Last 14 days, Last 30 days, +// Last 90 days, This month, Last month +``` + +**Custom Presets** + +```tsx +const customPresets = [ + { + label: "Yesterday", + dateRange: { + from: new Date(Date.now() - 24 * 60 * 60 * 1000), + to: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }, + { + label: "This Week", + dateRange: { + from: startOfWeek(new Date()), + to: new Date(), + }, + }, +]; + + +``` + +### Features + +- ✅ Single date selection +- ✅ Date range selection +- ✅ Preset date ranges +- ✅ Custom disabled dates +- ✅ Flexible date formatting with date-fns +- ✅ Keyboard navigation +- ✅ Accessible with proper ARIA attributes +- ✅ Eclipse design system integration +- ✅ Responsive popover positioning + +### Common Use Cases + +**Event Scheduling** + +```tsx + date < new Date()} +/> +``` + +**Report Date Range** + +```tsx + +``` + +**Booking System** + +```tsx + { + const day = date.getDay(); + return day === 0 || day === 6; // Disable weekends + }} + placeholder="Select booking dates" +/> +``` + +**Analytics Dashboard** + +```tsx +const analyticsPresets = [ + { label: "Last 7 days", dateRange: { from: sevenDaysAgo, to: today } }, + { label: "Last 30 days", dateRange: { from: thirtyDaysAgo, to: today } }, + { label: "This Quarter", dateRange: { from: quarterStart, to: today } }, +]; + + +``` + +### Disabling Dates + +**Disable Past Dates** + +```tsx + date < new Date()} +/> +``` + +**Disable Future Dates** + +```tsx + date > new Date()} +/> +``` + +**Disable Specific Dates** + +```tsx +const disabledDates = [ + new Date(2024, 11, 25), // Christmas + new Date(2025, 0, 1), // New Year +]; + + +``` + +**Disable Date Ranges** + +```tsx + +``` + +**Disable Days of Week** + +```tsx + { + const day = date.getDay(); + return day === 0 || day === 6; // Disable weekends + }} +/> +``` + +### Formatting + +The component uses `date-fns` for date formatting: + +- **Single date**: `PPP` format (e.g., "April 29, 2024") +- **Date range**: `LLL dd, y` format (e.g., "Apr 01, 2024 - Apr 30, 2024") + +To customize formatting, you can format the dates in your component: + +```tsx +import { format } from "date-fns"; + + +``` + +### Best Practices + +- Use **single date picker** for events, deadlines, or appointments +- Use **range picker** for reports, analytics, or booking periods +- Provide **presets** for common date ranges to improve UX +- **Disable irrelevant dates** (e.g., past dates for future bookings) +- Use clear **placeholder text** that indicates what the date is for +- Consider **default values** for better user experience +- Add **validation** to ensure date ranges make sense +- Show **clear labels** above date pickers in forms +- Use **consistent date formats** across your application + +### Accessibility + +- Full keyboard navigation support +- ARIA labels and roles for screen readers +- Focus management within the calendar +- Escape key to close the popover +- Tab navigation between dates +- Enter/Space to select dates +- Arrow keys to navigate calendar days +- High contrast colors for readability +- Clear visual focus indicators + +### Design Tokens + +The component uses Eclipse design system tokens: + +- Button: Standard button variants and sizes +- Popover: `bg-popover`, `text-popover-foreground` +- Calendar: Eclipse calendar component styling +- Borders: `border-stroke-neutral` +- Text: `text-muted-foreground` for placeholder +- Icons: Lucide React `CalendarIcon` + +### TypeScript Support + +The component is fully typed with TypeScript: + +```tsx +import type { DateRange, Matcher } from "react-day-picker"; +import type { DatePickerProps } from "@prisma-docs/eclipse"; + +const props: DatePickerProps = { + mode: "range", + dateRange: { from: new Date(), to: new Date() }, + onDateRangeChange: (range) => console.log(range), + presets: [ + { label: "Last 7 days", dateRange: { from: new Date(), to: new Date() } }, + ], +}; +``` + +### Integration with Forms + +**With React Hook Form** + +```tsx +import { useForm, Controller } from "react-hook-form"; +import { DatePickerSingle } from "@prisma-docs/eclipse"; + +function MyForm() { + const { control, handleSubmit } = useForm(); + + return ( +
+ ( + + )} + /> + + ); +} +``` + +**With Native State** + +```tsx +function MyForm() { + const [startDate, setStartDate] = useState(); + const [endDate, setEndDate] = useState(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + console.log({ startDate, endDate }); + }; + + return ( +
+
+ + + +
+
+ ); +} +``` diff --git a/apps/eclipse/content/design-system/molecules/meta.json b/apps/eclipse/content/design-system/molecules/meta.json index 68e73f73a9..96200c2922 100644 --- a/apps/eclipse/content/design-system/molecules/meta.json +++ b/apps/eclipse/content/design-system/molecules/meta.json @@ -6,6 +6,7 @@ "accordion", "banner", "breadcrumb", + "datepicker", "card", "codeblock", "dialog", diff --git a/apps/eclipse/src/components/date-picker-examples.tsx b/apps/eclipse/src/components/date-picker-examples.tsx new file mode 100644 index 0000000000..21214ef6d1 --- /dev/null +++ b/apps/eclipse/src/components/date-picker-examples.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { + DatePickerSingle, + DatePickerRange, + createDateRangePresets, +} from "@prisma-docs/eclipse"; +import type { DateRange } from "react-day-picker"; + +export function DatePickerSingleExample() { + const [date, setDate] = useState(); + + return ( + + ); +} + +export function DatePickerRangeExample() { + const [dateRange, setDateRange] = useState(); + + return ( + + ); +} + +export function DatePickerRangeWithPresetsExample() { + const [dateRange, setDateRange] = useState(); + const presets = createDateRangePresets(); + + return ( + + ); +} diff --git a/packages/eclipse/package.json b/packages/eclipse/package.json index 32d68da37d..edd455936a 100644 --- a/packages/eclipse/package.json +++ b/packages/eclipse/package.json @@ -35,9 +35,12 @@ "@radix-ui/react-tooltip": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", + "date-fns": "^4.1.0", "fumadocs-core": "catalog:", "fumadocs-ui": "catalog:", "lucide-react": "catalog:", + "radix-ui": "^1.4.3", + "react-day-picker": "^9.13.2", "tailwind-merge": "catalog:" }, "devDependencies": { diff --git a/packages/eclipse/src/components/date-picker.tsx b/packages/eclipse/src/components/date-picker.tsx new file mode 100644 index 0000000000..e270213f99 --- /dev/null +++ b/packages/eclipse/src/components/date-picker.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; +import { CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; +import type { DateRange, Matcher } from "react-day-picker"; + +import { cn } from "../lib/cn"; +import { Button, type ButtonProps } from "./button"; +import { Calendar } from "./ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +export interface DatePickerProps { + /** + * The selected date (for single date picker) + */ + date?: Date; + /** + * Callback when date changes (for single date picker) + */ + onDateChange?: (date: Date | undefined) => void; + /** + * The selected date range (for range picker) + */ + dateRange?: DateRange; + /** + * Callback when date range changes (for range picker) + */ + onDateRangeChange?: (range: DateRange | undefined) => void; + /** + * Placeholder text when no date is selected + */ + placeholder?: string; + /** + * Mode: 'single' or 'range' + */ + mode?: "single" | "range"; + /** + * Preset date ranges (for range picker) + */ + presets?: Array<{ + label: string; + dateRange: DateRange; + }>; + /** + * Disabled dates + */ + disabled?: Matcher | Matcher[]; + /** + * Custom className for the trigger button + */ + className?: string; + /** + * Align popover content + */ + align?: "start" | "center" | "end"; +} + +export function DatePicker({ + date, + onDateChange, + dateRange, + onDateRangeChange, + placeholder, + mode = "single", + presets, + disabled, + className, + align = "start", +}: DatePickerProps) { + const [open, setOpen] = React.useState(false); + + // Single date picker + if (mode === "single") { + return ( + + + + + + { + onDateChange?.(newDate); + setOpen(false); + }} + disabled={disabled} + initialFocus + /> + + + ); + } + + // Range date picker + return ( +
+ + + + + +
+ {presets && presets.length > 0 && ( +
+
+ Presets +
+ {presets.map((preset, index) => ( + + ))} +
+ )} + +
+
+
+
+ ); +} + +// Convenience exports for specific use cases +export function DatePickerSingle( + props: Omit< + DatePickerProps, + "mode" | "dateRange" | "onDateRangeChange" | "presets" + >, +) { + return ; +} + +export function DatePickerRange( + props: Omit, +) { + return ; +} + +// Re-export helper functions from lib +export { + createDateRangePresets, + createDateRangePreset, + getLastNDays, + getCurrentMonth, + getPreviousMonth, +} from "../lib/date-presets"; diff --git a/packages/eclipse/src/components/index.ts b/packages/eclipse/src/components/index.ts index 5b2d1350d8..2920d7d82f 100644 --- a/packages/eclipse/src/components/index.ts +++ b/packages/eclipse/src/components/index.ts @@ -110,6 +110,19 @@ export { Checkbox } from "./checkbox"; export { RadioGroup, RadioGroupItem } from "./radio-group"; export { Spinner } from "./spinner"; + +export { + DatePicker, + DatePickerSingle, + DatePickerRange, + createDateRangePresets, + createDateRangePreset, + getLastNDays, + getCurrentMonth, + getPreviousMonth, +} from "./date-picker"; +export type { DatePickerProps } from "./date-picker"; + export { Card, CardHeader, diff --git a/packages/eclipse/src/components/ui/calendar.tsx b/packages/eclipse/src/components/ui/calendar.tsx new file mode 100644 index 0000000000..60afef822d --- /dev/null +++ b/packages/eclipse/src/components/ui/calendar.tsx @@ -0,0 +1,218 @@ +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/cn"; +import { Button, buttonVariants } from "../button"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "default", + 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 ( +