apex-design-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +747 -0
  3. package/package.json +57 -0
  4. package/registry/components/accordion.json +26 -0
  5. package/registry/components/alert.json +25 -0
  6. package/registry/components/avatar.json +26 -0
  7. package/registry/components/badge.json +24 -0
  8. package/registry/components/breadcrumb.json +28 -0
  9. package/registry/components/button.json +25 -0
  10. package/registry/components/card.json +28 -0
  11. package/registry/components/checkbox.json +23 -0
  12. package/registry/components/command.json +31 -0
  13. package/registry/components/dialog.json +32 -0
  14. package/registry/components/divider.json +22 -0
  15. package/registry/components/dropdown-menu.json +36 -0
  16. package/registry/components/empty-state.json +21 -0
  17. package/registry/components/error-message.json +20 -0
  18. package/registry/components/field-group.json +20 -0
  19. package/registry/components/helper-text.json +20 -0
  20. package/registry/components/input.json +21 -0
  21. package/registry/components/label.json +20 -0
  22. package/registry/components/progress.json +22 -0
  23. package/registry/components/radio.json +23 -0
  24. package/registry/components/select.json +32 -0
  25. package/registry/components/spinner.json +20 -0
  26. package/registry/components/switch.json +22 -0
  27. package/registry/components/table.json +27 -0
  28. package/registry/components/tabs.json +25 -0
  29. package/registry/components/textarea.json +21 -0
  30. package/registry/components/theme-toggler.json +24 -0
  31. package/registry/components/toast.json +31 -0
  32. package/registry/components/tooltip.json +26 -0
  33. package/registry/components/use-theme.json +19 -0
  34. package/registry/components/utils.json +21 -0
  35. package/registry/registry.json +35 -0
  36. package/registry/source/accordion.tsx +55 -0
  37. package/registry/source/alert.tsx +102 -0
  38. package/registry/source/avatar.tsx +137 -0
  39. package/registry/source/badge.tsx +38 -0
  40. package/registry/source/breadcrumb.tsx +109 -0
  41. package/registry/source/button.tsx +58 -0
  42. package/registry/source/card.tsx +108 -0
  43. package/registry/source/checkbox.tsx +170 -0
  44. package/registry/source/command.tsx +195 -0
  45. package/registry/source/dialog.tsx +133 -0
  46. package/registry/source/divider.tsx +84 -0
  47. package/registry/source/dropdown-menu.tsx +209 -0
  48. package/registry/source/empty-state.tsx +88 -0
  49. package/registry/source/error-message.tsx +49 -0
  50. package/registry/source/field-group.tsx +53 -0
  51. package/registry/source/helper-text.tsx +40 -0
  52. package/registry/source/input.tsx +219 -0
  53. package/registry/source/label.tsx +60 -0
  54. package/registry/source/progress.tsx +84 -0
  55. package/registry/source/radio.tsx +161 -0
  56. package/registry/source/select.tsx +278 -0
  57. package/registry/source/spinner.tsx +84 -0
  58. package/registry/source/switch.tsx +104 -0
  59. package/registry/source/table.tsx +116 -0
  60. package/registry/source/tabs.tsx +55 -0
  61. package/registry/source/textarea.tsx +129 -0
  62. package/registry/source/theme-toggler.tsx +94 -0
  63. package/registry/source/toast.tsx +166 -0
  64. package/registry/source/tooltip.tsx +55 -0
  65. package/registry/source/use-theme.tsx +102 -0
  66. package/registry/source/utils.ts +13 -0
@@ -0,0 +1,129 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../lib/utils';
3
+
4
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
5
+ /** Additional CSS classes */
6
+ className?: string;
7
+ /** Textarea size */
8
+ size?: 'sm' | 'md' | 'lg';
9
+ /** Error state */
10
+ error?: boolean;
11
+ /** Label text */
12
+ label?: string;
13
+ /** Helper text shown below textarea */
14
+ helperText?: string;
15
+ /** Error message shown when error={true} */
16
+ errorMessage?: string;
17
+ /** Mark as required */
18
+ required?: boolean;
19
+ /** Show character count */
20
+ showCount?: boolean;
21
+ /** Resize behavior */
22
+ resize?: 'none' | 'vertical' | 'both';
23
+ }
24
+
25
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
26
+ (
27
+ {
28
+ className,
29
+ size = 'md',
30
+ error = false,
31
+ label,
32
+ helperText,
33
+ errorMessage,
34
+ required,
35
+ showCount = false,
36
+ resize = 'vertical',
37
+ maxLength,
38
+ value,
39
+ id,
40
+ ...props
41
+ },
42
+ ref
43
+ ) => {
44
+ const textareaId = id || React.useId();
45
+ const helperTextId = `${textareaId}-helper`;
46
+ const errorMessageId = `${textareaId}-error`;
47
+
48
+ const currentLength = typeof value === 'string' ? value.length : 0;
49
+
50
+ const sizeClasses = {
51
+ sm: 'min-h-[80px] px-3 py-2 text-sm',
52
+ md: 'min-h-[120px] px-3 py-2 text-base',
53
+ lg: 'min-h-[160px] px-4 py-3 text-lg',
54
+ };
55
+
56
+ const resizeClasses = {
57
+ none: 'resize-none',
58
+ vertical: 'resize-y',
59
+ both: 'resize',
60
+ };
61
+
62
+ return (
63
+ <div className="w-full">
64
+ <div className="flex items-center justify-between mb-1.5">
65
+ {label && (
66
+ <label
67
+ htmlFor={textareaId}
68
+ className="block text-sm font-medium text-semantic-fg-secondary"
69
+ >
70
+ {label}
71
+ {required && <span className="ml-1 text-semantic-fg-error">*</span>}
72
+ </label>
73
+ )}
74
+ {showCount && maxLength && (
75
+ <span className="text-xs text-semantic-fg-secondary">
76
+ {currentLength}/{maxLength}
77
+ </span>
78
+ )}
79
+ </div>
80
+
81
+ <textarea
82
+ ref={ref}
83
+ id={textareaId}
84
+ value={value}
85
+ maxLength={maxLength}
86
+ className={cn(
87
+ 'w-full rounded-md border bg-semantic-control-bg transition-colors',
88
+ 'placeholder:text-semantic-control-placeholder',
89
+ 'focus:outline-none focus:ring-2 focus:ring-offset-0',
90
+ 'disabled:cursor-not-allowed disabled:bg-semantic-bg-disabled disabled:text-semantic-fg-disabled',
91
+ sizeClasses[size],
92
+ resizeClasses[resize],
93
+ error
94
+ ? 'border-semantic-control-border-error text-semantic-control-fg focus:ring-semantic-control-border-error focus:border-semantic-control-border-error'
95
+ : 'border-semantic-control-border text-semantic-control-fg focus:ring-semantic-focus focus:border-semantic-border-focus',
96
+ className
97
+ )}
98
+ aria-invalid={error ? 'true' : 'false'}
99
+ aria-describedby={
100
+ error && errorMessage
101
+ ? errorMessageId
102
+ : helperText
103
+ ? helperTextId
104
+ : undefined
105
+ }
106
+ aria-required={required}
107
+ {...props}
108
+ />
109
+
110
+ {!error && helperText && (
111
+ <p id={helperTextId} className="mt-1.5 text-sm text-semantic-fg-secondary">
112
+ {helperText}
113
+ </p>
114
+ )}
115
+
116
+ {error && errorMessage && (
117
+ <p id={errorMessageId} className="mt-1.5 text-sm text-semantic-fg-error">
118
+ {errorMessage}
119
+ </p>
120
+ )}
121
+ </div>
122
+ );
123
+ }
124
+ );
125
+
126
+ Textarea.displayName = 'Textarea';
127
+
128
+ export { Textarea };
129
+ export default Textarea;
@@ -0,0 +1,94 @@
1
+ import { Sun, Moon, Monitor } from 'lucide-react';
2
+ import { cn } from '../../lib/utils';
3
+ import { useTheme } from '../../hooks/useTheme';
4
+
5
+ export interface ThemeTogglerProps {
6
+ /** Show system option alongside light/dark */
7
+ showSystem?: boolean;
8
+ /** Size of the toggler */
9
+ size?: 'sm' | 'md' | 'lg';
10
+ /** Additional CSS classes */
11
+ className?: string;
12
+ }
13
+
14
+ const sizeMap = {
15
+ sm: { button: 'h-8 w-8', icon: 'h-4 w-4' },
16
+ md: { button: 'h-9 w-9', icon: 'h-5 w-5' },
17
+ lg: { button: 'h-10 w-10', icon: 'h-6 w-6' },
18
+ };
19
+
20
+ export function ThemeToggler({ showSystem = false, size = 'md', className }: ThemeTogglerProps) {
21
+ const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
22
+ const { button: buttonSize, icon: iconSize } = sizeMap[size];
23
+
24
+ if (showSystem) {
25
+ return (
26
+ <div role="radiogroup" aria-label="Theme selector" className={cn('inline-flex items-center rounded-lg border border-semantic-border-default bg-semantic-bg-sunken p-1', className)}>
27
+ <button
28
+ type="button"
29
+ role="radio"
30
+ aria-checked={theme === 'light'}
31
+ onClick={() => setTheme('light')}
32
+ className={cn(
33
+ 'inline-flex items-center justify-center rounded-md p-1.5 transition-colors',
34
+ theme === 'light'
35
+ ? 'bg-semantic-bg-segment-active text-semantic-fg-segment-active shadow-sm'
36
+ : 'text-semantic-fg-muted hover:text-semantic-fg-secondary'
37
+ )}
38
+ aria-label="Light mode"
39
+ >
40
+ <Sun className={iconSize} aria-hidden="true" />
41
+ </button>
42
+ <button
43
+ type="button"
44
+ role="radio"
45
+ aria-checked={theme === 'dark'}
46
+ onClick={() => setTheme('dark')}
47
+ className={cn(
48
+ 'inline-flex items-center justify-center rounded-md p-1.5 transition-colors',
49
+ theme === 'dark'
50
+ ? 'bg-semantic-bg-segment-active text-semantic-fg-segment-active shadow-sm'
51
+ : 'text-semantic-fg-muted hover:text-semantic-fg-secondary'
52
+ )}
53
+ aria-label="Dark mode"
54
+ >
55
+ <Moon className={iconSize} aria-hidden="true" />
56
+ </button>
57
+ <button
58
+ type="button"
59
+ role="radio"
60
+ aria-checked={theme === 'system'}
61
+ onClick={() => setTheme('system')}
62
+ className={cn(
63
+ 'inline-flex items-center justify-center rounded-md p-1.5 transition-colors',
64
+ theme === 'system'
65
+ ? 'bg-semantic-bg-segment-active text-semantic-fg-segment-active shadow-sm'
66
+ : 'text-semantic-fg-muted hover:text-semantic-fg-secondary'
67
+ )}
68
+ aria-label="System theme"
69
+ >
70
+ <Monitor className={iconSize} aria-hidden="true" />
71
+ </button>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <button
78
+ type="button"
79
+ onClick={toggleTheme}
80
+ className={cn(
81
+ buttonSize,
82
+ 'inline-flex items-center justify-center rounded-md border border-semantic-border-default bg-semantic-bg-elevated text-semantic-fg-secondary transition-colors hover:bg-semantic-bg-hover hover:text-semantic-fg-primary',
83
+ className
84
+ )}
85
+ aria-label={resolvedTheme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
86
+ >
87
+ {resolvedTheme === 'light' ? (
88
+ <Moon className={iconSize} aria-hidden="true" />
89
+ ) : (
90
+ <Sun className={iconSize} aria-hidden="true" />
91
+ )}
92
+ </button>
93
+ );
94
+ }
@@ -0,0 +1,166 @@
1
+ import * as React from 'react';
2
+ import * as ToastPrimitives from '@radix-ui/react-toast';
3
+ import { cva, type VariantProps } from 'class-variance-authority';
4
+ import { X, CheckCircle2, XCircle, AlertCircle, Info } from 'lucide-react';
5
+ import { cn } from '../../lib/utils';
6
+
7
+ const ToastProvider = ToastPrimitives.Provider;
8
+
9
+ const ToastViewport = React.forwardRef<
10
+ React.ElementRef<typeof ToastPrimitives.Viewport>,
11
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
12
+ >(({ className, ...props }, ref) => (
13
+ <ToastPrimitives.Viewport
14
+ ref={ref}
15
+ aria-live="polite"
16
+ aria-atomic="true"
17
+ className={cn(
18
+ 'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ ));
24
+ ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
25
+
26
+ const toastVariants = cva(
27
+ 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
28
+ {
29
+ variants: {
30
+ variant: {
31
+ default: 'border-semantic-border-default bg-semantic-bg-elevated text-semantic-fg-primary',
32
+ success: 'success border-semantic-status-success-border bg-semantic-status-success-bg text-semantic-status-success-fg',
33
+ error: 'error border-semantic-status-error-border bg-semantic-status-error-bg text-semantic-status-error-fg',
34
+ warning: 'warning border-semantic-status-warning-border bg-semantic-status-warning-bg text-semantic-status-warning-fg',
35
+ info: 'info border-semantic-status-info-border bg-semantic-status-info-bg text-semantic-status-info-fg',
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ variant: 'default',
40
+ },
41
+ }
42
+ );
43
+
44
+ const Toast = React.forwardRef<
45
+ React.ElementRef<typeof ToastPrimitives.Root>,
46
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
47
+ VariantProps<typeof toastVariants>
48
+ >(({ className, variant, ...props }, ref) => {
49
+ return (
50
+ <ToastPrimitives.Root
51
+ ref={ref}
52
+ className={cn(toastVariants({ variant }), className)}
53
+ {...props}
54
+ />
55
+ );
56
+ });
57
+ Toast.displayName = ToastPrimitives.Root.displayName;
58
+
59
+ const ToastAction = React.forwardRef<
60
+ React.ElementRef<typeof ToastPrimitives.Action>,
61
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
62
+ >(({ className, ...props }, ref) => (
63
+ <ToastPrimitives.Action
64
+ ref={ref}
65
+ className={cn(
66
+ 'inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-semantic-border-default bg-transparent px-3 text-sm font-medium ring-offset-semantic-offset transition-colors hover:bg-semantic-bg-hover focus:outline-none focus:ring-2 focus:ring-semantic-focus focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.success]:border-semantic-status-success-border group-[.success]:hover:bg-semantic-status-success-bg group-[.error]:border-semantic-status-error-border group-[.error]:hover:bg-semantic-status-error-bg',
67
+ className
68
+ )}
69
+ {...props}
70
+ />
71
+ ));
72
+ ToastAction.displayName = ToastPrimitives.Action.displayName;
73
+
74
+ const ToastClose = React.forwardRef<
75
+ React.ElementRef<typeof ToastPrimitives.Close>,
76
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
77
+ >(({ className, ...props }, ref) => (
78
+ <ToastPrimitives.Close
79
+ ref={ref}
80
+ className={cn(
81
+ 'absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100',
82
+ className
83
+ )}
84
+ toast-close=""
85
+ {...props}
86
+ >
87
+ <X className="h-4 w-4" aria-hidden="true" />
88
+ <span className="sr-only">Close</span>
89
+ </ToastPrimitives.Close>
90
+ ));
91
+ ToastClose.displayName = ToastPrimitives.Close.displayName;
92
+
93
+ const ToastTitle = React.forwardRef<
94
+ React.ElementRef<typeof ToastPrimitives.Title>,
95
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
96
+ >(({ className, ...props }, ref) => (
97
+ <ToastPrimitives.Title
98
+ ref={ref}
99
+ className={cn('text-sm font-semibold', className)}
100
+ {...props}
101
+ />
102
+ ));
103
+ ToastTitle.displayName = ToastPrimitives.Title.displayName;
104
+
105
+ const ToastDescription = React.forwardRef<
106
+ React.ElementRef<typeof ToastPrimitives.Description>,
107
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
108
+ >(({ className, ...props }, ref) => (
109
+ <ToastPrimitives.Description
110
+ ref={ref}
111
+ className={cn('text-sm opacity-90', className)}
112
+ {...props}
113
+ />
114
+ ));
115
+ ToastDescription.displayName = ToastPrimitives.Description.displayName;
116
+
117
+ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
118
+
119
+ type ToastActionElement = React.ReactElement<typeof ToastAction>;
120
+
121
+ export interface ToastContentProps {
122
+ title?: string;
123
+ description?: string;
124
+ action?: ToastActionElement;
125
+ variant?: 'default' | 'success' | 'error' | 'warning' | 'info';
126
+ icon?: React.ReactNode;
127
+ showIcon?: boolean;
128
+ }
129
+
130
+ const ToastContent = React.forwardRef<HTMLDivElement, ToastContentProps>(
131
+ ({ title, description, icon, showIcon = true, variant = 'default' }, ref) => {
132
+ const defaultIcons = {
133
+ default: <Info className="h-5 w-5" />,
134
+ info: <Info className="h-5 w-5 text-semantic-status-info-icon" />,
135
+ success: <CheckCircle2 className="h-5 w-5 text-semantic-status-success-icon" />,
136
+ error: <XCircle className="h-5 w-5 text-semantic-status-error-icon" />,
137
+ warning: <AlertCircle className="h-5 w-5 text-semantic-status-warning-icon" />,
138
+ };
139
+
140
+ const iconToRender = icon || defaultIcons[variant];
141
+
142
+ return (
143
+ <div ref={ref} className="flex gap-3">
144
+ {showIcon && <div className="flex-shrink-0" aria-hidden="true">{iconToRender}</div>}
145
+ <div className="grid gap-1">
146
+ {title && <ToastTitle>{title}</ToastTitle>}
147
+ {description && <ToastDescription>{description}</ToastDescription>}
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+ );
153
+ ToastContent.displayName = 'ToastContent';
154
+
155
+ export {
156
+ type ToastProps,
157
+ type ToastActionElement,
158
+ ToastProvider,
159
+ ToastViewport,
160
+ Toast,
161
+ ToastTitle,
162
+ ToastDescription,
163
+ ToastClose,
164
+ ToastAction,
165
+ ToastContent,
166
+ };
@@ -0,0 +1,55 @@
1
+ import * as React from 'react';
2
+ import * as TooltipPrimitive from '@radix-ui/react-tooltip';
3
+ import { cn } from '../../lib/utils';
4
+
5
+ const TooltipProvider = TooltipPrimitive.Provider;
6
+
7
+ const Tooltip = TooltipPrimitive.Root;
8
+
9
+ const TooltipTrigger = TooltipPrimitive.Trigger;
10
+
11
+ const TooltipContent = React.forwardRef<
12
+ React.ElementRef<typeof TooltipPrimitive.Content>,
13
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
14
+ >(({ className, sideOffset = 4, ...props }, ref) => (
15
+ <TooltipPrimitive.Content
16
+ ref={ref}
17
+ sideOffset={sideOffset}
18
+ className={cn(
19
+ 'z-50 overflow-hidden rounded-md border border-semantic-border-default bg-semantic-bg-elevated px-3 py-1.5 text-sm text-semantic-fg-primary shadow-md',
20
+ 'animate-in fade-in-0 zoom-in-95',
21
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
22
+ 'data-[side=bottom]:slide-in-from-top-2',
23
+ 'data-[side=left]:slide-in-from-right-2',
24
+ 'data-[side=right]:slide-in-from-left-2',
25
+ 'data-[side=top]:slide-in-from-bottom-2',
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ ));
31
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
32
+
33
+ export interface SimpleTooltipProps {
34
+ content: React.ReactNode;
35
+ children: React.ReactElement;
36
+ side?: 'top' | 'right' | 'bottom' | 'left';
37
+ delayDuration?: number;
38
+ className?: string;
39
+ }
40
+
41
+ const SimpleTooltip = React.forwardRef<HTMLDivElement, SimpleTooltipProps>(
42
+ ({ content, children, side = 'top', delayDuration = 200, className }, ref) => {
43
+ return (
44
+ <Tooltip delayDuration={delayDuration}>
45
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
46
+ <TooltipContent side={side} className={className} ref={ref}>
47
+ {content}
48
+ </TooltipContent>
49
+ </Tooltip>
50
+ );
51
+ }
52
+ );
53
+ SimpleTooltip.displayName = 'SimpleTooltip';
54
+
55
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip };
@@ -0,0 +1,102 @@
1
+ import { createContext, useContext, useEffect, useState, useCallback } from 'react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ type Theme = 'light' | 'dark' | 'system';
5
+ type ResolvedTheme = 'light' | 'dark';
6
+
7
+ interface ThemeContextValue {
8
+ theme: Theme;
9
+ resolvedTheme: ResolvedTheme;
10
+ setTheme: (theme: Theme) => void;
11
+ toggleTheme: () => void;
12
+ }
13
+
14
+ const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
15
+
16
+ const STORAGE_KEY = 'ds-theme';
17
+
18
+ function getSystemTheme(): ResolvedTheme {
19
+ if (typeof window === 'undefined') return 'light';
20
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
21
+ }
22
+
23
+ function resolveTheme(theme: Theme): ResolvedTheme {
24
+ return theme === 'system' ? getSystemTheme() : theme;
25
+ }
26
+
27
+ function applyTheme(resolved: ResolvedTheme) {
28
+ const root = document.documentElement;
29
+ root.classList.remove('light', 'dark');
30
+ root.classList.add(resolved);
31
+ }
32
+
33
+ export interface ThemeProviderProps {
34
+ children: ReactNode;
35
+ defaultTheme?: Theme;
36
+ storageKey?: string;
37
+ }
38
+
39
+ export function ThemeProvider({
40
+ children,
41
+ defaultTheme = 'system',
42
+ storageKey = STORAGE_KEY,
43
+ }: ThemeProviderProps) {
44
+ const [theme, setThemeState] = useState<Theme>(() => {
45
+ if (typeof window === 'undefined') return defaultTheme;
46
+ const stored = localStorage.getItem(storageKey) as Theme | null;
47
+ return stored || defaultTheme;
48
+ });
49
+
50
+ const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
51
+ resolveTheme(theme)
52
+ );
53
+
54
+ const setTheme = useCallback(
55
+ (newTheme: Theme) => {
56
+ setThemeState(newTheme);
57
+ localStorage.setItem(storageKey, newTheme);
58
+ const resolved = resolveTheme(newTheme);
59
+ setResolvedTheme(resolved);
60
+ applyTheme(resolved);
61
+ },
62
+ [storageKey]
63
+ );
64
+
65
+ const toggleTheme = useCallback(() => {
66
+ setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
67
+ }, [resolvedTheme, setTheme]);
68
+
69
+ // Apply theme on mount
70
+ useEffect(() => {
71
+ applyTheme(resolveTheme(theme));
72
+ }, []);
73
+
74
+ // Listen for system theme changes
75
+ useEffect(() => {
76
+ if (theme !== 'system') return;
77
+
78
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
79
+ const handler = () => {
80
+ const resolved = getSystemTheme();
81
+ setResolvedTheme(resolved);
82
+ applyTheme(resolved);
83
+ };
84
+
85
+ mediaQuery.addEventListener('change', handler);
86
+ return () => mediaQuery.removeEventListener('change', handler);
87
+ }, [theme]);
88
+
89
+ return (
90
+ <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
91
+ {children}
92
+ </ThemeContext.Provider>
93
+ );
94
+ }
95
+
96
+ export function useTheme(): ThemeContextValue {
97
+ const context = useContext(ThemeContext);
98
+ if (!context) {
99
+ throw new Error('useTheme must be used within a ThemeProvider');
100
+ }
101
+ return context;
102
+ }
@@ -0,0 +1,13 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ /**
5
+ * Utility function to merge Tailwind CSS classes with proper precedence
6
+ * Uses clsx to handle conditional classes and tailwind-merge to resolve conflicts
7
+ *
8
+ * @example
9
+ * cn('px-4 py-2', isLarge && 'px-6 py-3', className)
10
+ */
11
+ export function cn(...inputs: ClassValue[]) {
12
+ return twMerge(clsx(inputs));
13
+ }