From c6f3604ee665629da8d08ccf266e118b6599259e Mon Sep 17 00:00:00 2001 From: Van Buren Date: Tue, 2 Dec 2025 15:56:26 -0500 Subject: [PATCH 1/3] feat: add lib --- client/package.json | 2 + client/pnpm-lock.yaml | 42 +++- client/src/components/index.ts | 1 + .../components/library/ConfirmationDialog.tsx | 39 +++ client/src/components/library/DatePicker.tsx | 81 +++++++ client/src/components/library/index.ts | 2 + client/src/components/ui/alert-dialog.tsx | 138 +++++++++++ client/src/components/ui/calendar.tsx | 229 ++++++++++++++++++ client/src/components/ui/popover.tsx | 31 +++ 9 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 client/src/components/library/ConfirmationDialog.tsx create mode 100644 client/src/components/library/DatePicker.tsx create mode 100644 client/src/components/library/index.ts create mode 100644 client/src/components/ui/alert-dialog.tsx create mode 100644 client/src/components/ui/calendar.tsx create mode 100644 client/src/components/ui/popover.tsx diff --git a/client/package.json b/client/package.json index 48b7fc2..d0af854 100644 --- a/client/package.json +++ b/client/package.json @@ -38,10 +38,12 @@ "autoprefixer": "^10.4.22", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.553.0", "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^18.3.1", + "react-day-picker": "^9.11.3", "react-dom": "^18.3.1", "react-router-dom": "^7.9.6", "sonner": "^2.0.7", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 0e4666a..6113998 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lucide-react: specifier: ^0.553.0 version: 0.553.0(react@18.3.1) @@ -116,6 +119,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.11.3 + version: 9.11.3(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -134,9 +140,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.1.17) - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 devDependencies: '@types/node': specifier: ^24.10.1 @@ -246,6 +249,9 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1379,6 +1385,12 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + 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'} @@ -1576,6 +1588,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + react-day-picker@9.11.3: + resolution: {integrity: sha512-7lD12UvGbkyXqgzbYIGQTbl+x29B9bAf+k0pP5Dcs1evfpKk6zv4EdH/edNc8NxcmCiTNXr2HIYPrSZ3XvmVBg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1695,9 +1713,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tw-animate-css@1.4.0: - resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1896,6 +1911,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@date-fns/tz@1.4.1': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -2936,6 +2953,10 @@ snapshots: csstype@3.2.3: {} + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3095,6 +3116,13 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + react-day-picker@9.11.3(react@18.3.1): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -3225,8 +3253,6 @@ snapshots: tslib@2.8.1: {} - tw-animate-css@1.4.0: {} - typescript@5.9.3: {} undici-types@7.16.0: {} diff --git a/client/src/components/index.ts b/client/src/components/index.ts index 47869ba..cfe63f6 100644 --- a/client/src/components/index.ts +++ b/client/src/components/index.ts @@ -1,2 +1,3 @@ export * from "./base"; export * from "./examples"; +export * from "./library"; diff --git a/client/src/components/library/ConfirmationDialog.tsx b/client/src/components/library/ConfirmationDialog.tsx new file mode 100644 index 0000000..3b3fb29 --- /dev/null +++ b/client/src/components/library/ConfirmationDialog.tsx @@ -0,0 +1,39 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +export interface ConfirmationDialogProps { + open: boolean; + title: string; + text: string; + buttons: React.ReactNode; +} + +/** + * Reusable confirmation dialog component + * + * @component + */ +export const ConfirmationDialog = ({ + open, + title, + text, + buttons, +}: ConfirmationDialogProps) => { + return ( + + + + {title} + {text} + + {buttons} + + + ); +}; diff --git a/client/src/components/library/DatePicker.tsx b/client/src/components/library/DatePicker.tsx new file mode 100644 index 0000000..b639e91 --- /dev/null +++ b/client/src/components/library/DatePicker.tsx @@ -0,0 +1,81 @@ +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export interface DatePickerProps { + value: string | null; // YYYY-MM-DD + onChange: (value: string | null) => void; + label?: string; + placeholder?: string; + maxDate?: Date; + minDate?: Date; + disabled?: boolean; +} + +/** + * Date picker component using shadcn calendar + * + * @component + */ +export const DatePicker = ({ + value, + onChange, + placeholder = "Pick a date", + maxDate, + minDate, + disabled = false, +}: DatePickerProps) => { + const dateValue = value + ? (() => { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day); + })() + : undefined; + + const handleSelect = (date: Date | undefined) => { + if (date) { + // Format as YYYY-MM-DD for consistency + const formattedDate = format(date, "yyyy-MM-dd"); + onChange(formattedDate); + } else { + onChange(null); + } + }; + + return ( + + + + + + { + if (maxDate && date > maxDate) return true; + if (minDate && date < minDate) return true; + return false; + }} + /> + + + ); +}; diff --git a/client/src/components/library/index.ts b/client/src/components/library/index.ts new file mode 100644 index 0000000..0965b8a --- /dev/null +++ b/client/src/components/library/index.ts @@ -0,0 +1,2 @@ +export * from "./ConfirmationDialog"; +export * from "./DatePicker"; diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..1d22728 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,138 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx new file mode 100644 index 0000000..f057197 --- /dev/null +++ b/client/src/components/ui/calendar.tsx @@ -0,0 +1,229 @@ +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react"; +import * as React from "react"; +import { + type DayButton, + DayPicker, + getDefaultClassNames, +} from "react-day-picker"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +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 min-w-[250px]", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months, + ), + month: cn( + "flex w-full flex-col gap-4", + defaultClassNames.month, + ), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next, + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption, + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root, + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown, + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label, + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday, + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header, + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number, + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day, + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start, + ), + range_middle: cn( + "rounded-none", + defaultClassNames.range_middle, + ), + range_end: cn( + "bg-accent rounded-r-md", + 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 ( +