because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): extract UI components to shared package

+2382
+43
packages/ui/package.json
··· 1 + { 2 + "name": "@cv/ui", 3 + "version": "0.0.0", 4 + "private": true, 5 + "type": "module", 6 + "main": "./src/index.ts", 7 + "types": "./src/index.ts", 8 + "exports": { 9 + ".": { 10 + "import": "./src/index.ts", 11 + "types": "./src/index.ts" 12 + }, 13 + "./styles": "./src/index.css" 14 + }, 15 + "files": [ 16 + "src/", 17 + "*.css" 18 + ], 19 + "scripts": { 20 + "lint": "biome check .", 21 + "lint:fix": "biome check --write ." 22 + }, 23 + "peerDependencies": { 24 + "react": "^18.3.1", 25 + "react-dom": "^18.3.1" 26 + }, 27 + "dependencies": { 28 + "@cv/utils": "*", 29 + "@catppuccin/tailwindcss": "^1.0.0", 30 + "@tanstack/react-virtual": "^3.11.2", 31 + "class-variance-authority": "^0.7.0", 32 + "clsx": "^2.1.1", 33 + "tailwind-merge": "^2.5.3" 34 + }, 35 + "devDependencies": { 36 + "@tailwindcss/cli": "^4.1.16", 37 + "@tailwindcss/postcss": "^4.0.0", 38 + "@types/react": "^18.3.5", 39 + "@types/react-dom": "^18.3.0", 40 + "tailwindcss": "^4.0.0", 41 + "typescript": "^5.5.3" 42 + } 43 + }
+5
packages/ui/postcss.config.cjs
··· 1 + module.exports = { 2 + plugins: { 3 + "@tailwindcss/postcss": {}, 4 + }, 5 + };
+45
packages/ui/src/components/Badge.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const badgeVariants = cva( 5 + "inline-flex items-center rounded-full px-3 py-1 text-xs font-medium", 6 + { 7 + variants: { 8 + color: { 9 + "ctp-red": "bg-ctp-red/20 text-ctp-red", 10 + "ctp-orange": "bg-ctp-peach/20 text-ctp-peach", 11 + "ctp-yellow": "bg-ctp-yellow/20 text-ctp-yellow", 12 + "ctp-green": "bg-ctp-green/20 text-ctp-green", 13 + "ctp-teal": "bg-ctp-teal/20 text-ctp-teal", 14 + "ctp-sky": "bg-ctp-sky/20 text-ctp-sky", 15 + "ctp-sapphire": "bg-ctp-sapphire/20 text-ctp-sapphire", 16 + "ctp-blue": "bg-ctp-blue/20 text-ctp-blue", 17 + "ctp-lavender": "bg-ctp-lavender/20 text-ctp-lavender", 18 + "ctp-mauve": "bg-ctp-mauve/20 text-ctp-mauve", 19 + "ctp-pink": "bg-ctp-pink/20 text-ctp-pink", 20 + "ctp-maroon": "bg-ctp-maroon/20 text-ctp-maroon", 21 + "ctp-peach": "bg-ctp-peach/20 text-ctp-peach", 22 + "ctp-rosewater": "bg-ctp-rosewater/20 text-ctp-rosewater", 23 + "ctp-gray": "bg-ctp-overlay0/20 text-ctp-overlay0", 24 + }, 25 + }, 26 + defaultVariants: { 27 + color: "ctp-blue", 28 + }, 29 + }, 30 + ); 31 + 32 + interface BadgeProps extends VariantProps<typeof badgeVariants> { 33 + children: React.ReactNode; 34 + className?: string; 35 + } 36 + 37 + export const Badge = ({ 38 + children, 39 + color = "ctp-blue", 40 + className = "", 41 + }: BadgeProps) => { 42 + return ( 43 + <span className={cn(badgeVariants({ color }), className)}>{children}</span> 44 + ); 45 + };
+56
packages/ui/src/components/Button.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const buttonVariants = cva( 5 + "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", 6 + { 7 + variants: { 8 + variant: { 9 + primary: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", 10 + secondary: "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface1/80", 11 + outline: 12 + "border border-ctp-surface1 bg-transparent text-ctp-text hover:bg-ctp-surface0", 13 + ghost: "text-ctp-text hover:bg-ctp-surface0", 14 + destructive: "bg-ctp-red text-ctp-base hover:bg-ctp-red/90", 15 + }, 16 + size: { 17 + sm: "h-8 px-3 text-sm", 18 + md: "h-10 px-4 py-2", 19 + lg: "h-12 px-8 text-lg", 20 + }, 21 + }, 22 + defaultVariants: { 23 + variant: "primary", 24 + size: "md", 25 + }, 26 + }, 27 + ); 28 + 29 + interface ButtonProps extends VariantProps<typeof buttonVariants> { 30 + children: React.ReactNode; 31 + onClick?: () => void; 32 + disabled?: boolean; 33 + className?: string; 34 + type?: "button" | "submit" | "reset"; 35 + } 36 + 37 + export const Button = ({ 38 + children, 39 + onClick, 40 + disabled = false, 41 + variant = "primary", 42 + size = "md", 43 + className = "", 44 + type = "button", 45 + }: ButtonProps) => { 46 + return ( 47 + <button 48 + type={type} 49 + onClick={onClick} 50 + disabled={disabled} 51 + className={cn(buttonVariants({ variant, size }), className)} 52 + > 53 + {children} 54 + </button> 55 + ); 56 + };
+416
packages/ui/src/components/Calendar.tsx
··· 1 + import { range } from "@cv/utils"; 2 + import { cva } from "class-variance-authority"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { cn } from "../lib/cn"; 5 + import { 6 + formatDisplayDate, 7 + getDaysInMonth, 8 + getFirstDayOfMonth, 9 + isDateInRange, 10 + isSameDate, 11 + isToday, 12 + } from "../lib/date"; 13 + 14 + const calendarInputVariants = cva( 15 + "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", 16 + { 17 + variants: { 18 + disabled: { 19 + true: "opacity-50 cursor-not-allowed", 20 + false: "", 21 + }, 22 + }, 23 + defaultVariants: { 24 + disabled: false, 25 + }, 26 + }, 27 + ); 28 + 29 + const calendarDayVariants = cva( 30 + "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", 31 + { 32 + variants: { 33 + selected: { 34 + true: "bg-ctp-blue text-ctp-base font-semibold p-2", 35 + false: "", 36 + }, 37 + today: { 38 + true: "bg-ctp-surface2 text-ctp-text font-medium", 39 + false: "", 40 + }, 41 + disabled: { 42 + true: "text-ctp-subtext0 cursor-not-allowed", 43 + false: "text-ctp-text", 44 + }, 45 + }, 46 + compoundVariants: [ 47 + { 48 + selected: true, 49 + today: true, 50 + className: "bg-ctp-blue text-ctp-base", 51 + }, 52 + { 53 + selected: false, 54 + disabled: false, 55 + today: false, 56 + className: "hover:bg-ctp-surface1", 57 + }, 58 + { 59 + selected: false, 60 + disabled: false, 61 + className: "hover:bg-ctp-surface1", 62 + }, 63 + ], 64 + defaultVariants: { 65 + selected: false, 66 + today: false, 67 + disabled: false, 68 + }, 69 + }, 70 + ); 71 + 72 + const calendarNavButtonVariants = cva( 73 + "p-1 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue", 74 + { 75 + variants: { 76 + variant: { 77 + icon: "hover:bg-ctp-surface1 text-ctp-text", 78 + today: 79 + "text-xs px-2 py-1 bg-ctp-surface1 hover:bg-ctp-surface2 text-ctp-text", 80 + }, 81 + }, 82 + defaultVariants: { 83 + variant: "icon", 84 + }, 85 + }, 86 + ); 87 + 88 + const calendarActionButtonVariants = cva( 89 + "text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue rounded px-4 py-2", 90 + { 91 + variants: { 92 + variant: { 93 + clear: "text-ctp-red hover:text-ctp-red/80", 94 + done: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", 95 + }, 96 + }, 97 + defaultVariants: { 98 + variant: "done", 99 + }, 100 + }, 101 + ); 102 + 103 + interface CalendarProps { 104 + value?: Date | null; 105 + onChange: (date: Date | null) => void; 106 + placeholder?: string; 107 + disabled?: boolean; 108 + className?: string; 109 + minDate?: Date; 110 + maxDate?: Date; 111 + format?: "short" | "long" | "time"; 112 + } 113 + 114 + export const Calendar = ({ 115 + value, 116 + onChange, 117 + placeholder = "Select date", 118 + disabled = false, 119 + className, 120 + minDate, 121 + maxDate, 122 + format = "short", 123 + }: CalendarProps) => { 124 + const [isOpen, setIsOpen] = useState(false); 125 + const [currentMonth, setCurrentMonth] = useState( 126 + value ? value.getMonth() : new Date().getMonth(), 127 + ); 128 + const [currentYear, setCurrentYear] = useState( 129 + (value ?? new Date()).getFullYear(), 130 + ); 131 + 132 + const calendarRef = useRef<HTMLDivElement>(null); 133 + const inputRef = useRef<HTMLInputElement>(null); 134 + 135 + // Close calendar when clicking outside 136 + useEffect(() => { 137 + if (!isOpen) return; 138 + 139 + const handleClickOutside = (event: MouseEvent) => { 140 + if ( 141 + calendarRef.current && 142 + !calendarRef.current.contains(event.target as Node) && 143 + inputRef.current && 144 + !inputRef.current.contains(event.target as Node) 145 + ) { 146 + setIsOpen(false); 147 + } 148 + }; 149 + 150 + document.addEventListener("mousedown", handleClickOutside); 151 + return () => document.removeEventListener("mousedown", handleClickOutside); 152 + }, [isOpen]); 153 + 154 + const isDateDisabled = (date: Date): boolean => 155 + !isDateInRange(date, minDate, maxDate); 156 + 157 + const isDateSelected = (date: Date): boolean => 158 + value != null ? isSameDate(date, value) : false; 159 + 160 + const handleDateSelect = (day: number) => { 161 + const selectedDate = new Date(currentYear, currentMonth, day); 162 + 163 + if (isDateDisabled(selectedDate)) return; 164 + 165 + onChange(selectedDate); 166 + setIsOpen(false); 167 + }; 168 + 169 + const navigateMonth = (direction: "prev" | "next") => { 170 + if (direction === "prev") { 171 + if (currentMonth === 0) { 172 + setCurrentMonth(11); 173 + setCurrentYear(currentYear - 1); 174 + } else { 175 + setCurrentMonth(currentMonth - 1); 176 + } 177 + } else if (direction === "next") { 178 + if (currentMonth === 11) { 179 + setCurrentMonth(0); 180 + setCurrentYear(currentYear + 1); 181 + } else { 182 + setCurrentMonth(currentMonth + 1); 183 + } 184 + } 185 + }; 186 + 187 + const goToToday = () => { 188 + const today = new Date(); 189 + setCurrentMonth(today.getMonth()); 190 + setCurrentYear(today.getFullYear()); 191 + }; 192 + 193 + const clearDate = () => { 194 + onChange(null); 195 + setIsOpen(false); 196 + }; 197 + 198 + const renderCalendarDays = () => { 199 + const daysInMonth = getDaysInMonth(currentMonth, currentYear); 200 + const firstDay = getFirstDayOfMonth(currentMonth, currentYear); 201 + 202 + const allDays: Array<number | null> = [ 203 + ...range(firstDay).map(() => null), 204 + ...range(daysInMonth).map((index) => index + 1), 205 + ]; 206 + 207 + const numberOfRows = Math.ceil(allDays.length / 7); 208 + const rows = range(numberOfRows).map((rowIndex) => { 209 + const start = rowIndex * 7; 210 + const row = allDays.slice(start, start + 7); 211 + const paddedRow = [...row, ...range(7 - row.length).map(() => null)]; 212 + return paddedRow; 213 + }); 214 + 215 + return rows.map((row, rowIndex) => { 216 + const rowStartDay = row.find((day) => day !== null); 217 + const rowKey = rowStartDay 218 + ? `row-${currentYear}-${currentMonth}-${rowStartDay}` 219 + : `row-${currentYear}-${currentMonth}-empty-${rowIndex}`; 220 + 221 + return ( 222 + <tr key={rowKey}> 223 + {row.map((day, colIndex) => { 224 + if (day === null) { 225 + const emptyKey = `empty-${currentYear}-${currentMonth}-${rowStartDay ?? rowIndex}-col-${colIndex}`; 226 + return ( 227 + <td key={emptyKey} className="p-1 text-center align-middle"> 228 + <div className="aspect-square" /> 229 + </td> 230 + ); 231 + } 232 + 233 + const date = new Date(currentYear, currentMonth, day); 234 + const disabled = isDateDisabled(date); 235 + const selected = isDateSelected(date); 236 + const today = isToday(date); 237 + 238 + return ( 239 + <td 240 + key={`day-${currentYear}-${currentMonth}-${day}`} 241 + className="p-1 text-center align-middle" 242 + > 243 + <button 244 + type="button" 245 + className={calendarDayVariants({ 246 + selected, 247 + today: today && !selected, 248 + disabled, 249 + })} 250 + onClick={() => handleDateSelect(day)} 251 + disabled={disabled} 252 + aria-label={`Select ${date.toLocaleDateString()}`} 253 + > 254 + {day} 255 + </button> 256 + </td> 257 + ); 258 + })} 259 + </tr> 260 + ); 261 + }); 262 + }; 263 + 264 + const monthNames = [ 265 + "January", 266 + "February", 267 + "March", 268 + "April", 269 + "May", 270 + "June", 271 + "July", 272 + "August", 273 + "September", 274 + "October", 275 + "November", 276 + "December", 277 + ]; 278 + 279 + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 280 + 281 + return ( 282 + <div className={cn("relative", className)}> 283 + <div className="relative"> 284 + <input 285 + ref={inputRef} 286 + type="text" 287 + value={value ? formatDisplayDate(value, format) : ""} 288 + placeholder={placeholder} 289 + readOnly 290 + disabled={disabled} 291 + onClick={() => !disabled && setIsOpen(!isOpen)} 292 + className={calendarInputVariants({ disabled })} 293 + /> 294 + <button 295 + type="button" 296 + className="absolute right-2 top-1/2 -translate-y-1/2 text-ctp-subtext0 hover:text-ctp-text" 297 + onClick={() => !disabled && setIsOpen(!isOpen)} 298 + disabled={disabled} 299 + aria-label="Open calendar" 300 + > 301 + <svg 302 + className="w-4 h-4" 303 + fill="none" 304 + stroke="currentColor" 305 + viewBox="0 0 24 24" 306 + > 307 + <title>Calendar icon</title> 308 + <path 309 + strokeLinecap="round" 310 + strokeLinejoin="round" 311 + strokeWidth={2} 312 + d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" 313 + /> 314 + </svg> 315 + </button> 316 + </div> 317 + 318 + {isOpen && ( 319 + <div 320 + ref={calendarRef} 321 + className="absolute top-full left-0 mt-1 bg-ctp-base border border-ctp-overlay0 rounded-lg shadow-lg z-50 p-4 w-80 max-w-full" 322 + > 323 + <div className="flex items-center justify-between mb-4"> 324 + <button 325 + type="button" 326 + onClick={() => navigateMonth("prev")} 327 + className={calendarNavButtonVariants({ variant: "icon" })} 328 + aria-label="Previous month" 329 + > 330 + <svg 331 + className="w-4 h-4" 332 + fill="none" 333 + stroke="currentColor" 334 + viewBox="0 0 24 24" 335 + > 336 + <title>Previous month</title> 337 + <path 338 + strokeLinecap="round" 339 + strokeLinejoin="round" 340 + strokeWidth={2} 341 + d="M15 19l-7-7 7-7" 342 + /> 343 + </svg> 344 + </button> 345 + <div className="flex items-center gap-2"> 346 + <span className="font-semibold text-ctp-text"> 347 + {monthNames[currentMonth]} {currentYear} 348 + </span> 349 + <button 350 + type="button" 351 + onClick={goToToday} 352 + className={calendarNavButtonVariants({ variant: "today" })} 353 + > 354 + Today 355 + </button> 356 + </div> 357 + <button 358 + type="button" 359 + onClick={() => navigateMonth("next")} 360 + className={calendarNavButtonVariants({ variant: "icon" })} 361 + aria-label="Next month" 362 + > 363 + <svg 364 + className="w-4 h-4" 365 + fill="none" 366 + stroke="currentColor" 367 + viewBox="0 0 24 24" 368 + > 369 + <title>Next month</title> 370 + <path 371 + strokeLinecap="round" 372 + strokeLinejoin="round" 373 + strokeWidth={2} 374 + d="M9 5l7 7-7 7" 375 + /> 376 + </svg> 377 + </button> 378 + </div> 379 + 380 + <table className="w-full border-collapse"> 381 + <thead> 382 + <tr> 383 + {dayNames.map((day) => ( 384 + <th 385 + key={day} 386 + className="p-1 aspect-square text-center align-middle text-xs font-medium text-ctp-subtext0" 387 + > 388 + {day} 389 + </th> 390 + ))} 391 + </tr> 392 + </thead> 393 + <tbody>{renderCalendarDays()}</tbody> 394 + </table> 395 + 396 + <div className="flex justify-between mt-4 pt-4 border-t border-ctp-overlay0"> 397 + <button 398 + type="button" 399 + onClick={clearDate} 400 + className={calendarActionButtonVariants({ variant: "clear" })} 401 + > 402 + Clear 403 + </button> 404 + <button 405 + type="button" 406 + onClick={() => setIsOpen(false)} 407 + className={calendarActionButtonVariants({ variant: "done" })} 408 + > 409 + Done 410 + </button> 411 + </div> 412 + </div> 413 + )} 414 + </div> 415 + ); 416 + };
+35
packages/ui/src/components/Card.tsx
··· 1 + import { cn } from "../lib/cn"; 2 + 3 + interface CardProps { 4 + children: React.ReactNode; 5 + className?: string; 6 + onClick?: () => void; 7 + } 8 + 9 + export const Card = ({ children, className = "", onClick }: CardProps) => { 10 + if (onClick) { 11 + return ( 12 + <button 13 + type="button" 14 + className={cn( 15 + "rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-sm cursor-pointer hover:shadow-md transition-shadow text-left w-full", 16 + className, 17 + )} 18 + onClick={onClick} 19 + > 20 + {children} 21 + </button> 22 + ); 23 + } 24 + 25 + return ( 26 + <div 27 + className={cn( 28 + "rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-sm", 29 + className, 30 + )} 31 + > 32 + {children} 33 + </div> 34 + ); 35 + };
+46
packages/ui/src/components/Checkbox.tsx
··· 1 + import { cn } from "../lib/cn"; 2 + 3 + interface CheckboxProps { 4 + label?: string; 5 + checked?: boolean; 6 + onChange?: (checked: boolean) => void; 7 + disabled?: boolean; 8 + className?: string; 9 + id?: string; 10 + } 11 + 12 + export const Checkbox = ({ 13 + label, 14 + checked = false, 15 + onChange, 16 + disabled = false, 17 + className = "", 18 + id, 19 + }: CheckboxProps) => { 20 + const checkboxId = 21 + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); 22 + 23 + return ( 24 + <div className="flex items-center space-x-2"> 25 + <input 26 + id={checkboxId} 27 + type="checkbox" 28 + checked={checked} 29 + onChange={(e) => onChange?.(e.target.checked)} 30 + disabled={disabled} 31 + className={cn( 32 + "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", 33 + className, 34 + )} 35 + /> 36 + {label && ( 37 + <label 38 + htmlFor={checkboxId} 39 + className="text-sm font-medium text-ctp-text cursor-pointer" 40 + > 41 + {label} 42 + </label> 43 + )} 44 + </div> 45 + ); 46 + };
+73
packages/ui/src/components/FormattedDate.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + 3 + const formatDate = (dateString: string): string => { 4 + return new Date(dateString).toLocaleDateString("en-US", { 5 + year: "numeric", 6 + month: "short", 7 + }); 8 + }; 9 + 10 + const dateVariants = cva("cursor-help", { 11 + variants: { 12 + size: { 13 + sm: "text-sm", 14 + md: "text-base", 15 + lg: "text-lg", 16 + }, 17 + variant: { 18 + default: "text-ctp-text", 19 + muted: "text-ctp-subtext0", 20 + subtle: "text-ctp-subtext1", 21 + }, 22 + }, 23 + defaultVariants: { 24 + size: "md", 25 + variant: "default", 26 + }, 27 + }); 28 + 29 + interface FormattedDateProps extends VariantProps<typeof dateVariants> { 30 + /** 31 + * ISO date string to format 32 + */ 33 + date: string; 34 + /** 35 + * Optional additional CSS class name 36 + */ 37 + className?: string; 38 + } 39 + 40 + /** 41 + * Displays a formatted date with full date on hover 42 + * 43 + * @example 44 + * <FormattedDate date="2024-01-15T00:00:00Z" /> 45 + * // Displays: "Jan 2024" 46 + * // Tooltip: "January 15, 2024" 47 + * 48 + * @example 49 + * <FormattedDate date="2024-01-15T00:00:00Z" variant="muted" size="sm" /> 50 + */ 51 + export const FormattedDate = ({ 52 + date, 53 + size, 54 + variant, 55 + className, 56 + }: FormattedDateProps) => { 57 + const shortDate = formatDate(date); 58 + const fullDate = new Date(date).toLocaleDateString("en-US", { 59 + year: "numeric", 60 + month: "long", 61 + day: "numeric", 62 + }); 63 + 64 + return ( 65 + <time 66 + dateTime={date} 67 + title={fullDate} 68 + className={dateVariants({ size, variant, className })} 69 + > 70 + {shortDate} 71 + </time> 72 + ); 73 + };
+102
packages/ui/src/components/FormattedDateRange.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + 3 + const formatDateRange = ( 4 + startDate: string, 5 + endDate?: string | null, 6 + ): string => { 7 + const formatDate = (dateString: string): string => 8 + new Date(dateString).toLocaleDateString("en-US", { 9 + year: "numeric", 10 + month: "short", 11 + }); 12 + 13 + const start = formatDate(startDate); 14 + const end = endDate ? formatDate(endDate) : "Present"; 15 + return `${start} - ${end}`; 16 + }; 17 + 18 + const dateRangeVariants = cva("cursor-help", { 19 + variants: { 20 + size: { 21 + sm: "text-sm", 22 + md: "text-base", 23 + lg: "text-lg", 24 + }, 25 + variant: { 26 + default: "text-ctp-text", 27 + muted: "text-ctp-subtext0", 28 + subtle: "text-ctp-subtext1", 29 + }, 30 + }, 31 + defaultVariants: { 32 + size: "md", 33 + variant: "default", 34 + }, 35 + }); 36 + 37 + interface FormattedDateRangeProps 38 + extends VariantProps<typeof dateRangeVariants> { 39 + /** 40 + * ISO date string for start date 41 + */ 42 + startDate: string; 43 + /** 44 + * ISO date string for end date (optional, null for ongoing) 45 + */ 46 + endDate?: string | null; 47 + /** 48 + * Optional additional CSS class name 49 + */ 50 + className?: string; 51 + } 52 + 53 + /** 54 + * Displays a formatted date range with full dates on hover 55 + * 56 + * @example 57 + * <FormattedDateRange startDate="2023-01-15T00:00:00Z" endDate="2024-03-20T00:00:00Z" /> 58 + * // Displays: "Jan 2023 - Mar 2024" 59 + * // Tooltip: "January 15, 2023 - March 20, 2024" 60 + * 61 + * @example 62 + * <FormattedDateRange startDate="2023-01-15T00:00:00Z" /> 63 + * // Displays: "Jan 2023 - Present" 64 + * // Tooltip: "January 15, 2023 - Present" 65 + * 66 + * @example 67 + * <FormattedDateRange 68 + * startDate="2023-01-15T00:00:00Z" 69 + * variant="muted" 70 + * size="sm" 71 + * /> 72 + */ 73 + export const FormattedDateRange = ({ 74 + startDate, 75 + endDate, 76 + size, 77 + variant, 78 + className, 79 + }: FormattedDateRangeProps) => { 80 + const shortRange = formatDateRange(startDate, endDate); 81 + 82 + const formatFullDate = (date: string) => 83 + new Date(date).toLocaleDateString("en-US", { 84 + year: "numeric", 85 + month: "long", 86 + day: "numeric", 87 + }); 88 + 89 + const fullStartDate = formatFullDate(startDate); 90 + const fullEndDate = endDate ? formatFullDate(endDate) : "Present"; 91 + const fullRange = `${fullStartDate} - ${fullEndDate}`; 92 + 93 + return ( 94 + <time 95 + dateTime={`${startDate}/${endDate || ""}`} 96 + title={fullRange} 97 + className={dateRangeVariants({ size, variant, className })} 98 + > 99 + {shortRange} 100 + </time> 101 + ); 102 + };
+123
packages/ui/src/components/IconButton.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const iconButtonVariants = cva( 5 + "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", 6 + { 7 + variants: { 8 + variant: { 9 + primary: "", 10 + secondary: "", 11 + outline: "border border-ctp-surface1 bg-transparent", 12 + ghost: "", 13 + destructive: "", 14 + }, 15 + size: { 16 + xs: "h-6 w-6 text-xs", 17 + sm: "h-8 w-8 text-sm", 18 + md: "h-10 w-10", 19 + lg: "h-12 w-12 text-lg", 20 + }, 21 + showColorOnHover: { 22 + true: "", 23 + false: "", 24 + }, 25 + }, 26 + compoundVariants: [ 27 + // Primary variants 28 + { 29 + variant: "primary", 30 + showColorOnHover: true, 31 + className: "text-ctp-text hover:bg-ctp-blue hover:text-ctp-base", 32 + }, 33 + { 34 + variant: "primary", 35 + showColorOnHover: false, 36 + className: "bg-ctp-blue text-ctp-base hover:bg-ctp-blue/90", 37 + }, 38 + // Secondary variants 39 + { 40 + variant: "secondary", 41 + showColorOnHover: true, 42 + className: "text-ctp-text hover:bg-ctp-surface1", 43 + }, 44 + { 45 + variant: "secondary", 46 + showColorOnHover: false, 47 + className: "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface1/80", 48 + }, 49 + // Outline variants 50 + { 51 + variant: "outline", 52 + showColorOnHover: true, 53 + className: "text-ctp-text hover:bg-ctp-surface0 hover:border-ctp-blue", 54 + }, 55 + { 56 + variant: "outline", 57 + showColorOnHover: false, 58 + className: "text-ctp-text hover:bg-ctp-surface0", 59 + }, 60 + // Ghost variants 61 + { 62 + variant: "ghost", 63 + showColorOnHover: true, 64 + className: "text-ctp-text hover:bg-ctp-surface0", 65 + }, 66 + { 67 + variant: "ghost", 68 + showColorOnHover: false, 69 + className: "text-ctp-text hover:bg-ctp-surface0", 70 + }, 71 + // Destructive variants 72 + { 73 + variant: "destructive", 74 + showColorOnHover: true, 75 + className: "text-ctp-text hover:bg-ctp-red hover:text-ctp-base", 76 + }, 77 + { 78 + variant: "destructive", 79 + showColorOnHover: false, 80 + className: "bg-ctp-red text-ctp-base hover:bg-ctp-red/90", 81 + }, 82 + ], 83 + defaultVariants: { 84 + variant: "ghost", 85 + size: "md", 86 + showColorOnHover: true, 87 + }, 88 + }, 89 + ); 90 + 91 + interface IconButtonProps extends VariantProps<typeof iconButtonVariants> { 92 + icon: React.ReactNode; 93 + label: string; 94 + onClick?: () => void; 95 + disabled?: boolean; 96 + className?: string; 97 + } 98 + 99 + export const IconButton = ({ 100 + icon, 101 + label, 102 + onClick, 103 + disabled = false, 104 + variant = "ghost", 105 + size = "md", 106 + className = "", 107 + showColorOnHover = true, 108 + }: IconButtonProps) => { 109 + return ( 110 + <button 111 + type="button" 112 + onClick={onClick} 113 + disabled={disabled} 114 + aria-label={label} 115 + className={cn( 116 + iconButtonVariants({ variant, size, showColorOnHover }), 117 + className, 118 + )} 119 + > 120 + {icon} 121 + </button> 122 + ); 123 + };
+26
packages/ui/src/components/PageHeader.tsx
··· 1 + import type { ReactNode } from "react"; 2 + import { cn } from "../lib/cn"; 3 + 4 + interface PageHeaderProps { 5 + title: string; 6 + description?: string; 7 + action?: ReactNode; 8 + className?: string; 9 + } 10 + 11 + export const PageHeader = ({ 12 + title, 13 + description, 14 + action, 15 + className, 16 + }: PageHeaderProps) => { 17 + return ( 18 + <div className={cn("flex items-center justify-between mb-6", className)}> 19 + <div> 20 + <h1 className="text-2xl font-bold text-ctp-text">{title}</h1> 21 + {description && <p className="text-ctp-subtext0 mt-1">{description}</p>} 22 + </div> 23 + {action && <div>{action}</div>} 24 + </div> 25 + ); 26 + };
+43
packages/ui/src/components/Placeholder.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const placeholderVariants = cva( 5 + "flex flex-col items-center justify-center text-center", 6 + { 7 + variants: { 8 + variant: { 9 + loading: "text-ctp-subtext0", 10 + error: "text-ctp-red", 11 + empty: "text-ctp-subtext0", 12 + }, 13 + }, 14 + defaultVariants: { 15 + variant: "loading", 16 + }, 17 + }, 18 + ); 19 + 20 + interface PlaceholderProps extends VariantProps<typeof placeholderVariants> { 21 + title?: string; 22 + message?: string; 23 + children?: React.ReactNode; 24 + className?: string; 25 + } 26 + 27 + export const Placeholder = ({ 28 + variant, 29 + title, 30 + message, 31 + children, 32 + className, 33 + }: PlaceholderProps) => { 34 + return ( 35 + <div className={cn(placeholderVariants({ variant }), className)}> 36 + {title && ( 37 + <h2 className="mb-2 text-2xl font-bold text-ctp-text">{title}</h2> 38 + )} 39 + {message && <div className="text-sm">{message}</div>} 40 + {children} 41 + </div> 42 + ); 43 + };
+177
packages/ui/src/components/RangeSlider.tsx
··· 1 + import React, { useCallback, useRef, useState } from "react"; 2 + import { cn } from "../lib/cn"; 3 + 4 + interface RangeSliderProps { 5 + min: number; 6 + max: number; 7 + value: [number, number]; 8 + onChange: (value: [number, number]) => void; 9 + step?: number; 10 + className?: string; 11 + disabled?: boolean; 12 + formatValue?: (value: number) => string; 13 + showLabels?: boolean; 14 + showValues?: boolean; 15 + } 16 + 17 + export const RangeSlider = ({ 18 + min, 19 + max, 20 + value, 21 + onChange, 22 + step = 1, 23 + className, 24 + disabled = false, 25 + formatValue = (val) => val.toLocaleString(), 26 + showLabels = true, 27 + showValues = true, 28 + }: RangeSliderProps) => { 29 + const [isDragging, setIsDragging] = useState<"min" | "max" | null>(null); 30 + const sliderRef = useRef<HTMLDivElement>(null); 31 + 32 + const getPercentage = useCallback( 33 + (val: number) => ((val - min) / (max - min)) * 100, 34 + [min, max], 35 + ); 36 + 37 + const getValueFromPosition = useCallback( 38 + (clientX: number) => { 39 + if (!sliderRef.current) return min; 40 + 41 + const rect = sliderRef.current.getBoundingClientRect(); 42 + const percentage = Math.max( 43 + 0, 44 + Math.min(1, (clientX - rect.left) / rect.width), 45 + ); 46 + const rawValue = min + percentage * (max - min); 47 + const steppedValue = Math.round(rawValue / step) * step; 48 + 49 + return Math.max(min, Math.min(max, steppedValue)); 50 + }, 51 + [min, max, step], 52 + ); 53 + 54 + const handleMouseDown = (e: React.MouseEvent, thumb: "min" | "max") => { 55 + if (disabled) return; 56 + 57 + e.preventDefault(); 58 + setIsDragging(thumb); 59 + 60 + const newValue = getValueFromPosition(e.clientX); 61 + const [currentMin, currentMax] = value; 62 + 63 + if (thumb === "min") { 64 + const newMin = Math.min(newValue, currentMax); 65 + onChange([newMin, currentMax]); 66 + } else { 67 + const newMax = Math.max(newValue, currentMin); 68 + onChange([currentMin, newMax]); 69 + } 70 + }; 71 + 72 + const handleMouseMove = useCallback( 73 + (e: MouseEvent) => { 74 + if (!isDragging || disabled) return; 75 + 76 + const newValue = getValueFromPosition(e.clientX); 77 + const [currentMin, currentMax] = value; 78 + 79 + if (isDragging === "min") { 80 + const newMin = Math.min(newValue, currentMax); 81 + onChange([newMin, currentMax]); 82 + } else { 83 + const newMax = Math.max(newValue, currentMin); 84 + onChange([currentMin, newMax]); 85 + } 86 + }, 87 + [isDragging, disabled, getValueFromPosition, value, onChange], 88 + ); 89 + 90 + const handleMouseUp = useCallback(() => { 91 + setIsDragging(null); 92 + }, []); 93 + 94 + React.useEffect(() => { 95 + if (isDragging) { 96 + document.addEventListener("mousemove", handleMouseMove); 97 + document.addEventListener("mouseup", handleMouseUp); 98 + 99 + return () => { 100 + document.removeEventListener("mousemove", handleMouseMove); 101 + document.removeEventListener("mouseup", handleMouseUp); 102 + }; 103 + } 104 + }, [isDragging, handleMouseMove, handleMouseUp]); 105 + 106 + const minPercentage = getPercentage(value[0]); 107 + const maxPercentage = getPercentage(value[1]); 108 + 109 + return ( 110 + <div className={cn("w-full", className)}> 111 + {showLabels && ( 112 + <div className="flex justify-between text-sm text-ctp-subtext0 mb-2"> 113 + <span>Min: {formatValue(min)}</span> 114 + <span>Max: {formatValue(max)}</span> 115 + </div> 116 + )} 117 + 118 + <div className="relative"> 119 + {/* Track */} 120 + <div 121 + ref={sliderRef} 122 + className={cn( 123 + "relative h-2 bg-ctp-surface1 rounded-full cursor-pointer", 124 + disabled && "opacity-50 cursor-not-allowed", 125 + )} 126 + > 127 + {/* Active range */} 128 + <div 129 + className="absolute h-2 bg-ctp-blue rounded-full" 130 + style={{ 131 + left: `${minPercentage}%`, 132 + width: `${maxPercentage - minPercentage}%`, 133 + }} 134 + /> 135 + 136 + {/* Min thumb */} 137 + <button 138 + type="button" 139 + className={cn( 140 + "absolute w-4 h-4 bg-ctp-blue border-2 border-ctp-base rounded-full cursor-grab transform -translate-y-1 -translate-x-1/2 transition-all duration-150", 141 + "hover:scale-110 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-offset-2", 142 + isDragging === "min" && "scale-110 shadow-lg cursor-grabbing", 143 + disabled && "cursor-not-allowed", 144 + )} 145 + style={{ left: `${minPercentage}%` }} 146 + onMouseDown={(e) => handleMouseDown(e, "min")} 147 + disabled={disabled} 148 + aria-label={`Minimum value: ${formatValue(value[0])}`} 149 + /> 150 + 151 + {/* Max thumb */} 152 + <button 153 + type="button" 154 + className={cn( 155 + "absolute w-4 h-4 bg-ctp-blue border-2 border-ctp-base rounded-full cursor-grab transform -translate-y-1 -translate-x-1/2 transition-all duration-150", 156 + "hover:scale-110 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-offset-2", 157 + isDragging === "max" && "scale-110 shadow-lg cursor-grabbing", 158 + disabled && "cursor-not-allowed", 159 + )} 160 + style={{ left: `${maxPercentage}%` }} 161 + onMouseDown={(e) => handleMouseDown(e, "max")} 162 + disabled={disabled} 163 + aria-label={`Maximum value: ${formatValue(value[1])}`} 164 + /> 165 + </div> 166 + 167 + {/* Value labels */} 168 + {showValues && ( 169 + <div className="flex justify-between mt-2 text-xs text-ctp-text"> 170 + <span className="font-medium">{formatValue(value[0])}</span> 171 + <span className="font-medium">{formatValue(value[1])}</span> 172 + </div> 173 + )} 174 + </div> 175 + </div> 176 + ); 177 + };
+238
packages/ui/src/components/SearchableSelect.tsx
··· 1 + import { useVirtualizer } from "@tanstack/react-virtual"; 2 + import { cva, type VariantProps } from "class-variance-authority"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { cn } from "../lib/cn"; 5 + 6 + const searchableSelectVariants = cva( 7 + "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", 8 + { 9 + variants: { 10 + state: { 11 + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", 12 + error: "border-ctp-red focus-visible:ring-ctp-red", 13 + }, 14 + }, 15 + defaultVariants: { 16 + state: "default", 17 + }, 18 + }, 19 + ); 20 + 21 + export interface SearchableSelectOption { 22 + value: string; 23 + label: string; 24 + } 25 + 26 + interface SearchableSelectProps 27 + extends Omit<VariantProps<typeof searchableSelectVariants>, "state"> { 28 + label?: string; 29 + placeholder?: string; 30 + value?: string; 31 + onChange?: (value: string) => void; 32 + disabled?: boolean; 33 + error?: string; 34 + options?: SearchableSelectOption[]; 35 + className?: string; 36 + id?: string; 37 + required?: boolean; 38 + // Pagination support 39 + hasNextPage?: boolean; 40 + onLoadMore?: () => void; 41 + isLoading?: boolean; 42 + // Add new option support 43 + allowAddNew?: boolean; 44 + onAddNew?: (label: string) => void; 45 + addNewLabel?: string; 46 + } 47 + 48 + export const SearchableSelect = ({ 49 + label, 50 + placeholder = "Search...", 51 + value, 52 + onChange, 53 + disabled = false, 54 + error, 55 + options = [], 56 + className = "", 57 + id, 58 + required = false, 59 + hasNextPage = false, 60 + onLoadMore, 61 + isLoading = false, 62 + allowAddNew = false, 63 + onAddNew, 64 + addNewLabel = "Add", 65 + }: SearchableSelectProps) => { 66 + const [isOpen, setIsOpen] = useState(false); 67 + const [searchTerm, setSearchTerm] = useState(""); 68 + const listRef = useRef<HTMLDivElement>(null); 69 + 70 + const selectId = 71 + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); 72 + const selectState = error ? "error" : "default"; 73 + 74 + const filteredOptions = options.filter((option) => 75 + option.label.toLowerCase().includes(searchTerm.toLowerCase()), 76 + ); 77 + 78 + // Check if search term doesn't match any option and add new is allowed 79 + const hasNoMatch = 80 + allowAddNew && 81 + searchTerm.trim().length > 0 && 82 + filteredOptions.length === 0 && 83 + !options.some( 84 + (opt) => opt.label.toLowerCase() === searchTerm.toLowerCase(), 85 + ); 86 + 87 + const selectedOption = options.find((opt) => opt.value === value); 88 + 89 + // Setup virtualizer for the dropdown list 90 + // Add 1 to count if we need to show "Add new" option 91 + const virtualizer = useVirtualizer({ 92 + count: filteredOptions.length + (hasNoMatch ? 1 : 0), 93 + getScrollElement: () => listRef.current, 94 + estimateSize: () => 40, 95 + overscan: 5, 96 + }); 97 + 98 + // Auto-load more when scrolling near the end 99 + const virtualItems = virtualizer.getVirtualItems(); 100 + 101 + useEffect(() => { 102 + if (!isOpen) return; 103 + 104 + const [lastItem] = [...virtualItems].reverse(); 105 + 106 + if (!lastItem) return; 107 + 108 + const shouldLoadMore = 109 + lastItem.index >= filteredOptions.length - 1 && hasNextPage && !isLoading; 110 + 111 + if (shouldLoadMore && onLoadMore) { 112 + onLoadMore(); 113 + } 114 + }, [ 115 + isOpen, 116 + hasNextPage, 117 + isLoading, 118 + onLoadMore, 119 + filteredOptions.length, 120 + virtualItems, 121 + ]); 122 + 123 + const handleFocus = () => { 124 + setIsOpen(true); 125 + setSearchTerm(""); 126 + }; 127 + 128 + return ( 129 + <div className="space-y-2 relative"> 130 + {label && ( 131 + <label htmlFor={selectId} className="text-sm font-medium text-ctp-text"> 132 + {label} 133 + </label> 134 + )} 135 + <div className="relative"> 136 + <input 137 + id={selectId} 138 + type="text" 139 + value={isOpen ? searchTerm : selectedOption?.label || ""} 140 + onChange={(e) => setSearchTerm(e.target.value)} 141 + onFocus={handleFocus} 142 + onBlur={() => { 143 + setTimeout(() => setIsOpen(false), 200); 144 + }} 145 + placeholder={placeholder} 146 + disabled={disabled} 147 + required={required} 148 + className={cn( 149 + searchableSelectVariants({ state: selectState }), 150 + className, 151 + )} 152 + /> 153 + {isOpen && (filteredOptions.length > 0 || hasNoMatch) && ( 154 + <div 155 + ref={listRef} 156 + className="absolute z-50 w-full mt-1 bg-ctp-surface0 border border-ctp-surface1 rounded-md shadow-lg max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-ctp-surface2 scrollbar-track-ctp-surface0 hover:scrollbar-thumb-ctp-overlay0" 157 + > 158 + <div 159 + style={{ 160 + height: `${virtualizer.getTotalSize()}px`, 161 + width: "100%", 162 + position: "relative", 163 + }} 164 + > 165 + {virtualItems.map((virtualRow) => { 166 + // Check if this is the "Add new" row 167 + if (hasNoMatch && virtualRow.index === filteredOptions.length) { 168 + return ( 169 + <button 170 + key="add-new" 171 + type="button" 172 + style={{ 173 + position: "absolute", 174 + top: 0, 175 + left: 0, 176 + width: "100%", 177 + height: `${virtualRow.size}px`, 178 + transform: `translateY(${virtualRow.start}px)`, 179 + }} 180 + className="text-left px-3 py-2 cursor-pointer hover:bg-ctp-surface1 text-sm text-ctp-blue transition-colors flex items-center gap-2" 181 + onMouseDown={(e) => { 182 + e.preventDefault(); 183 + onAddNew?.(searchTerm.trim()); 184 + setSearchTerm(""); 185 + setIsOpen(false); 186 + }} 187 + > 188 + <span className="text-ctp-blue">+</span> 189 + <span> 190 + {addNewLabel} "{searchTerm.trim()}" 191 + </span> 192 + </button> 193 + ); 194 + } 195 + 196 + const option = filteredOptions[virtualRow.index]; 197 + if (!option) return null; 198 + 199 + return ( 200 + <button 201 + key={option.value} 202 + type="button" 203 + style={{ 204 + position: "absolute", 205 + top: 0, 206 + left: 0, 207 + width: "100%", 208 + height: `${virtualRow.size}px`, 209 + transform: `translateY(${virtualRow.start}px)`, 210 + }} 211 + className="text-left px-3 py-2 cursor-pointer hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors" 212 + onMouseDown={() => { 213 + onChange?.(option.value); 214 + setSearchTerm(""); 215 + setIsOpen(false); 216 + }} 217 + > 218 + {option.label} 219 + </button> 220 + ); 221 + })} 222 + </div> 223 + 224 + {/* Loading indicator */} 225 + {isLoading && ( 226 + <div className="px-3 py-2 text-center border-t border-ctp-surface1"> 227 + <span className="text-xs text-ctp-subtext0"> 228 + Loading more... 229 + </span> 230 + </div> 231 + )} 232 + </div> 233 + )} 234 + </div> 235 + {error && <p className="text-sm text-ctp-red">{error}</p>} 236 + </div> 237 + ); 238 + };
+83
packages/ui/src/components/Select.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const selectVariants = cva( 5 + "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", 6 + { 7 + variants: { 8 + state: { 9 + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", 10 + error: "border-ctp-red focus-visible:ring-ctp-red", 11 + }, 12 + }, 13 + defaultVariants: { 14 + state: "default", 15 + }, 16 + }, 17 + ); 18 + 19 + interface SelectOption { 20 + value: string; 21 + label: string; 22 + } 23 + 24 + interface SelectProps 25 + extends Omit<VariantProps<typeof selectVariants>, "state"> { 26 + label?: string; 27 + placeholder?: string; 28 + value?: string; 29 + onChange?: (value: string) => void; 30 + disabled?: boolean; 31 + error?: string; 32 + options: SelectOption[]; 33 + className?: string; 34 + id?: string; 35 + required?: boolean; 36 + } 37 + 38 + export const Select = ({ 39 + label, 40 + placeholder, 41 + value, 42 + onChange, 43 + disabled = false, 44 + error, 45 + options, 46 + className = "", 47 + id, 48 + required = false, 49 + }: SelectProps) => { 50 + const selectId = 51 + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); 52 + const selectState = error ? "error" : "default"; 53 + 54 + return ( 55 + <div className="space-y-2"> 56 + {label && ( 57 + <label htmlFor={selectId} className="text-sm font-medium text-ctp-text"> 58 + {label} 59 + </label> 60 + )} 61 + <select 62 + id={selectId} 63 + value={value} 64 + onChange={(e) => onChange?.(e.target.value)} 65 + disabled={disabled} 66 + required={required} 67 + className={cn(selectVariants({ state: selectState }), className)} 68 + > 69 + {placeholder && ( 70 + <option value="" disabled> 71 + {placeholder} 72 + </option> 73 + )} 74 + {options.map((option) => ( 75 + <option key={option.value} value={option.value}> 76 + {option.label} 77 + </option> 78 + ))} 79 + </select> 80 + {error && <p className="text-sm text-ctp-red">{error}</p>} 81 + </div> 82 + ); 83 + };
+52
packages/ui/src/components/StatusBadge.tsx
··· 1 + import { cn } from "../lib/cn"; 2 + import { Badge } from "./Badge"; 3 + 4 + type Status = 5 + | "active" 6 + | "inactive" 7 + | "pending" 8 + | "success" 9 + | "error" 10 + | "warning"; 11 + 12 + interface StatusBadgeProps { 13 + status: Status; 14 + className?: string; 15 + } 16 + 17 + const statusConfig = { 18 + active: { 19 + label: "Active", 20 + color: "ctp-green" as const, 21 + }, 22 + inactive: { 23 + label: "Inactive", 24 + color: "ctp-gray" as const, 25 + }, 26 + pending: { 27 + label: "Pending", 28 + color: "ctp-yellow" as const, 29 + }, 30 + success: { 31 + label: "Success", 32 + color: "ctp-green" as const, 33 + }, 34 + error: { 35 + label: "Error", 36 + color: "ctp-red" as const, 37 + }, 38 + warning: { 39 + label: "Warning", 40 + color: "ctp-orange" as const, 41 + }, 42 + }; 43 + 44 + export const StatusBadge = ({ status, className = "" }: StatusBadgeProps) => { 45 + const config = statusConfig[status]; 46 + 47 + return ( 48 + <Badge color={config.color} className={cn("", className)}> 49 + {config.label} 50 + </Badge> 51 + ); 52 + };
+118
packages/ui/src/components/Table.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const tableVariants = cva("overflow-x-auto", { 5 + variants: { 6 + fullWidth: { 7 + true: "w-full", 8 + false: "", 9 + }, 10 + }, 11 + defaultVariants: { 12 + fullWidth: false, 13 + }, 14 + }); 15 + 16 + interface TableProps extends VariantProps<typeof tableVariants> { 17 + children: React.ReactNode; 18 + className?: string; 19 + } 20 + 21 + interface TableHeaderProps { 22 + children: React.ReactNode; 23 + className?: string; 24 + } 25 + 26 + interface TableBodyProps { 27 + children: React.ReactNode; 28 + className?: string; 29 + } 30 + 31 + interface TableRowProps { 32 + children: React.ReactNode; 33 + className?: string; 34 + } 35 + 36 + interface TableHeaderCellProps { 37 + children: React.ReactNode; 38 + className?: string; 39 + colSpan?: number; 40 + } 41 + 42 + interface TableCellProps { 43 + children: React.ReactNode; 44 + className?: string; 45 + colSpan?: number; 46 + } 47 + 48 + export const Table = ({ 49 + children, 50 + className = "", 51 + fullWidth = false, 52 + }: TableProps) => { 53 + return ( 54 + <div className={cn(tableVariants({ fullWidth }))}> 55 + <table 56 + className={cn( 57 + fullWidth 58 + ? "w-full divide-y divide-ctp-surface1" 59 + : "min-w-full divide-y divide-ctp-surface1", 60 + className, 61 + )} 62 + > 63 + {children} 64 + </table> 65 + </div> 66 + ); 67 + }; 68 + 69 + export const TableHeader = ({ children, className = "" }: TableHeaderProps) => { 70 + return <thead className={cn("bg-ctp-surface1", className)}>{children}</thead>; 71 + }; 72 + 73 + export const TableBody = ({ children, className = "" }: TableBodyProps) => { 74 + return ( 75 + <tbody className={cn("divide-y divide-ctp-surface1", className)}> 76 + {children} 77 + </tbody> 78 + ); 79 + }; 80 + 81 + export const TableRow = ({ children, className = "" }: TableRowProps) => { 82 + return ( 83 + <tr className={cn("hover:bg-ctp-surface0/50", className)}>{children}</tr> 84 + ); 85 + }; 86 + 87 + export const TableHeaderCell = ({ 88 + children, 89 + className = "", 90 + colSpan, 91 + }: TableHeaderCellProps) => { 92 + return ( 93 + <th 94 + className={cn( 95 + "px-6 py-4 text-left text-xs font-medium text-ctp-subtext0 uppercase tracking-wider", 96 + className, 97 + )} 98 + colSpan={colSpan} 99 + > 100 + {children} 101 + </th> 102 + ); 103 + }; 104 + 105 + export const TableCell = ({ 106 + children, 107 + className = "", 108 + colSpan, 109 + }: TableCellProps) => { 110 + return ( 111 + <td 112 + className={cn("px-6 py-4 text-sm text-ctp-text", className)} 113 + colSpan={colSpan} 114 + > 115 + {children} 116 + </td> 117 + ); 118 + };
+80
packages/ui/src/components/TextInput.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const inputVariants = cva( 5 + "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", 6 + { 7 + variants: { 8 + state: { 9 + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", 10 + success: "border-ctp-green focus-visible:ring-ctp-green", 11 + error: "border-ctp-red focus-visible:ring-ctp-red", 12 + }, 13 + }, 14 + defaultVariants: { 15 + state: "default", 16 + }, 17 + }, 18 + ); 19 + 20 + interface TextInputProps 21 + extends Omit<VariantProps<typeof inputVariants>, "state"> { 22 + label?: string; 23 + placeholder?: string; 24 + value?: string; 25 + onChange?: (value: string) => void; 26 + disabled?: boolean; 27 + error?: string; 28 + state?: "default" | "success" | "error"; 29 + type?: 30 + | "text" 31 + | "email" 32 + | "password" 33 + | "number" 34 + | "tel" 35 + | "url" 36 + | "date" 37 + | "datetime-local"; 38 + className?: string; 39 + id?: string; 40 + required?: boolean; 41 + } 42 + 43 + export const TextInput = ({ 44 + label, 45 + placeholder, 46 + value, 47 + onChange, 48 + disabled = false, 49 + error, 50 + state = "default", 51 + type = "text", 52 + className = "", 53 + id, 54 + required = false, 55 + }: TextInputProps) => { 56 + const inputId = 57 + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); 58 + const inputState = error ? "error" : state; 59 + 60 + return ( 61 + <div className="space-y-2"> 62 + {label && ( 63 + <label htmlFor={inputId} className="text-sm font-medium text-ctp-text"> 64 + {label} 65 + </label> 66 + )} 67 + <input 68 + id={inputId} 69 + type={type} 70 + placeholder={placeholder} 71 + value={value} 72 + onChange={(e) => onChange?.(e.target.value)} 73 + disabled={disabled} 74 + required={required} 75 + className={cn(inputVariants({ state: inputState }), className)} 76 + /> 77 + {error && <p className="text-sm text-ctp-red">{error}</p>} 78 + </div> 79 + ); 80 + };
+75
packages/ui/src/components/Textarea.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { cn } from "../lib/cn"; 3 + 4 + const textareaVariants = cva( 5 + "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", 6 + { 7 + variants: { 8 + state: { 9 + default: "border-ctp-surface1 focus-visible:ring-ctp-blue", 10 + success: "border-ctp-green focus-visible:ring-ctp-green", 11 + error: "border-ctp-red focus-visible:ring-ctp-red", 12 + }, 13 + }, 14 + defaultVariants: { 15 + state: "default", 16 + }, 17 + }, 18 + ); 19 + 20 + interface TextareaProps 21 + extends Omit<VariantProps<typeof textareaVariants>, "state"> { 22 + label?: string; 23 + placeholder?: string; 24 + value?: string; 25 + onChange?: (value: string) => void; 26 + disabled?: boolean; 27 + error?: string; 28 + state?: "default" | "success" | "error"; 29 + className?: string; 30 + id?: string; 31 + required?: boolean; 32 + rows?: number; 33 + } 34 + 35 + export const Textarea = ({ 36 + label, 37 + placeholder, 38 + value, 39 + onChange, 40 + disabled = false, 41 + error, 42 + state = "default", 43 + className = "", 44 + id, 45 + required = false, 46 + rows = 4, 47 + }: TextareaProps) => { 48 + const textareaId = 49 + id || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); 50 + const textareaState = error ? "error" : state; 51 + 52 + return ( 53 + <div className="space-y-2"> 54 + {label && ( 55 + <label 56 + htmlFor={textareaId} 57 + className="text-sm font-medium text-ctp-text" 58 + > 59 + {label} 60 + </label> 61 + )} 62 + <textarea 63 + id={textareaId} 64 + placeholder={placeholder} 65 + value={value} 66 + onChange={(e) => onChange?.(e.target.value)} 67 + disabled={disabled} 68 + required={required} 69 + rows={rows} 70 + className={cn(textareaVariants({ state: textareaState }), className)} 71 + /> 72 + {error && <p className="text-sm text-ctp-red">{error}</p>} 73 + </div> 74 + ); 75 + };
+22
packages/ui/src/components/icons/CheckIcon.tsx
··· 1 + interface CheckIconProps { 2 + className?: string; 3 + } 4 + 5 + export const CheckIcon = ({ className }: CheckIconProps) => { 6 + return ( 7 + <svg 8 + className={className} 9 + fill="none" 10 + stroke="currentColor" 11 + viewBox="0 0 24 24" 12 + > 13 + <title>Copied</title> 14 + <path 15 + strokeLinecap="round" 16 + strokeLinejoin="round" 17 + strokeWidth={2} 18 + d="M5 13l4 4L19 7" 19 + /> 20 + </svg> 21 + ); 22 + };
+27
packages/ui/src/components/icons/ChevronDownIcon.tsx
··· 1 + interface ChevronDownIconProps { 2 + className?: string; 3 + isOpen?: boolean; 4 + } 5 + 6 + export const ChevronDownIcon = ({ 7 + className = "", 8 + isOpen = false, 9 + }: ChevronDownIconProps) => { 10 + const rotation = isOpen ? "rotate-180" : ""; 11 + return ( 12 + <svg 13 + className={`${className} ${rotation} transition-transform`} 14 + fill="none" 15 + stroke="currentColor" 16 + viewBox="0 0 24 24" 17 + aria-hidden="true" 18 + > 19 + <path 20 + strokeLinecap="round" 21 + strokeLinejoin="round" 22 + strokeWidth={2} 23 + d="M19 9l-7 7-7-7" 24 + /> 25 + </svg> 26 + ); 27 + };
+25
packages/ui/src/components/icons/CloseIcon.tsx
··· 1 + interface CloseIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Close icon component 7 + */ 8 + export const CloseIcon = ({ className = "w-4 h-4" }: CloseIconProps) => { 9 + return ( 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 16 + <title>Close icon</title> 17 + <path 18 + strokeLinecap="round" 19 + strokeLinejoin="round" 20 + strokeWidth={2} 21 + d="M6 18L18 6M6 6l12 12" 22 + /> 23 + </svg> 24 + ); 25 + };
+25
packages/ui/src/components/icons/DeleteIcon.tsx
··· 1 + interface DeleteIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Delete icon component 7 + */ 8 + export const DeleteIcon = ({ className = "w-4 h-4" }: DeleteIconProps) => { 9 + return ( 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 16 + <title>Delete icon</title> 17 + <path 18 + strokeLinecap="round" 19 + strokeLinejoin="round" 20 + strokeWidth={2} 21 + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 22 + /> 23 + </svg> 24 + ); 25 + };
+19
packages/ui/src/components/icons/DocumentIcon.tsx
··· 1 + export const DocumentIcon = () => { 2 + return ( 3 + <svg 4 + className="w-5 h-5" 5 + fill="none" 6 + stroke="currentColor" 7 + viewBox="0 0 24 24" 8 + aria-label="Document icon" 9 + > 10 + <title>Document</title> 11 + <path 12 + strokeLinecap="round" 13 + strokeLinejoin="round" 14 + strokeWidth={2} 15 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 16 + /> 17 + </svg> 18 + ); 19 + };
+25
packages/ui/src/components/icons/EditIcon.tsx
··· 1 + interface EditIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Edit icon component 7 + */ 8 + export const EditIcon = ({ className = "w-4 h-4" }: EditIconProps) => { 9 + return ( 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 16 + <title>Edit icon</title> 17 + <path 18 + strokeLinecap="round" 19 + strokeLinejoin="round" 20 + strokeWidth={2} 21 + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" 22 + /> 23 + </svg> 24 + ); 25 + };
+19
packages/ui/src/components/icons/ErrorIcon.tsx
··· 1 + interface ErrorIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Error icon component 7 + */ 8 + export const ErrorIcon = ({ className = "w-5 h-5" }: ErrorIconProps) => { 9 + return ( 10 + <svg className={className} fill="currentColor" viewBox="0 0 20 20"> 11 + <title>Error icon</title> 12 + <path 13 + fillRule="evenodd" 14 + d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" 15 + clipRule="evenodd" 16 + /> 17 + </svg> 18 + ); 19 + };
+23
packages/ui/src/components/icons/LinkIcon.tsx
··· 1 + interface LinkIconProps { 2 + className?: string; 3 + } 4 + 5 + export const LinkIcon = ({ className }: LinkIconProps) => { 6 + return ( 7 + <svg 8 + className={className ?? "w-5 h-5"} 9 + fill="none" 10 + stroke="currentColor" 11 + viewBox="0 0 24 24" 12 + aria-label="Link icon" 13 + > 14 + <title>Link</title> 15 + <path 16 + strokeLinecap="round" 17 + strokeLinejoin="round" 18 + strokeWidth={2} 19 + d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" 20 + /> 21 + </svg> 22 + ); 23 + };
+31
packages/ui/src/components/icons/LoadingIcon.tsx
··· 1 + interface LoadingIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Loading spinner icon component 7 + */ 8 + export const LoadingIcon = ({ className = "w-4 h-4" }: LoadingIconProps) => { 9 + return ( 10 + <svg 11 + className={`${className} animate-spin`} 12 + fill="none" 13 + viewBox="0 0 24 24" 14 + > 15 + <title>Loading icon</title> 16 + <circle 17 + className="opacity-25" 18 + cx="12" 19 + cy="12" 20 + r="10" 21 + stroke="currentColor" 22 + strokeWidth="4" 23 + /> 24 + <path 25 + className="opacity-75" 26 + fill="currentColor" 27 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 28 + /> 29 + </svg> 30 + ); 31 + };
+27
packages/ui/src/components/icons/LocationIcon.tsx
··· 1 + interface LocationIconProps { 2 + className?: string; 3 + } 4 + 5 + export const LocationIcon = ({ className = "w-4 h-4" }: LocationIconProps) => ( 6 + <svg 7 + className={className} 8 + fill="none" 9 + stroke="currentColor" 10 + viewBox="0 0 24 24" 11 + aria-label="Location" 12 + > 13 + <title>Location icon</title> 14 + <path 15 + strokeLinecap="round" 16 + strokeLinejoin="round" 17 + strokeWidth={2} 18 + d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" 19 + /> 20 + <path 21 + strokeLinecap="round" 22 + strokeLinejoin="round" 23 + strokeWidth={2} 24 + d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" 25 + /> 26 + </svg> 27 + );
+21
packages/ui/src/components/icons/SalaryIcon.tsx
··· 1 + interface SalaryIconProps { 2 + className?: string; 3 + } 4 + 5 + export const SalaryIcon = ({ className = "w-4 h-4" }: SalaryIconProps) => ( 6 + <svg 7 + className={className} 8 + fill="none" 9 + stroke="currentColor" 10 + viewBox="0 0 24 24" 11 + aria-label="Salary" 12 + > 13 + <title>Salary icon</title> 14 + <path 15 + strokeLinecap="round" 16 + strokeLinejoin="round" 17 + strokeWidth={2} 18 + d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" 19 + /> 20 + </svg> 21 + );
+58
packages/ui/src/components/icons/ToastIcon.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + 3 + const iconVariants = cva("w-5 h-5", { 4 + variants: { 5 + level: { 6 + success: "text-green-600", 7 + warning: "text-yellow-600", 8 + info: "text-blue-600", 9 + error: "text-red-600", 10 + }, 11 + }, 12 + defaultVariants: { 13 + level: "info", 14 + }, 15 + }); 16 + 17 + interface ToastIconProps extends VariantProps<typeof iconVariants> { 18 + level: "success" | "warning" | "info" | "error"; 19 + } 20 + 21 + /** 22 + * Toast icon component with CVA styling 23 + */ 24 + export const ToastIcon = ({ level }: ToastIconProps) => { 25 + const iconProps = { 26 + className: iconVariants({ level }), 27 + fill: "none", 28 + stroke: "currentColor", 29 + viewBox: "0 0 24 24", 30 + }; 31 + 32 + return ( 33 + <svg {...iconProps}> 34 + <title>{level.charAt(0).toUpperCase() + level.slice(1)} icon</title> 35 + <path 36 + strokeLinecap="round" 37 + strokeLinejoin="round" 38 + strokeWidth={2} 39 + d={getIconPath(level)} 40 + /> 41 + </svg> 42 + ); 43 + }; 44 + 45 + /** 46 + * Get the SVG path for the given toast level 47 + */ 48 + function getIconPath(level: "success" | "warning" | "info" | "error"): string { 49 + const iconPaths = { 50 + success: "M5 13l4 4L19 7", 51 + warning: 52 + "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z", 53 + info: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", 54 + error: "M6 18L18 6M6 6l12 12", 55 + }; 56 + 57 + return iconPaths[level]; 58 + }
+19
packages/ui/src/components/icons/UploadIcon.tsx
··· 1 + export const UploadIcon = () => { 2 + return ( 3 + <svg 4 + className="w-5 h-5" 5 + fill="none" 6 + stroke="currentColor" 7 + viewBox="0 0 24 24" 8 + aria-label="Upload icon" 9 + > 10 + <title>Upload</title> 11 + <path 12 + strokeLinecap="round" 13 + strokeLinejoin="round" 14 + strokeWidth={2} 15 + d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" 16 + /> 17 + </svg> 18 + ); 19 + };
+13
packages/ui/src/components/icons/index.ts
··· 1 + export { CheckIcon } from "./CheckIcon"; 2 + export { ChevronDownIcon } from "./ChevronDownIcon"; 3 + export { CloseIcon } from "./CloseIcon"; 4 + export { DeleteIcon } from "./DeleteIcon"; 5 + export { DocumentIcon } from "./DocumentIcon"; 6 + export { EditIcon } from "./EditIcon"; 7 + export { ErrorIcon } from "./ErrorIcon"; 8 + export { LinkIcon } from "./LinkIcon"; 9 + export { LoadingIcon } from "./LoadingIcon"; 10 + export { LocationIcon } from "./LocationIcon"; 11 + export { SalaryIcon } from "./SalaryIcon"; 12 + export { ToastIcon } from "./ToastIcon"; 13 + export { UploadIcon } from "./UploadIcon";
+18
packages/ui/src/components/index.ts
··· 1 + // UI Components 2 + 3 + export * from "./Badge"; 4 + export * from "./Button"; 5 + export * from "./Card"; 6 + export * from "./Checkbox"; 7 + export * from "./IconButton"; 8 + // Icons 9 + export * from "./icons"; 10 + export * from "./PageHeader"; 11 + export * from "./Placeholder"; 12 + export * from "./RangeSlider"; 13 + export * from "./SearchableSelect"; 14 + export * from "./Select"; 15 + export * from "./StatusBadge"; 16 + export * from "./Table"; 17 + export * from "./Textarea"; 18 + export * from "./TextInput";
+53
packages/ui/src/index.css
··· 1 + @theme { 2 + /* Catppuccin Mocha color palette */ 3 + --color-ctp-rosewater: #f5e0dc; 4 + --color-ctp-flamingo: #f2cdcd; 5 + --color-ctp-pink: #f5c2e7; 6 + --color-ctp-mauve: #cba6f7; 7 + --color-ctp-red: #f38ba8; 8 + --color-ctp-maroon: #eba0ac; 9 + --color-ctp-peach: #fab387; 10 + --color-ctp-yellow: #f9e2af; 11 + --color-ctp-green: #a6e3a1; 12 + --color-ctp-teal: #94e2d5; 13 + --color-ctp-sky: #89dceb; 14 + --color-ctp-sapphire: #74c7ec; 15 + --color-ctp-blue: #89b4fa; 16 + --color-ctp-lavender: #b4befe; 17 + --color-ctp-text: #cdd6f4; 18 + --color-ctp-subtext1: #bac2de; 19 + --color-ctp-subtext0: #a6adc8; 20 + --color-ctp-overlay2: #9399b2; 21 + --color-ctp-overlay1: #7f849c; 22 + --color-ctp-overlay0: #6c7086; 23 + --color-ctp-surface2: #585b70; 24 + --color-ctp-surface1: #45475a; 25 + --color-ctp-surface0: #313244; 26 + --color-ctp-base: #1e1e2e; 27 + --color-ctp-mantle: #181825; 28 + --color-ctp-crust: #11111b; 29 + } 30 + 31 + /* Custom scrollbar styles provided by the UI package */ 32 + .scrollbar-thin { 33 + scrollbar-width: thin; 34 + } 35 + .scrollbar-thin::-webkit-scrollbar { 36 + width: 8px; 37 + height: 8px; 38 + } 39 + .scrollbar-thumb-ctp-surface2::-webkit-scrollbar-thumb { 40 + background-color: rgb(var(--ctp-surface2) / 1); 41 + border-radius: 4px; 42 + } 43 + .scrollbar-track-ctp-surface0::-webkit-scrollbar-track { 44 + background-color: rgb(var(--ctp-surface0) / 1); 45 + } 46 + .hover\:scrollbar-thumb-ctp-overlay0:hover::-webkit-scrollbar-thumb { 47 + background-color: rgb(var(--ctp-overlay0) / 1); 48 + } 49 + 50 + .cv-calendar-grid { 51 + display: grid; 52 + grid-template-columns: repeat(7, minmax(0, 1fr)); 53 + }
+45
packages/ui/src/index.ts
··· 1 + // Utilities 2 + 3 + // Display Components 4 + export { Badge } from "./components/Badge"; 5 + // Form Components 6 + export { Button } from "./components/Button"; 7 + export { Calendar } from "./components/Calendar"; 8 + export { Checkbox } from "./components/Checkbox"; 9 + // Date Components 10 + export { FormattedDate } from "./components/FormattedDate"; 11 + export { FormattedDateRange } from "./components/FormattedDateRange"; 12 + export { IconButton } from "./components/IconButton"; 13 + // Icons 14 + export { 15 + CheckIcon, 16 + ChevronDownIcon, 17 + CloseIcon, 18 + DeleteIcon, 19 + DocumentIcon, 20 + EditIcon, 21 + ErrorIcon, 22 + LinkIcon, 23 + LoadingIcon, 24 + LocationIcon, 25 + SalaryIcon, 26 + ToastIcon, 27 + UploadIcon, 28 + } from "./components/icons"; 29 + export { PageHeader } from "./components/PageHeader"; 30 + export { Placeholder } from "./components/Placeholder"; 31 + export { RangeSlider } from "./components/RangeSlider"; 32 + export { SearchableSelect } from "./components/SearchableSelect"; 33 + export { Select } from "./components/Select"; 34 + export { StatusBadge } from "./components/StatusBadge"; 35 + export { 36 + Table, 37 + TableBody, 38 + TableCell, 39 + TableHeader, 40 + TableHeaderCell, 41 + TableRow, 42 + } from "./components/Table"; 43 + export { Textarea } from "./components/Textarea"; 44 + export { TextInput } from "./components/TextInput"; 45 + export { cn } from "./lib/cn";
+10
packages/ui/src/lib/cn.ts
··· 1 + import { type ClassValue, clsx } from "clsx"; 2 + import { twMerge } from "tailwind-merge"; 3 + 4 + /** 5 + * Utility function to merge Tailwind CSS classes 6 + * Combines clsx for conditional classes and tailwind-merge to resolve conflicts 7 + */ 8 + export const cn = (...inputs: ClassValue[]) => { 9 + return twMerge(clsx(inputs)); 10 + };
+54
packages/ui/src/lib/date.ts
··· 1 + export type DateDisplayFormat = "short" | "long" | "time"; 2 + 3 + export const formatDisplayDate = ( 4 + date: Date, 5 + format: DateDisplayFormat, 6 + ): string => { 7 + if (format === "long") { 8 + return date.toLocaleDateString("en-US", { 9 + weekday: "long", 10 + year: "numeric", 11 + month: "long", 12 + day: "numeric", 13 + }); 14 + } 15 + 16 + if (format === "time") { 17 + return date.toLocaleTimeString("en-US", { 18 + hour: "2-digit", 19 + minute: "2-digit", 20 + }); 21 + } 22 + 23 + return date.toLocaleDateString("en-US", { 24 + month: "short", 25 + day: "numeric", 26 + year: "numeric", 27 + }); 28 + }; 29 + 30 + export const getDaysInMonth = (month: number, year: number): number => { 31 + return new Date(year, month + 1, 0).getDate(); 32 + }; 33 + 34 + export const getFirstDayOfMonth = (month: number, year: number): number => { 35 + return new Date(year, month, 1).getDay(); 36 + }; 37 + 38 + export const isSameDate = (date1: Date, date2: Date): boolean => { 39 + return date1.toDateString() === date2.toDateString(); 40 + }; 41 + 42 + export const isToday = (date: Date): boolean => { 43 + return isSameDate(date, new Date()); 44 + }; 45 + 46 + export const isDateInRange = ( 47 + date: Date, 48 + minDate?: Date | null, 49 + maxDate?: Date | null, 50 + ): boolean => { 51 + if (minDate && date < minDate) return false; 52 + if (maxDate && date > maxDate) return false; 53 + return true; 54 + };
+12
packages/ui/tsconfig.json
··· 1 + { 2 + "extends": "../tsconfig/tsconfig.library.json", 3 + "compilerOptions": { 4 + "jsx": "react-jsx", 5 + "declaration": true, 6 + "declarationMap": true, 7 + "outDir": "./dist", 8 + "rootDir": "./src" 9 + }, 10 + "include": ["src"], 11 + "exclude": ["node_modules", "dist"] 12 + }