radtools 0.1.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 (133) hide show
  1. package/README.md +108 -0
  2. package/bin/radtools.js +5 -0
  3. package/dist/cli/index.js +427 -0
  4. package/package.json +55 -0
  5. package/templates/api-routes/assets/optimize/route.ts +94 -0
  6. package/templates/api-routes/assets/route.ts +159 -0
  7. package/templates/api-routes/components/create-folder/route.ts +55 -0
  8. package/templates/api-routes/components/route.ts +156 -0
  9. package/templates/api-routes/fonts/route.ts +96 -0
  10. package/templates/api-routes/fonts/upload/route.ts +79 -0
  11. package/templates/api-routes/read-css/route.ts +29 -0
  12. package/templates/api-routes/write-css/route.ts +423 -0
  13. package/templates/components/Rad_os/AppWindow.tsx +423 -0
  14. package/templates/components/Rad_os/MobileAppModal.tsx +76 -0
  15. package/templates/components/Rad_os/WindowTitleBar.tsx +290 -0
  16. package/templates/components/icons/Icon.tsx +224 -0
  17. package/templates/components/icons/README.md +85 -0
  18. package/templates/components/icons/index.ts +20 -0
  19. package/templates/components/icons.tsx +164 -0
  20. package/templates/components/ui/Accordion.tsx +268 -0
  21. package/templates/components/ui/Alert.tsx +111 -0
  22. package/templates/components/ui/Badge.tsx +87 -0
  23. package/templates/components/ui/Breadcrumbs.tsx +88 -0
  24. package/templates/components/ui/Button.tsx +249 -0
  25. package/templates/components/ui/Card.tsx +137 -0
  26. package/templates/components/ui/Checkbox.tsx +137 -0
  27. package/templates/components/ui/ContextMenu.tsx +220 -0
  28. package/templates/components/ui/Dialog.tsx +264 -0
  29. package/templates/components/ui/Divider.tsx +70 -0
  30. package/templates/components/ui/DropdownMenu.tsx +301 -0
  31. package/templates/components/ui/HelpPanel.tsx +119 -0
  32. package/templates/components/ui/Input.tsx +176 -0
  33. package/templates/components/ui/Popover.tsx +211 -0
  34. package/templates/components/ui/Progress.tsx +158 -0
  35. package/templates/components/ui/Select.tsx +134 -0
  36. package/templates/components/ui/Sheet.tsx +316 -0
  37. package/templates/components/ui/Slider.tsx +223 -0
  38. package/templates/components/ui/Switch.tsx +155 -0
  39. package/templates/components/ui/Tabs.tsx +253 -0
  40. package/templates/components/ui/Toast.tsx +192 -0
  41. package/templates/components/ui/Tooltip.tsx +129 -0
  42. package/templates/components/ui/hooks/useModalBehavior.ts +66 -0
  43. package/templates/components/ui/index.ts +84 -0
  44. package/templates/devtools/DevToolsPanel.tsx +261 -0
  45. package/templates/devtools/DevToolsProvider.tsx +43 -0
  46. package/templates/devtools/components/BreakpointIndicator.tsx +49 -0
  47. package/templates/devtools/components/ColorPicker.tsx +33 -0
  48. package/templates/devtools/components/ComponentsSecondaryNav.tsx +44 -0
  49. package/templates/devtools/components/ContextualFooter.tsx +56 -0
  50. package/templates/devtools/components/DraggablePanel.tsx +43 -0
  51. package/templates/devtools/components/PrimaryNavigationFooter.tsx +254 -0
  52. package/templates/devtools/components/SearchableColorDropdown.tsx +253 -0
  53. package/templates/devtools/components/SecondaryNavigation.tsx +36 -0
  54. package/templates/devtools/components/TokenDropdown.tsx +47 -0
  55. package/templates/devtools/components/TypographyFooter.tsx +145 -0
  56. package/templates/devtools/hooks/useMockState.ts +16 -0
  57. package/templates/devtools/index.ts +17 -0
  58. package/templates/devtools/lib/componentScanner.ts +78 -0
  59. package/templates/devtools/lib/cssParser.ts +465 -0
  60. package/templates/devtools/lib/searchIndexes.ts +45 -0
  61. package/templates/devtools/lib/selectorGenerator.ts +86 -0
  62. package/templates/devtools/store/index.ts +66 -0
  63. package/templates/devtools/store/slices/assetsSlice.ts +106 -0
  64. package/templates/devtools/store/slices/componentsSlice.ts +59 -0
  65. package/templates/devtools/store/slices/mockStatesSlice.ts +77 -0
  66. package/templates/devtools/store/slices/panelSlice.ts +17 -0
  67. package/templates/devtools/store/slices/typographySlice.ts +538 -0
  68. package/templates/devtools/store/slices/variablesSlice.ts +167 -0
  69. package/templates/devtools/tabs/AssetsTab/AssetGrid.tsx +76 -0
  70. package/templates/devtools/tabs/AssetsTab/FolderTree.tsx +53 -0
  71. package/templates/devtools/tabs/AssetsTab/UploadDropzone.tsx +76 -0
  72. package/templates/devtools/tabs/AssetsTab/index.tsx +182 -0
  73. package/templates/devtools/tabs/ComponentsTab/AddTabButton.tsx +63 -0
  74. package/templates/devtools/tabs/ComponentsTab/ComponentList.tsx +153 -0
  75. package/templates/devtools/tabs/ComponentsTab/DesignSystemTab.tsx +1515 -0
  76. package/templates/devtools/tabs/ComponentsTab/DynamicFolderTab.tsx +113 -0
  77. package/templates/devtools/tabs/ComponentsTab/PropDisplay.tsx +55 -0
  78. package/templates/devtools/tabs/ComponentsTab/index.tsx +167 -0
  79. package/templates/devtools/tabs/ComponentsTab/previews/.gitkeep +4 -0
  80. package/templates/devtools/tabs/ComponentsTab/previews/Rad_os.tsx +262 -0
  81. package/templates/devtools/tabs/ComponentsTab/tabConfig.ts +53 -0
  82. package/templates/devtools/tabs/MockStatesTab/index.tsx +29 -0
  83. package/templates/devtools/tabs/TypographyTab/FontManager.tsx +421 -0
  84. package/templates/devtools/tabs/TypographyTab/TypographyStylesDisplay.tsx +290 -0
  85. package/templates/devtools/tabs/TypographyTab/index.tsx +98 -0
  86. package/templates/devtools/tabs/VariablesTab/BaseColorEditor.tsx +267 -0
  87. package/templates/devtools/tabs/VariablesTab/BorderRadiusEditor.tsx +37 -0
  88. package/templates/devtools/tabs/VariablesTab/ColorModeSelector.tsx +235 -0
  89. package/templates/devtools/tabs/VariablesTab/index.tsx +100 -0
  90. package/templates/devtools/types/index.ts +99 -0
  91. package/templates/globals.css +574 -0
  92. package/templates/hooks/index.ts +1 -0
  93. package/templates/hooks/useWindowManager.ts +212 -0
  94. package/templates/public/assets/icons/avatar.svg +18 -0
  95. package/templates/public/assets/icons/checkmark-filled.svg +14 -0
  96. package/templates/public/assets/icons/checkmark.svg +14 -0
  97. package/templates/public/assets/icons/chevron-down.svg +14 -0
  98. package/templates/public/assets/icons/close.svg +14 -0
  99. package/templates/public/assets/icons/copy.svg +14 -0
  100. package/templates/public/assets/icons/download.svg +14 -0
  101. package/templates/public/assets/icons/expand.svg +31 -0
  102. package/templates/public/assets/icons/file-blank.svg +17 -0
  103. package/templates/public/assets/icons/file-image.svg +19 -0
  104. package/templates/public/assets/icons/file-written.svg +17 -0
  105. package/templates/public/assets/icons/folder-closed.svg +17 -0
  106. package/templates/public/assets/icons/folder-open.svg +17 -0
  107. package/templates/public/assets/icons/hamburger.svg +18 -0
  108. package/templates/public/assets/icons/home-outline.svg +28 -0
  109. package/templates/public/assets/icons/home.svg +30 -0
  110. package/templates/public/assets/icons/hourglass.svg +25 -0
  111. package/templates/public/assets/icons/information-circle.svg +14 -0
  112. package/templates/public/assets/icons/information.svg +17 -0
  113. package/templates/public/assets/icons/lightning.svg +14 -0
  114. package/templates/public/assets/icons/locked.svg +17 -0
  115. package/templates/public/assets/icons/not-allowed.svg +14 -0
  116. package/templates/public/assets/icons/plus.svg +5 -0
  117. package/templates/public/assets/icons/power-thin.svg +17 -0
  118. package/templates/public/assets/icons/power.svg +17 -0
  119. package/templates/public/assets/icons/question-block.svg +14 -0
  120. package/templates/public/assets/icons/question.svg +17 -0
  121. package/templates/public/assets/icons/refresh-block.svg +14 -0
  122. package/templates/public/assets/icons/refresh.svg +17 -0
  123. package/templates/public/assets/icons/save.svg +14 -0
  124. package/templates/public/assets/icons/search.svg +25 -0
  125. package/templates/public/assets/icons/settings.svg +14 -0
  126. package/templates/public/assets/icons/trash-full.svg +21 -0
  127. package/templates/public/assets/icons/trash-open.svg +23 -0
  128. package/templates/public/assets/icons/trash.svg +18 -0
  129. package/templates/public/assets/icons/unlocked.svg +17 -0
  130. package/templates/public/assets/icons/waring-triangle-filled.svg +17 -0
  131. package/templates/public/assets/icons/warning-triangle-filled-2.svg +30 -0
  132. package/templates/public/assets/icons/warning-triangle-lines.svg +29 -0
  133. package/templates/public/assets/icons/wrench.svg +17 -0
@@ -0,0 +1,253 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState } from 'react';
4
+ import { Icon } from '@/components/icons';
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ type TabsVariant = 'pill' | 'line';
11
+
12
+ interface TabsContextValue {
13
+ activeTab: string;
14
+ setActiveTab: (id: string) => void;
15
+ variant: TabsVariant;
16
+ }
17
+
18
+ interface TabsProps {
19
+ /** Default active tab ID (uncontrolled mode) */
20
+ defaultValue?: string;
21
+ /** Active tab ID (controlled mode) */
22
+ value?: string;
23
+ /** Callback when tab changes (controlled mode) */
24
+ onValueChange?: (value: string) => void;
25
+ /** Visual variant */
26
+ variant?: TabsVariant;
27
+ /** Tab components */
28
+ children: React.ReactNode;
29
+ /** Additional classes for container */
30
+ className?: string;
31
+ }
32
+
33
+ interface TabListProps {
34
+ /** TabTrigger components */
35
+ children: React.ReactNode;
36
+ /** Additional classes */
37
+ className?: string;
38
+ }
39
+
40
+ interface TabTriggerProps {
41
+ /** Unique tab ID */
42
+ value: string;
43
+ /** Tab label */
44
+ children: React.ReactNode;
45
+ /** Icon name (filename without .svg extension) */
46
+ iconName?: string;
47
+ /** Additional classes */
48
+ className?: string;
49
+ }
50
+
51
+ interface TabContentProps {
52
+ /** Tab ID this content belongs to */
53
+ value: string;
54
+ /** Content to render when active */
55
+ children: React.ReactNode;
56
+ /** Additional classes */
57
+ className?: string;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Context
62
+ // ============================================================================
63
+
64
+ const TabsContext = createContext<TabsContextValue | null>(null);
65
+
66
+ function useTabsContext() {
67
+ const context = useContext(TabsContext);
68
+ if (!context) {
69
+ throw new Error('Tab components must be used within a Tabs provider');
70
+ }
71
+ return context;
72
+ }
73
+
74
+ // ============================================================================
75
+ // Styles
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Tab trigger base styles - matching Webflow tab style
80
+ */
81
+ const triggerBaseStyles = `
82
+ flex items-center justify-center
83
+ px-4 py-2
84
+ font-joystix text-xs uppercase
85
+ cursor-pointer select-none
86
+ text-black
87
+ transition-all duration-200 ease-out
88
+ relative
89
+ `;
90
+
91
+ /**
92
+ * Pill variant styles (similar to buttons)
93
+ */
94
+ const pillStyles = {
95
+ inactive: `
96
+ border border-black
97
+ rounded-sm
98
+ bg-transparent
99
+ hover:bg-black/5
100
+ `,
101
+ active: `
102
+ border border-black
103
+ rounded-sm
104
+ bg-sun-yellow
105
+ `,
106
+ };
107
+
108
+ /**
109
+ * Line variant styles (Webflow-style tabs with connected active state)
110
+ */
111
+ const lineStyles = {
112
+ inactive: `
113
+ bg-transparent
114
+ hover:bg-warm-cloud/50
115
+ `,
116
+ active: `
117
+ border-b-0
118
+ bg-warm-cloud
119
+ border-t border-l border-r border-black
120
+ rounded-t-md
121
+ mb-0
122
+ relative
123
+ z-10
124
+ `,
125
+ };
126
+
127
+ // ============================================================================
128
+ // Components
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Tabs container - provides context for tab state
133
+ * Supports both controlled and uncontrolled modes
134
+ */
135
+ export function Tabs({
136
+ defaultValue,
137
+ value,
138
+ onValueChange,
139
+ variant = 'pill',
140
+ children,
141
+ className = '',
142
+ }: TabsProps) {
143
+ // Uncontrolled mode uses internal state
144
+ const [internalValue, setInternalValue] = useState(defaultValue || '');
145
+
146
+ // Determine if controlled or uncontrolled
147
+ const isControlled = value !== undefined;
148
+ const activeTab = isControlled ? value : internalValue;
149
+
150
+ const setActiveTab = (newValue: string) => {
151
+ if (isControlled) {
152
+ onValueChange?.(newValue);
153
+ } else {
154
+ setInternalValue(newValue);
155
+ }
156
+ };
157
+
158
+ return (
159
+ <TabsContext.Provider value={{ activeTab, setActiveTab, variant }}>
160
+ <div className={className}>
161
+ {children}
162
+ </div>
163
+ </TabsContext.Provider>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Container for tab triggers - Webflow-style tab menu
169
+ */
170
+ export function TabList({ children, className = '' }: TabListProps) {
171
+ return (
172
+ <div className={`flex gap-1 ${className}`}>
173
+ {children}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Individual tab trigger button - Webflow-style
180
+ */
181
+ export function TabTrigger({
182
+ value,
183
+ children,
184
+ iconName,
185
+ className = '',
186
+ }: TabTriggerProps) {
187
+ const { activeTab, setActiveTab, variant } = useTabsContext();
188
+ const isActive = activeTab === value;
189
+
190
+ const variantStyle = variant === 'pill'
191
+ ? (isActive ? pillStyles.active : pillStyles.inactive)
192
+ : (isActive ? lineStyles.active : lineStyles.inactive);
193
+
194
+ const classes = [
195
+ triggerBaseStyles,
196
+ variantStyle,
197
+ className,
198
+ ]
199
+ .join(' ')
200
+ .replace(/\s+/g, ' ')
201
+ .trim();
202
+
203
+ return (
204
+ <button
205
+ type="button"
206
+ role="tab"
207
+ aria-selected={isActive}
208
+ onClick={() => setActiveTab(value)}
209
+ className={classes}
210
+ >
211
+ <span className="flex items-center gap-2">
212
+ {iconName && (
213
+ <span className="opacity-70">
214
+ <Icon name={iconName} size={16} />
215
+ </span>
216
+ )}
217
+ <span>{children}</span>
218
+ </span>
219
+ </button>
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Tab content panel - Webflow-style tab pane
225
+ */
226
+ export function TabContent({
227
+ value,
228
+ children,
229
+ className = '',
230
+ }: TabContentProps) {
231
+ const { activeTab, variant } = useTabsContext();
232
+
233
+ if (activeTab !== value) {
234
+ return null;
235
+ }
236
+
237
+ // For line variant, content connects seamlessly with active tab
238
+ const contentClasses = variant === 'line'
239
+ ? `bg-warm-cloud border-r border-black ${className}`
240
+ : className;
241
+
242
+ return (
243
+ <div
244
+ role="tabpanel"
245
+ className={contentClasses}
246
+ >
247
+ {children}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ export default Tabs;
253
+
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { Icon } from '@/components/icons';
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ type ToastVariant = 'default' | 'success' | 'warning' | 'error' | 'info';
12
+
13
+ interface ToastData {
14
+ id: string;
15
+ title: string;
16
+ description?: string;
17
+ variant?: ToastVariant;
18
+ duration?: number;
19
+ /** Icon name (filename without .svg extension) - overrides variant default */
20
+ iconName?: string;
21
+ }
22
+
23
+ interface ToastContextValue {
24
+ toasts: ToastData[];
25
+ addToast: (toast: Omit<ToastData, 'id'>) => string;
26
+ removeToast: (id: string) => void;
27
+ }
28
+
29
+ // ============================================================================
30
+ // Context
31
+ // ============================================================================
32
+
33
+ const ToastContext = createContext<ToastContextValue | null>(null);
34
+
35
+ export function useToast() {
36
+ const context = useContext(ToastContext);
37
+ if (!context) {
38
+ throw new Error('useToast must be used within a ToastProvider');
39
+ }
40
+ return context;
41
+ }
42
+
43
+ // ============================================================================
44
+ // Toast Provider
45
+ // ============================================================================
46
+
47
+ interface ToastProviderProps {
48
+ /** Children */
49
+ children: React.ReactNode;
50
+ /** Default duration in ms */
51
+ defaultDuration?: number;
52
+ }
53
+
54
+ export function ToastProvider({ children, defaultDuration = 5000 }: ToastProviderProps) {
55
+ const [toasts, setToasts] = useState<ToastData[]>([]);
56
+ const [mounted, setMounted] = useState(false);
57
+
58
+ useEffect(() => {
59
+ setMounted(true);
60
+ }, []);
61
+
62
+ const addToast = useCallback((toast: Omit<ToastData, 'id'>) => {
63
+ const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
64
+ const duration = toast.duration ?? defaultDuration;
65
+
66
+ setToasts((prev) => [...prev, { ...toast, id }]);
67
+
68
+ // Auto-remove after duration
69
+ if (duration > 0) {
70
+ setTimeout(() => {
71
+ setToasts((prev) => prev.filter((t) => t.id !== id));
72
+ }, duration);
73
+ }
74
+
75
+ return id;
76
+ }, [defaultDuration]);
77
+
78
+ const removeToast = useCallback((id: string) => {
79
+ setToasts((prev) => prev.filter((t) => t.id !== id));
80
+ }, []);
81
+
82
+ return (
83
+ <ToastContext.Provider value={{ toasts, addToast, removeToast }}>
84
+ {children}
85
+ {mounted && createPortal(
86
+ <ToastViewport toasts={toasts} removeToast={removeToast} />,
87
+ document.body
88
+ )}
89
+ </ToastContext.Provider>
90
+ );
91
+ }
92
+
93
+ // ============================================================================
94
+ // Toast Viewport
95
+ // ============================================================================
96
+
97
+ interface ToastViewportProps {
98
+ toasts: ToastData[];
99
+ removeToast: (id: string) => void;
100
+ }
101
+
102
+ function ToastViewport({ toasts, removeToast }: ToastViewportProps) {
103
+ if (toasts.length === 0) return null;
104
+
105
+ return (
106
+ <div
107
+ className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm w-full pointer-events-none"
108
+ aria-live="polite"
109
+ >
110
+ {toasts.map((toast) => (
111
+ <Toast key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
112
+ ))}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ // ============================================================================
118
+ // Toast Component
119
+ // ============================================================================
120
+
121
+ const variantStyles: Record<ToastVariant, string> = {
122
+ default: 'bg-warm-cloud border-black',
123
+ success: 'bg-success-green/20 border-success-green',
124
+ warning: 'bg-sun-yellow/20 border-sunset-fuzz',
125
+ error: 'bg-error-red/20 border-error-red',
126
+ info: 'bg-sky-blue/20 border-sky-blue',
127
+ };
128
+
129
+ const variantIconMap: Record<ToastVariant, string | null> = {
130
+ default: null,
131
+ success: 'checkmark-filled',
132
+ warning: 'warning-triangle-filled-2',
133
+ error: 'close',
134
+ info: 'information-circle',
135
+ };
136
+
137
+ interface ToastProps {
138
+ toast: ToastData;
139
+ onClose: () => void;
140
+ }
141
+
142
+ function Toast({ toast, onClose }: ToastProps) {
143
+ const variant = toast.variant || 'default';
144
+ const displayIconName = toast.iconName || variantIconMap[variant];
145
+
146
+ return (
147
+ <div
148
+ className={`
149
+ pointer-events-auto
150
+ p-4
151
+ border-2
152
+ rounded-sm
153
+ shadow-[2px_2px_0_0_var(--color-black)]
154
+ animate-slideIn
155
+ ${variantStyles[variant]}
156
+ `.trim()}
157
+ role="alert"
158
+ >
159
+ <div className="flex items-start gap-3">
160
+ {/* Icon */}
161
+ {displayIconName && (
162
+ <span className="flex-shrink-0">
163
+ <Icon name={displayIconName} size={16} />
164
+ </span>
165
+ )}
166
+
167
+ {/* Content */}
168
+ <div className="flex-1 min-w-0">
169
+ <p className="font-joystix text-xs uppercase text-black">
170
+ {toast.title}
171
+ </p>
172
+ {toast.description && (
173
+ <p className="font-mondwest text-base text-black/70 mt-1">
174
+ {toast.description}
175
+ </p>
176
+ )}
177
+ </div>
178
+
179
+ {/* Close Button */}
180
+ <button
181
+ onClick={onClose}
182
+ className="text-black/50 hover:text-black flex-shrink-0 -mt-1"
183
+ aria-label="Close"
184
+ >
185
+ <Icon name="close" size={16} />
186
+ </button>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ export default ToastProvider;
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
10
+ type TooltipSize = 'sm' | 'md' | 'lg';
11
+
12
+ interface TooltipProps {
13
+ /** Tooltip content */
14
+ content: React.ReactNode;
15
+ /** Position relative to trigger */
16
+ position?: TooltipPosition;
17
+ /** Delay before showing (ms) - set to 0 for instant */
18
+ delay?: number;
19
+ /** Size preset - matches Button sizes (sm=10px, md=12px, lg=14px) */
20
+ size?: TooltipSize;
21
+ /** Trigger element */
22
+ children: React.ReactNode;
23
+ /** Additional classes */
24
+ className?: string;
25
+ }
26
+
27
+ // ============================================================================
28
+ // Styles
29
+ // ============================================================================
30
+
31
+ const positionStyles: Record<TooltipPosition, string> = {
32
+ top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
33
+ bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
34
+ left: 'right-full top-1/2 -translate-y-1/2 mr-2',
35
+ right: 'left-full top-1/2 -translate-y-1/2 ml-2',
36
+ };
37
+
38
+ const arrowStyles: Record<TooltipPosition, string> = {
39
+ top: 'top-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-b-transparent border-t-black',
40
+ bottom: 'bottom-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-t-transparent border-b-black',
41
+ left: 'left-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-r-transparent border-l-black',
42
+ right: 'right-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-l-transparent border-r-black',
43
+ };
44
+
45
+ /**
46
+ * Font size presets matching Button component sizes
47
+ * sm=10px, md=12px, lg=14px
48
+ */
49
+ const sizeStyles: Record<TooltipSize, string> = {
50
+ sm: 'text-[10px]',
51
+ md: 'text-xs',
52
+ lg: 'text-sm',
53
+ };
54
+
55
+ // ============================================================================
56
+ // Component
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Tooltip component for hover information
61
+ */
62
+ export function Tooltip({
63
+ content,
64
+ position = 'top',
65
+ delay = 0,
66
+ size = 'md',
67
+ children,
68
+ className = '',
69
+ }: TooltipProps) {
70
+ const [isVisible, setIsVisible] = useState(false);
71
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
72
+
73
+ const showTooltip = () => {
74
+ if (delay > 0) {
75
+ timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
76
+ } else {
77
+ setIsVisible(true);
78
+ }
79
+ };
80
+
81
+ const hideTooltip = () => {
82
+ if (timeoutRef.current) {
83
+ clearTimeout(timeoutRef.current);
84
+ }
85
+ setIsVisible(false);
86
+ };
87
+
88
+ return (
89
+ <div
90
+ className={`relative inline-flex ${className}`}
91
+ onMouseEnter={showTooltip}
92
+ onMouseLeave={hideTooltip}
93
+ onFocus={showTooltip}
94
+ onBlur={hideTooltip}
95
+ >
96
+ {children}
97
+
98
+ {isVisible && (
99
+ <div
100
+ className={`
101
+ absolute z-[1000]
102
+ px-2 py-1
103
+ bg-black text-cream
104
+ font-joystix uppercase
105
+ rounded-sm
106
+ whitespace-nowrap
107
+ pointer-events-none
108
+ ${sizeStyles[size]}
109
+ ${positionStyles[position]}
110
+ `}
111
+ role="tooltip"
112
+ >
113
+ {content}
114
+ {/* Arrow */}
115
+ <div
116
+ className={`
117
+ absolute
118
+ border-4 border-solid
119
+ ${arrowStyles[position]}
120
+ `}
121
+ />
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export default Tooltip;
129
+
@@ -0,0 +1,66 @@
1
+ import { useEffect, RefObject } from 'react';
2
+
3
+ /**
4
+ * Hook to handle escape key press to close modals/overlays
5
+ * @param isActive - Whether the modal is currently open
6
+ * @param onEscape - Callback to close the modal
7
+ */
8
+ export function useEscapeKey(isActive: boolean, onEscape: () => void): void {
9
+ useEffect(() => {
10
+ if (!isActive) return;
11
+
12
+ const handleEscape = (e: KeyboardEvent) => {
13
+ if (e.key === 'Escape') {
14
+ onEscape();
15
+ }
16
+ };
17
+
18
+ document.addEventListener('keydown', handleEscape);
19
+ return () => document.removeEventListener('keydown', handleEscape);
20
+ }, [isActive, onEscape]);
21
+ }
22
+
23
+ /**
24
+ * Hook to detect clicks outside of specified element(s)
25
+ * @param isActive - Whether to listen for clicks
26
+ * @param refs - Array of refs to elements that should NOT trigger the callback
27
+ * @param onClickOutside - Callback when clicking outside all refs
28
+ */
29
+ export function useClickOutside(
30
+ isActive: boolean,
31
+ refs: RefObject<HTMLElement | null>[],
32
+ onClickOutside: () => void
33
+ ): void {
34
+ useEffect(() => {
35
+ if (!isActive) return;
36
+
37
+ const handleClickOutside = (e: MouseEvent) => {
38
+ const clickedOutsideAll = refs.every(
39
+ (ref) => ref.current && !ref.current.contains(e.target as Node)
40
+ );
41
+ if (clickedOutsideAll) {
42
+ onClickOutside();
43
+ }
44
+ };
45
+
46
+ document.addEventListener('mousedown', handleClickOutside);
47
+ return () => document.removeEventListener('mousedown', handleClickOutside);
48
+ }, [isActive, refs, onClickOutside]);
49
+ }
50
+
51
+ /**
52
+ * Hook to prevent body scroll when modal is open
53
+ * @param isActive - Whether to lock body scroll
54
+ */
55
+ export function useLockBodyScroll(isActive: boolean): void {
56
+ useEffect(() => {
57
+ if (isActive) {
58
+ document.body.style.overflow = 'hidden';
59
+ } else {
60
+ document.body.style.overflow = '';
61
+ }
62
+ return () => {
63
+ document.body.style.overflow = '';
64
+ };
65
+ }, [isActive]);
66
+ }