diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..fe925f4 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,43 @@ +{ + "name": "@cv/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./styles": "./src/index.css" + }, + "files": [ + "src/", + "*.css" + ], + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write ." + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "dependencies": { + "@cv/utils": "*", + "@catppuccin/tailwindcss": "^1.0.0", + "@tanstack/react-virtual": "^3.11.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.5.3" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.16", + "@tailwindcss/postcss": "^4.0.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.3" + } +} diff --git a/packages/ui/postcss.config.cjs b/packages/ui/postcss.config.cjs new file mode 100644 index 0000000..483f378 --- /dev/null +++ b/packages/ui/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/packages/ui/src/components/Badge.tsx b/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..183a7b6 --- /dev/null +++ b/packages/ui/src/components/Badge.tsx @@ -0,0 +1,45 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full px-3 py-1 text-xs font-medium", + { + variants: { + color: { + "ctp-red": "bg-ctp-red/20 text-ctp-red", + "ctp-orange": "bg-ctp-peach/20 text-ctp-peach", + "ctp-yellow": "bg-ctp-yellow/20 text-ctp-yellow", + "ctp-green": "bg-ctp-green/20 text-ctp-green", + "ctp-teal": "bg-ctp-teal/20 text-ctp-teal", + "ctp-sky": "bg-ctp-sky/20 text-ctp-sky", + "ctp-sapphire": "bg-ctp-sapphire/20 text-ctp-sapphire", + "ctp-blue": "bg-ctp-blue/20 text-ctp-blue", + "ctp-lavender": "bg-ctp-lavender/20 text-ctp-lavender", + "ctp-mauve": "bg-ctp-mauve/20 text-ctp-mauve", + "ctp-pink": "bg-ctp-pink/20 text-ctp-pink", + "ctp-maroon": "bg-ctp-maroon/20 text-ctp-maroon", + "ctp-peach": "bg-ctp-peach/20 text-ctp-peach", + "ctp-rosewater": "bg-ctp-rosewater/20 text-ctp-rosewater", + "ctp-gray": "bg-ctp-overlay0/20 text-ctp-overlay0", + }, + }, + defaultVariants: { + color: "ctp-blue", + }, + }, +); + +interface BadgeProps extends VariantProps { + children: React.ReactNode; + className?: string; +} + +export const Badge = ({ + children, + color = "ctp-blue", + className = "", +}: BadgeProps) => { + return ( + {children} + ); +}; diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..028b269 --- /dev/null +++ b/packages/ui/src/components/Button.tsx @@ -0,0 +1,56 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ctp-blue focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", + secondary: "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface1/80", + outline: + "border border-ctp-surface1 bg-transparent text-ctp-text hover:bg-ctp-surface0", + ghost: "text-ctp-text hover:bg-ctp-surface0", + destructive: "bg-ctp-red text-ctp-base hover:bg-ctp-red/90", + }, + size: { + sm: "h-8 px-3 text-sm", + md: "h-10 px-4 py-2", + lg: "h-12 px-8 text-lg", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +interface ButtonProps extends VariantProps { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + className?: string; + type?: "button" | "submit" | "reset"; +} + +export const Button = ({ + children, + onClick, + disabled = false, + variant = "primary", + size = "md", + className = "", + type = "button", +}: ButtonProps) => { + return ( + + ); +}; diff --git a/packages/ui/src/components/Calendar.tsx b/packages/ui/src/components/Calendar.tsx new file mode 100644 index 0000000..1731a63 --- /dev/null +++ b/packages/ui/src/components/Calendar.tsx @@ -0,0 +1,416 @@ +import { range } from "@cv/utils"; +import { cva } from "class-variance-authority"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "../lib/cn"; +import { + formatDisplayDate, + getDaysInMonth, + getFirstDayOfMonth, + isDateInRange, + isSameDate, + isToday, +} from "../lib/date"; + +const calendarInputVariants = cva( + "w-full px-3 py-2 pr-8 text-sm border border-ctp-overlay0 rounded-lg bg-ctp-base text-ctp-text placeholder-ctp-subtext0 focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:border-ctp-blue cursor-pointer transition-colors", + { + variants: { + disabled: { + true: "opacity-50 cursor-not-allowed", + false: "", + }, + }, + defaultVariants: { + disabled: false, + }, + }, +); + +const calendarDayVariants = cva( + "w-full aspect-square rounded-full text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-offset-1 flex items-center justify-center p-2", + { + variants: { + selected: { + true: "bg-ctp-blue text-ctp-base font-semibold p-2", + false: "", + }, + today: { + true: "bg-ctp-surface2 text-ctp-text font-medium", + false: "", + }, + disabled: { + true: "text-ctp-subtext0 cursor-not-allowed", + false: "text-ctp-text", + }, + }, + compoundVariants: [ + { + selected: true, + today: true, + className: "bg-ctp-blue text-ctp-base", + }, + { + selected: false, + disabled: false, + today: false, + className: "hover:bg-ctp-surface1", + }, + { + selected: false, + disabled: false, + className: "hover:bg-ctp-surface1", + }, + ], + defaultVariants: { + selected: false, + today: false, + disabled: false, + }, + }, +); + +const calendarNavButtonVariants = cva( + "p-1 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue", + { + variants: { + variant: { + icon: "hover:bg-ctp-surface1 text-ctp-text", + today: + "text-xs px-2 py-1 bg-ctp-surface1 hover:bg-ctp-surface2 text-ctp-text", + }, + }, + defaultVariants: { + variant: "icon", + }, + }, +); + +const calendarActionButtonVariants = cva( + "text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue rounded px-4 py-2", + { + variants: { + variant: { + clear: "text-ctp-red hover:text-ctp-red/80", + done: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", + }, + }, + defaultVariants: { + variant: "done", + }, + }, +); + +interface CalendarProps { + value?: Date | null; + onChange: (date: Date | null) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + minDate?: Date; + maxDate?: Date; + format?: "short" | "long" | "time"; +} + +export const Calendar = ({ + value, + onChange, + placeholder = "Select date", + disabled = false, + className, + minDate, + maxDate, + format = "short", +}: CalendarProps) => { + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState( + value ? value.getMonth() : new Date().getMonth(), + ); + const [currentYear, setCurrentYear] = useState( + (value ?? new Date()).getFullYear(), + ); + + const calendarRef = useRef(null); + const inputRef = useRef(null); + + // Close calendar when clicking outside + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + calendarRef.current && + !calendarRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + const isDateDisabled = (date: Date): boolean => + !isDateInRange(date, minDate, maxDate); + + const isDateSelected = (date: Date): boolean => + value != null ? isSameDate(date, value) : false; + + const handleDateSelect = (day: number) => { + const selectedDate = new Date(currentYear, currentMonth, day); + + if (isDateDisabled(selectedDate)) return; + + onChange(selectedDate); + setIsOpen(false); + }; + + const navigateMonth = (direction: "prev" | "next") => { + if (direction === "prev") { + if (currentMonth === 0) { + setCurrentMonth(11); + setCurrentYear(currentYear - 1); + } else { + setCurrentMonth(currentMonth - 1); + } + } else if (direction === "next") { + if (currentMonth === 11) { + setCurrentMonth(0); + setCurrentYear(currentYear + 1); + } else { + setCurrentMonth(currentMonth + 1); + } + } + }; + + const goToToday = () => { + const today = new Date(); + setCurrentMonth(today.getMonth()); + setCurrentYear(today.getFullYear()); + }; + + const clearDate = () => { + onChange(null); + setIsOpen(false); + }; + + const renderCalendarDays = () => { + const daysInMonth = getDaysInMonth(currentMonth, currentYear); + const firstDay = getFirstDayOfMonth(currentMonth, currentYear); + + const allDays: Array = [ + ...range(firstDay).map(() => null), + ...range(daysInMonth).map((index) => index + 1), + ]; + + const numberOfRows = Math.ceil(allDays.length / 7); + const rows = range(numberOfRows).map((rowIndex) => { + const start = rowIndex * 7; + const row = allDays.slice(start, start + 7); + const paddedRow = [...row, ...range(7 - row.length).map(() => null)]; + return paddedRow; + }); + + return rows.map((row, rowIndex) => { + const rowStartDay = row.find((day) => day !== null); + const rowKey = rowStartDay + ? `row-${currentYear}-${currentMonth}-${rowStartDay}` + : `row-${currentYear}-${currentMonth}-empty-${rowIndex}`; + + return ( + + {row.map((day, colIndex) => { + if (day === null) { + const emptyKey = `empty-${currentYear}-${currentMonth}-${rowStartDay ?? rowIndex}-col-${colIndex}`; + return ( + +
+ + ); + } + + const date = new Date(currentYear, currentMonth, day); + const disabled = isDateDisabled(date); + const selected = isDateSelected(date); + const today = isToday(date); + + return ( + + + + ); + })} + + ); + }); + }; + + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + return ( +
+
+ !disabled && setIsOpen(!isOpen)} + className={calendarInputVariants({ disabled })} + /> + +
+ + {isOpen && ( +
+
+ +
+ + {monthNames[currentMonth]} {currentYear} + + +
+ +
+ + + + + {dayNames.map((day) => ( + + ))} + + + {renderCalendarDays()} +
+ {day} +
+ +
+ + +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..5d5af15 --- /dev/null +++ b/packages/ui/src/components/Card.tsx @@ -0,0 +1,35 @@ +import { cn } from "../lib/cn"; + +interface CardProps { + children: React.ReactNode; + className?: string; + onClick?: () => void; +} + +export const Card = ({ children, className = "", onClick }: CardProps) => { + if (onClick) { + return ( + + ); + } + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx new file mode 100644 index 0000000..cb8ff7c --- /dev/null +++ b/packages/ui/src/components/Checkbox.tsx @@ -0,0 +1,46 @@ +import { cn } from "../lib/cn"; + +interface CheckboxProps { + label?: string; + checked?: boolean; + onChange?: (checked: boolean) => void; + disabled?: boolean; + className?: string; + id?: string; +} + +export const Checkbox = ({ + label, + checked = false, + onChange, + disabled = false, + className = "", + id, +}: CheckboxProps) => { + const checkboxId = + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); + + return ( +
+ onChange?.(e.target.checked)} + disabled={disabled} + className={cn( + "h-4 w-4 rounded border-ctp-surface1 bg-transparent text-ctp-blue focus:ring-2 focus:ring-ctp-blue focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + /> + {label && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/components/FormattedDate.tsx b/packages/ui/src/components/FormattedDate.tsx new file mode 100644 index 0000000..a9d3f56 --- /dev/null +++ b/packages/ui/src/components/FormattedDate.tsx @@ -0,0 +1,73 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + }); +}; + +const dateVariants = cva("cursor-help", { + variants: { + size: { + sm: "text-sm", + md: "text-base", + lg: "text-lg", + }, + variant: { + default: "text-ctp-text", + muted: "text-ctp-subtext0", + subtle: "text-ctp-subtext1", + }, + }, + defaultVariants: { + size: "md", + variant: "default", + }, +}); + +interface FormattedDateProps extends VariantProps { + /** + * ISO date string to format + */ + date: string; + /** + * Optional additional CSS class name + */ + className?: string; +} + +/** + * Displays a formatted date with full date on hover + * + * @example + * + * // Displays: "Jan 2024" + * // Tooltip: "January 15, 2024" + * + * @example + * + */ +export const FormattedDate = ({ + date, + size, + variant, + className, +}: FormattedDateProps) => { + const shortDate = formatDate(date); + const fullDate = new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( + + ); +}; diff --git a/packages/ui/src/components/FormattedDateRange.tsx b/packages/ui/src/components/FormattedDateRange.tsx new file mode 100644 index 0000000..315aaa1 --- /dev/null +++ b/packages/ui/src/components/FormattedDateRange.tsx @@ -0,0 +1,102 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +const formatDateRange = ( + startDate: string, + endDate?: string | null, +): string => { + const formatDate = (dateString: string): string => + new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + }); + + const start = formatDate(startDate); + const end = endDate ? formatDate(endDate) : "Present"; + return `${start} - ${end}`; +}; + +const dateRangeVariants = cva("cursor-help", { + variants: { + size: { + sm: "text-sm", + md: "text-base", + lg: "text-lg", + }, + variant: { + default: "text-ctp-text", + muted: "text-ctp-subtext0", + subtle: "text-ctp-subtext1", + }, + }, + defaultVariants: { + size: "md", + variant: "default", + }, +}); + +interface FormattedDateRangeProps + extends VariantProps { + /** + * ISO date string for start date + */ + startDate: string; + /** + * ISO date string for end date (optional, null for ongoing) + */ + endDate?: string | null; + /** + * Optional additional CSS class name + */ + className?: string; +} + +/** + * Displays a formatted date range with full dates on hover + * + * @example + * + * // Displays: "Jan 2023 - Mar 2024" + * // Tooltip: "January 15, 2023 - March 20, 2024" + * + * @example + * + * // Displays: "Jan 2023 - Present" + * // Tooltip: "January 15, 2023 - Present" + * + * @example + * + */ +export const FormattedDateRange = ({ + startDate, + endDate, + size, + variant, + className, +}: FormattedDateRangeProps) => { + const shortRange = formatDateRange(startDate, endDate); + + const formatFullDate = (date: string) => + new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + const fullStartDate = formatFullDate(startDate); + const fullEndDate = endDate ? formatFullDate(endDate) : "Present"; + const fullRange = `${fullStartDate} - ${fullEndDate}`; + + return ( + + ); +}; diff --git a/packages/ui/src/components/IconButton.tsx b/packages/ui/src/components/IconButton.tsx new file mode 100644 index 0000000..763d3ed --- /dev/null +++ b/packages/ui/src/components/IconButton.tsx @@ -0,0 +1,123 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const iconButtonVariants = cva( + "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ctp-blue focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "", + secondary: "", + outline: "border border-ctp-surface1 bg-transparent", + ghost: "", + destructive: "", + }, + size: { + xs: "h-6 w-6 text-xs", + sm: "h-8 w-8 text-sm", + md: "h-10 w-10", + lg: "h-12 w-12 text-lg", + }, + showColorOnHover: { + true: "", + false: "", + }, + }, + compoundVariants: [ + // Primary variants + { + variant: "primary", + showColorOnHover: true, + className: "text-ctp-text hover:bg-ctp-blue hover:text-ctp-base", + }, + { + variant: "primary", + showColorOnHover: false, + className: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", + }, + // Secondary variants + { + variant: "secondary", + showColorOnHover: true, + className: "text-ctp-text hover:bg-ctp-surface1", + }, + { + variant: "secondary", + showColorOnHover: false, + className: "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface1/80", + }, + // Outline variants + { + variant: "outline", + showColorOnHover: true, + className: "text-ctp-text hover:bg-ctp-surface0 hover:border-ctp-blue", + }, + { + variant: "outline", + showColorOnHover: false, + className: "text-ctp-text hover:bg-ctp-surface0", + }, + // Ghost variants + { + variant: "ghost", + showColorOnHover: true, + className: "text-ctp-text hover:bg-ctp-surface0", + }, + { + variant: "ghost", + showColorOnHover: false, + className: "text-ctp-text hover:bg-ctp-surface0", + }, + // Destructive variants + { + variant: "destructive", + showColorOnHover: true, + className: "text-ctp-text hover:bg-ctp-red hover:text-ctp-base", + }, + { + variant: "destructive", + showColorOnHover: false, + className: "bg-ctp-red text-ctp-base hover:bg-ctp-red/90", + }, + ], + defaultVariants: { + variant: "ghost", + size: "md", + showColorOnHover: true, + }, + }, +); + +interface IconButtonProps extends VariantProps { + icon: React.ReactNode; + label: string; + onClick?: () => void; + disabled?: boolean; + className?: string; +} + +export const IconButton = ({ + icon, + label, + onClick, + disabled = false, + variant = "ghost", + size = "md", + className = "", + showColorOnHover = true, +}: IconButtonProps) => { + return ( + + ); +}; diff --git a/packages/ui/src/components/PageHeader.tsx b/packages/ui/src/components/PageHeader.tsx new file mode 100644 index 0000000..8407156 --- /dev/null +++ b/packages/ui/src/components/PageHeader.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { cn } from "../lib/cn"; + +interface PageHeaderProps { + title: string; + description?: string; + action?: ReactNode; + className?: string; +} + +export const PageHeader = ({ + title, + description, + action, + className, +}: PageHeaderProps) => { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +}; diff --git a/packages/ui/src/components/Placeholder.tsx b/packages/ui/src/components/Placeholder.tsx new file mode 100644 index 0000000..eb21f41 --- /dev/null +++ b/packages/ui/src/components/Placeholder.tsx @@ -0,0 +1,43 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const placeholderVariants = cva( + "flex flex-col items-center justify-center text-center", + { + variants: { + variant: { + loading: "text-ctp-subtext0", + error: "text-ctp-red", + empty: "text-ctp-subtext0", + }, + }, + defaultVariants: { + variant: "loading", + }, + }, +); + +interface PlaceholderProps extends VariantProps { + title?: string; + message?: string; + children?: React.ReactNode; + className?: string; +} + +export const Placeholder = ({ + variant, + title, + message, + children, + className, +}: PlaceholderProps) => { + return ( +
+ {title && ( +

{title}

+ )} + {message &&
{message}
} + {children} +
+ ); +}; diff --git a/packages/ui/src/components/RangeSlider.tsx b/packages/ui/src/components/RangeSlider.tsx new file mode 100644 index 0000000..cf8b28b --- /dev/null +++ b/packages/ui/src/components/RangeSlider.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useRef, useState } from "react"; +import { cn } from "../lib/cn"; + +interface RangeSliderProps { + min: number; + max: number; + value: [number, number]; + onChange: (value: [number, number]) => void; + step?: number; + className?: string; + disabled?: boolean; + formatValue?: (value: number) => string; + showLabels?: boolean; + showValues?: boolean; +} + +export const RangeSlider = ({ + min, + max, + value, + onChange, + step = 1, + className, + disabled = false, + formatValue = (val) => val.toLocaleString(), + showLabels = true, + showValues = true, +}: RangeSliderProps) => { + const [isDragging, setIsDragging] = useState<"min" | "max" | null>(null); + const sliderRef = useRef(null); + + const getPercentage = useCallback( + (val: number) => ((val - min) / (max - min)) * 100, + [min, max], + ); + + const getValueFromPosition = useCallback( + (clientX: number) => { + if (!sliderRef.current) return min; + + const rect = sliderRef.current.getBoundingClientRect(); + const percentage = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + const rawValue = min + percentage * (max - min); + const steppedValue = Math.round(rawValue / step) * step; + + return Math.max(min, Math.min(max, steppedValue)); + }, + [min, max, step], + ); + + const handleMouseDown = (e: React.MouseEvent, thumb: "min" | "max") => { + if (disabled) return; + + e.preventDefault(); + setIsDragging(thumb); + + const newValue = getValueFromPosition(e.clientX); + const [currentMin, currentMax] = value; + + if (thumb === "min") { + const newMin = Math.min(newValue, currentMax); + onChange([newMin, currentMax]); + } else { + const newMax = Math.max(newValue, currentMin); + onChange([currentMin, newMax]); + } + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || disabled) return; + + const newValue = getValueFromPosition(e.clientX); + const [currentMin, currentMax] = value; + + if (isDragging === "min") { + const newMin = Math.min(newValue, currentMax); + onChange([newMin, currentMax]); + } else { + const newMax = Math.max(newValue, currentMin); + onChange([currentMin, newMax]); + } + }, + [isDragging, disabled, getValueFromPosition, value, onChange], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(null); + }, []); + + React.useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + const minPercentage = getPercentage(value[0]); + const maxPercentage = getPercentage(value[1]); + + return ( +
+ {showLabels && ( +
+ Min: {formatValue(min)} + Max: {formatValue(max)} +
+ )} + +
+ {/* Track */} +
+ {/* Active range */} +
+ + {/* Min thumb */} +
+ + {/* Value labels */} + {showValues && ( +
+ {formatValue(value[0])} + {formatValue(value[1])} +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/SearchableSelect.tsx b/packages/ui/src/components/SearchableSelect.tsx new file mode 100644 index 0000000..ebc07b6 --- /dev/null +++ b/packages/ui/src/components/SearchableSelect.tsx @@ -0,0 +1,238 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cva, type VariantProps } from "class-variance-authority"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "../lib/cn"; + +const searchableSelectVariants = cva( + "flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50", + { + variants: { + state: { + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", + error: "border-ctp-red focus-visible:ring-ctp-red", + }, + }, + defaultVariants: { + state: "default", + }, + }, +); + +export interface SearchableSelectOption { + value: string; + label: string; +} + +interface SearchableSelectProps + extends Omit, "state"> { + label?: string; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + error?: string; + options?: SearchableSelectOption[]; + className?: string; + id?: string; + required?: boolean; + // Pagination support + hasNextPage?: boolean; + onLoadMore?: () => void; + isLoading?: boolean; + // Add new option support + allowAddNew?: boolean; + onAddNew?: (label: string) => void; + addNewLabel?: string; +} + +export const SearchableSelect = ({ + label, + placeholder = "Search...", + value, + onChange, + disabled = false, + error, + options = [], + className = "", + id, + required = false, + hasNextPage = false, + onLoadMore, + isLoading = false, + allowAddNew = false, + onAddNew, + addNewLabel = "Add", +}: SearchableSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const listRef = useRef(null); + + const selectId = + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); + const selectState = error ? "error" : "default"; + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + // Check if search term doesn't match any option and add new is allowed + const hasNoMatch = + allowAddNew && + searchTerm.trim().length > 0 && + filteredOptions.length === 0 && + !options.some( + (opt) => opt.label.toLowerCase() === searchTerm.toLowerCase(), + ); + + const selectedOption = options.find((opt) => opt.value === value); + + // Setup virtualizer for the dropdown list + // Add 1 to count if we need to show "Add new" option + const virtualizer = useVirtualizer({ + count: filteredOptions.length + (hasNoMatch ? 1 : 0), + getScrollElement: () => listRef.current, + estimateSize: () => 40, + overscan: 5, + }); + + // Auto-load more when scrolling near the end + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + if (!isOpen) return; + + const [lastItem] = [...virtualItems].reverse(); + + if (!lastItem) return; + + const shouldLoadMore = + lastItem.index >= filteredOptions.length - 1 && hasNextPage && !isLoading; + + if (shouldLoadMore && onLoadMore) { + onLoadMore(); + } + }, [ + isOpen, + hasNextPage, + isLoading, + onLoadMore, + filteredOptions.length, + virtualItems, + ]); + + const handleFocus = () => { + setIsOpen(true); + setSearchTerm(""); + }; + + return ( +
+ {label && ( + + )} +
+ setSearchTerm(e.target.value)} + onFocus={handleFocus} + onBlur={() => { + setTimeout(() => setIsOpen(false), 200); + }} + placeholder={placeholder} + disabled={disabled} + required={required} + className={cn( + searchableSelectVariants({ state: selectState }), + className, + )} + /> + {isOpen && (filteredOptions.length > 0 || hasNoMatch) && ( +
+
+ {virtualItems.map((virtualRow) => { + // Check if this is the "Add new" row + if (hasNoMatch && virtualRow.index === filteredOptions.length) { + return ( + + ); + } + + const option = filteredOptions[virtualRow.index]; + if (!option) return null; + + return ( + + ); + })} +
+ + {/* Loading indicator */} + {isLoading && ( +
+ + Loading more... + +
+ )} +
+ )} +
+ {error &&

{error}

} +
+ ); +}; diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..dc243e7 --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,83 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const selectVariants = cva( + "flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50", + { + variants: { + state: { + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", + error: "border-ctp-red focus-visible:ring-ctp-red", + }, + }, + defaultVariants: { + state: "default", + }, + }, +); + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps + extends Omit, "state"> { + label?: string; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + error?: string; + options: SelectOption[]; + className?: string; + id?: string; + required?: boolean; +} + +export const Select = ({ + label, + placeholder, + value, + onChange, + disabled = false, + error, + options, + className = "", + id, + required = false, +}: SelectProps) => { + const selectId = + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); + const selectState = error ? "error" : "default"; + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ); +}; diff --git a/packages/ui/src/components/StatusBadge.tsx b/packages/ui/src/components/StatusBadge.tsx new file mode 100644 index 0000000..978d809 --- /dev/null +++ b/packages/ui/src/components/StatusBadge.tsx @@ -0,0 +1,52 @@ +import { cn } from "../lib/cn"; +import { Badge } from "./Badge"; + +type Status = + | "active" + | "inactive" + | "pending" + | "success" + | "error" + | "warning"; + +interface StatusBadgeProps { + status: Status; + className?: string; +} + +const statusConfig = { + active: { + label: "Active", + color: "ctp-green" as const, + }, + inactive: { + label: "Inactive", + color: "ctp-gray" as const, + }, + pending: { + label: "Pending", + color: "ctp-yellow" as const, + }, + success: { + label: "Success", + color: "ctp-green" as const, + }, + error: { + label: "Error", + color: "ctp-red" as const, + }, + warning: { + label: "Warning", + color: "ctp-orange" as const, + }, +}; + +export const StatusBadge = ({ status, className = "" }: StatusBadgeProps) => { + const config = statusConfig[status]; + + return ( + + {config.label} + + ); +}; diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..40ac782 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,118 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const tableVariants = cva("overflow-x-auto", { + variants: { + fullWidth: { + true: "w-full", + false: "", + }, + }, + defaultVariants: { + fullWidth: false, + }, +}); + +interface TableProps extends VariantProps { + children: React.ReactNode; + className?: string; +} + +interface TableHeaderProps { + children: React.ReactNode; + className?: string; +} + +interface TableBodyProps { + children: React.ReactNode; + className?: string; +} + +interface TableRowProps { + children: React.ReactNode; + className?: string; +} + +interface TableHeaderCellProps { + children: React.ReactNode; + className?: string; + colSpan?: number; +} + +interface TableCellProps { + children: React.ReactNode; + className?: string; + colSpan?: number; +} + +export const Table = ({ + children, + className = "", + fullWidth = false, +}: TableProps) => { + return ( +
+ + {children} +
+
+ ); +}; + +export const TableHeader = ({ children, className = "" }: TableHeaderProps) => { + return {children}; +}; + +export const TableBody = ({ children, className = "" }: TableBodyProps) => { + return ( + + {children} + + ); +}; + +export const TableRow = ({ children, className = "" }: TableRowProps) => { + return ( + {children} + ); +}; + +export const TableHeaderCell = ({ + children, + className = "", + colSpan, +}: TableHeaderCellProps) => { + return ( + + {children} + + ); +}; + +export const TableCell = ({ + children, + className = "", + colSpan, +}: TableCellProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/TextInput.tsx b/packages/ui/src/components/TextInput.tsx new file mode 100644 index 0000000..94b0d96 --- /dev/null +++ b/packages/ui/src/components/TextInput.tsx @@ -0,0 +1,80 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const inputVariants = cva( + "flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-ctp-subtext0 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50", + { + variants: { + state: { + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", + success: "border-ctp-green focus-visible:ring-ctp-green", + error: "border-ctp-red focus-visible:ring-ctp-red", + }, + }, + defaultVariants: { + state: "default", + }, + }, +); + +interface TextInputProps + extends Omit, "state"> { + label?: string; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + error?: string; + state?: "default" | "success" | "error"; + type?: + | "text" + | "email" + | "password" + | "number" + | "tel" + | "url" + | "date" + | "datetime-local"; + className?: string; + id?: string; + required?: boolean; +} + +export const TextInput = ({ + label, + placeholder, + value, + onChange, + disabled = false, + error, + state = "default", + type = "text", + className = "", + id, + required = false, +}: TextInputProps) => { + const inputId = + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); + const inputState = error ? "error" : state; + + return ( +
+ {label && ( + + )} + onChange?.(e.target.value)} + disabled={disabled} + required={required} + className={cn(inputVariants({ state: inputState }), className)} + /> + {error &&

{error}

} +
+ ); +}; diff --git a/packages/ui/src/components/Textarea.tsx b/packages/ui/src/components/Textarea.tsx new file mode 100644 index 0000000..3da48cd --- /dev/null +++ b/packages/ui/src/components/Textarea.tsx @@ -0,0 +1,75 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../lib/cn"; + +const textareaVariants = cva( + "flex w-full rounded-md border bg-transparent px-3 py-2 text-sm transition-colors placeholder:text-ctp-subtext0 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 resize-vertical", + { + variants: { + state: { + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", + success: "border-ctp-green focus-visible:ring-ctp-green", + error: "border-ctp-red focus-visible:ring-ctp-red", + }, + }, + defaultVariants: { + state: "default", + }, + }, +); + +interface TextareaProps + extends Omit, "state"> { + label?: string; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + error?: string; + state?: "default" | "success" | "error"; + className?: string; + id?: string; + required?: boolean; + rows?: number; +} + +export const Textarea = ({ + label, + placeholder, + value, + onChange, + disabled = false, + error, + state = "default", + className = "", + id, + required = false, + rows = 4, +}: TextareaProps) => { + const textareaId = + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); + const textareaState = error ? "error" : state; + + return ( +
+ {label && ( + + )} +