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.
- package/README.md +108 -0
- package/bin/radtools.js +5 -0
- package/dist/cli/index.js +427 -0
- package/package.json +55 -0
- package/templates/api-routes/assets/optimize/route.ts +94 -0
- package/templates/api-routes/assets/route.ts +159 -0
- package/templates/api-routes/components/create-folder/route.ts +55 -0
- package/templates/api-routes/components/route.ts +156 -0
- package/templates/api-routes/fonts/route.ts +96 -0
- package/templates/api-routes/fonts/upload/route.ts +79 -0
- package/templates/api-routes/read-css/route.ts +29 -0
- package/templates/api-routes/write-css/route.ts +423 -0
- package/templates/components/Rad_os/AppWindow.tsx +423 -0
- package/templates/components/Rad_os/MobileAppModal.tsx +76 -0
- package/templates/components/Rad_os/WindowTitleBar.tsx +290 -0
- package/templates/components/icons/Icon.tsx +224 -0
- package/templates/components/icons/README.md +85 -0
- package/templates/components/icons/index.ts +20 -0
- package/templates/components/icons.tsx +164 -0
- package/templates/components/ui/Accordion.tsx +268 -0
- package/templates/components/ui/Alert.tsx +111 -0
- package/templates/components/ui/Badge.tsx +87 -0
- package/templates/components/ui/Breadcrumbs.tsx +88 -0
- package/templates/components/ui/Button.tsx +249 -0
- package/templates/components/ui/Card.tsx +137 -0
- package/templates/components/ui/Checkbox.tsx +137 -0
- package/templates/components/ui/ContextMenu.tsx +220 -0
- package/templates/components/ui/Dialog.tsx +264 -0
- package/templates/components/ui/Divider.tsx +70 -0
- package/templates/components/ui/DropdownMenu.tsx +301 -0
- package/templates/components/ui/HelpPanel.tsx +119 -0
- package/templates/components/ui/Input.tsx +176 -0
- package/templates/components/ui/Popover.tsx +211 -0
- package/templates/components/ui/Progress.tsx +158 -0
- package/templates/components/ui/Select.tsx +134 -0
- package/templates/components/ui/Sheet.tsx +316 -0
- package/templates/components/ui/Slider.tsx +223 -0
- package/templates/components/ui/Switch.tsx +155 -0
- package/templates/components/ui/Tabs.tsx +253 -0
- package/templates/components/ui/Toast.tsx +192 -0
- package/templates/components/ui/Tooltip.tsx +129 -0
- package/templates/components/ui/hooks/useModalBehavior.ts +66 -0
- package/templates/components/ui/index.ts +84 -0
- package/templates/devtools/DevToolsPanel.tsx +261 -0
- package/templates/devtools/DevToolsProvider.tsx +43 -0
- package/templates/devtools/components/BreakpointIndicator.tsx +49 -0
- package/templates/devtools/components/ColorPicker.tsx +33 -0
- package/templates/devtools/components/ComponentsSecondaryNav.tsx +44 -0
- package/templates/devtools/components/ContextualFooter.tsx +56 -0
- package/templates/devtools/components/DraggablePanel.tsx +43 -0
- package/templates/devtools/components/PrimaryNavigationFooter.tsx +254 -0
- package/templates/devtools/components/SearchableColorDropdown.tsx +253 -0
- package/templates/devtools/components/SecondaryNavigation.tsx +36 -0
- package/templates/devtools/components/TokenDropdown.tsx +47 -0
- package/templates/devtools/components/TypographyFooter.tsx +145 -0
- package/templates/devtools/hooks/useMockState.ts +16 -0
- package/templates/devtools/index.ts +17 -0
- package/templates/devtools/lib/componentScanner.ts +78 -0
- package/templates/devtools/lib/cssParser.ts +465 -0
- package/templates/devtools/lib/searchIndexes.ts +45 -0
- package/templates/devtools/lib/selectorGenerator.ts +86 -0
- package/templates/devtools/store/index.ts +66 -0
- package/templates/devtools/store/slices/assetsSlice.ts +106 -0
- package/templates/devtools/store/slices/componentsSlice.ts +59 -0
- package/templates/devtools/store/slices/mockStatesSlice.ts +77 -0
- package/templates/devtools/store/slices/panelSlice.ts +17 -0
- package/templates/devtools/store/slices/typographySlice.ts +538 -0
- package/templates/devtools/store/slices/variablesSlice.ts +167 -0
- package/templates/devtools/tabs/AssetsTab/AssetGrid.tsx +76 -0
- package/templates/devtools/tabs/AssetsTab/FolderTree.tsx +53 -0
- package/templates/devtools/tabs/AssetsTab/UploadDropzone.tsx +76 -0
- package/templates/devtools/tabs/AssetsTab/index.tsx +182 -0
- package/templates/devtools/tabs/ComponentsTab/AddTabButton.tsx +63 -0
- package/templates/devtools/tabs/ComponentsTab/ComponentList.tsx +153 -0
- package/templates/devtools/tabs/ComponentsTab/DesignSystemTab.tsx +1515 -0
- package/templates/devtools/tabs/ComponentsTab/DynamicFolderTab.tsx +113 -0
- package/templates/devtools/tabs/ComponentsTab/PropDisplay.tsx +55 -0
- package/templates/devtools/tabs/ComponentsTab/index.tsx +167 -0
- package/templates/devtools/tabs/ComponentsTab/previews/.gitkeep +4 -0
- package/templates/devtools/tabs/ComponentsTab/previews/Rad_os.tsx +262 -0
- package/templates/devtools/tabs/ComponentsTab/tabConfig.ts +53 -0
- package/templates/devtools/tabs/MockStatesTab/index.tsx +29 -0
- package/templates/devtools/tabs/TypographyTab/FontManager.tsx +421 -0
- package/templates/devtools/tabs/TypographyTab/TypographyStylesDisplay.tsx +290 -0
- package/templates/devtools/tabs/TypographyTab/index.tsx +98 -0
- package/templates/devtools/tabs/VariablesTab/BaseColorEditor.tsx +267 -0
- package/templates/devtools/tabs/VariablesTab/BorderRadiusEditor.tsx +37 -0
- package/templates/devtools/tabs/VariablesTab/ColorModeSelector.tsx +235 -0
- package/templates/devtools/tabs/VariablesTab/index.tsx +100 -0
- package/templates/devtools/types/index.ts +99 -0
- package/templates/globals.css +574 -0
- package/templates/hooks/index.ts +1 -0
- package/templates/hooks/useWindowManager.ts +212 -0
- package/templates/public/assets/icons/avatar.svg +18 -0
- package/templates/public/assets/icons/checkmark-filled.svg +14 -0
- package/templates/public/assets/icons/checkmark.svg +14 -0
- package/templates/public/assets/icons/chevron-down.svg +14 -0
- package/templates/public/assets/icons/close.svg +14 -0
- package/templates/public/assets/icons/copy.svg +14 -0
- package/templates/public/assets/icons/download.svg +14 -0
- package/templates/public/assets/icons/expand.svg +31 -0
- package/templates/public/assets/icons/file-blank.svg +17 -0
- package/templates/public/assets/icons/file-image.svg +19 -0
- package/templates/public/assets/icons/file-written.svg +17 -0
- package/templates/public/assets/icons/folder-closed.svg +17 -0
- package/templates/public/assets/icons/folder-open.svg +17 -0
- package/templates/public/assets/icons/hamburger.svg +18 -0
- package/templates/public/assets/icons/home-outline.svg +28 -0
- package/templates/public/assets/icons/home.svg +30 -0
- package/templates/public/assets/icons/hourglass.svg +25 -0
- package/templates/public/assets/icons/information-circle.svg +14 -0
- package/templates/public/assets/icons/information.svg +17 -0
- package/templates/public/assets/icons/lightning.svg +14 -0
- package/templates/public/assets/icons/locked.svg +17 -0
- package/templates/public/assets/icons/not-allowed.svg +14 -0
- package/templates/public/assets/icons/plus.svg +5 -0
- package/templates/public/assets/icons/power-thin.svg +17 -0
- package/templates/public/assets/icons/power.svg +17 -0
- package/templates/public/assets/icons/question-block.svg +14 -0
- package/templates/public/assets/icons/question.svg +17 -0
- package/templates/public/assets/icons/refresh-block.svg +14 -0
- package/templates/public/assets/icons/refresh.svg +17 -0
- package/templates/public/assets/icons/save.svg +14 -0
- package/templates/public/assets/icons/search.svg +25 -0
- package/templates/public/assets/icons/settings.svg +14 -0
- package/templates/public/assets/icons/trash-full.svg +21 -0
- package/templates/public/assets/icons/trash-open.svg +23 -0
- package/templates/public/assets/icons/trash.svg +18 -0
- package/templates/public/assets/icons/unlocked.svg +17 -0
- package/templates/public/assets/icons/waring-triangle-filled.svg +17 -0
- package/templates/public/assets/icons/warning-triangle-filled-2.svg +30 -0
- package/templates/public/assets/icons/warning-triangle-lines.svg +29 -0
- package/templates/public/assets/icons/wrench.svg +17 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useEscapeKey, useClickOutside } from './hooks/useModalBehavior';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
type DropdownPosition = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
12
|
+
|
|
13
|
+
interface DropdownContextValue {
|
|
14
|
+
open: boolean;
|
|
15
|
+
setOpen: (open: boolean) => void;
|
|
16
|
+
triggerRef: React.RefObject<HTMLElement | null>;
|
|
17
|
+
position: DropdownPosition;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Context
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const DropdownContext = createContext<DropdownContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
function useDropdownContext() {
|
|
27
|
+
const context = useContext(DropdownContext);
|
|
28
|
+
if (!context) {
|
|
29
|
+
throw new Error('DropdownMenu components must be used within a DropdownMenu');
|
|
30
|
+
}
|
|
31
|
+
return context;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Dropdown Menu Root
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
interface DropdownMenuProps {
|
|
39
|
+
/** Controlled open state */
|
|
40
|
+
open?: boolean;
|
|
41
|
+
/** Default open state */
|
|
42
|
+
defaultOpen?: boolean;
|
|
43
|
+
/** Callback when open state changes */
|
|
44
|
+
onOpenChange?: (open: boolean) => void;
|
|
45
|
+
/** Position relative to trigger */
|
|
46
|
+
position?: DropdownPosition;
|
|
47
|
+
/** Children */
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function DropdownMenu({
|
|
52
|
+
open: controlledOpen,
|
|
53
|
+
defaultOpen = false,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
position = 'bottom-start',
|
|
56
|
+
children,
|
|
57
|
+
}: DropdownMenuProps) {
|
|
58
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
59
|
+
const isControlled = controlledOpen !== undefined;
|
|
60
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
61
|
+
const triggerRef = useRef<HTMLElement>(null);
|
|
62
|
+
|
|
63
|
+
const setOpen = useCallback((newOpen: boolean) => {
|
|
64
|
+
if (!isControlled) {
|
|
65
|
+
setInternalOpen(newOpen);
|
|
66
|
+
}
|
|
67
|
+
onOpenChange?.(newOpen);
|
|
68
|
+
}, [isControlled, onOpenChange]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DropdownContext.Provider value={{ open, setOpen, triggerRef, position }}>
|
|
72
|
+
<div className="relative inline-block">
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
</DropdownContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Dropdown Menu Trigger
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
interface DropdownMenuTriggerProps {
|
|
84
|
+
/** Trigger element */
|
|
85
|
+
children: React.ReactElement;
|
|
86
|
+
/** Pass through as child instead of wrapping */
|
|
87
|
+
asChild?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function DropdownMenuTrigger({ children, asChild }: DropdownMenuTriggerProps) {
|
|
91
|
+
const { open, setOpen, triggerRef } = useDropdownContext();
|
|
92
|
+
|
|
93
|
+
const handleClick = () => {
|
|
94
|
+
setOpen(!open);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (asChild && React.isValidElement(children)) {
|
|
98
|
+
return React.cloneElement(children as React.ReactElement<{ onClick?: () => void; ref?: React.Ref<HTMLElement | null> }>, {
|
|
99
|
+
onClick: handleClick,
|
|
100
|
+
ref: triggerRef as React.Ref<HTMLElement | null>,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
ref={triggerRef as React.RefObject<HTMLButtonElement>}
|
|
108
|
+
onClick={handleClick}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// Dropdown Menu Content
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
interface DropdownMenuContentProps {
|
|
120
|
+
/** Additional className */
|
|
121
|
+
className?: string;
|
|
122
|
+
/** Children */
|
|
123
|
+
children: React.ReactNode;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function DropdownMenuContent({ className = '', children }: DropdownMenuContentProps) {
|
|
127
|
+
const { open, setOpen, triggerRef, position } = useDropdownContext();
|
|
128
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
const [mounted, setMounted] = useState(false);
|
|
130
|
+
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setMounted(true);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
// Calculate position
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!open || !triggerRef.current || !contentRef.current) return;
|
|
139
|
+
|
|
140
|
+
const trigger = triggerRef.current.getBoundingClientRect();
|
|
141
|
+
const content = contentRef.current.getBoundingClientRect();
|
|
142
|
+
const gap = 4;
|
|
143
|
+
|
|
144
|
+
let top = 0;
|
|
145
|
+
let left = 0;
|
|
146
|
+
|
|
147
|
+
switch (position) {
|
|
148
|
+
case 'bottom-start':
|
|
149
|
+
top = trigger.bottom + gap;
|
|
150
|
+
left = trigger.left;
|
|
151
|
+
break;
|
|
152
|
+
case 'bottom-end':
|
|
153
|
+
top = trigger.bottom + gap;
|
|
154
|
+
left = trigger.right - content.width;
|
|
155
|
+
break;
|
|
156
|
+
case 'top-start':
|
|
157
|
+
top = trigger.top - content.height - gap;
|
|
158
|
+
left = trigger.left;
|
|
159
|
+
break;
|
|
160
|
+
case 'top-end':
|
|
161
|
+
top = trigger.top - content.height - gap;
|
|
162
|
+
left = trigger.right - content.width;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Keep within viewport
|
|
167
|
+
top = Math.max(8, Math.min(top, window.innerHeight - content.height - 8));
|
|
168
|
+
left = Math.max(8, Math.min(left, window.innerWidth - content.width - 8));
|
|
169
|
+
|
|
170
|
+
setCoords({ top, left });
|
|
171
|
+
}, [open, position, triggerRef]);
|
|
172
|
+
|
|
173
|
+
// Handle click outside
|
|
174
|
+
useClickOutside(open, [contentRef, triggerRef], () => setOpen(false));
|
|
175
|
+
|
|
176
|
+
// Handle escape
|
|
177
|
+
useEscapeKey(open, () => setOpen(false));
|
|
178
|
+
|
|
179
|
+
if (!mounted || !open) return null;
|
|
180
|
+
|
|
181
|
+
return createPortal(
|
|
182
|
+
<div
|
|
183
|
+
ref={contentRef}
|
|
184
|
+
role="menu"
|
|
185
|
+
className={`
|
|
186
|
+
fixed z-50
|
|
187
|
+
min-w-[8rem]
|
|
188
|
+
bg-warm-cloud
|
|
189
|
+
border-2 border-black
|
|
190
|
+
rounded-sm
|
|
191
|
+
shadow-[2px_2px_0_0_var(--color-black)]
|
|
192
|
+
py-1
|
|
193
|
+
animate-fadeIn
|
|
194
|
+
${className}
|
|
195
|
+
`.trim()}
|
|
196
|
+
style={{ top: coords.top, left: coords.left }}
|
|
197
|
+
>
|
|
198
|
+
{children}
|
|
199
|
+
</div>,
|
|
200
|
+
document.body
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// Dropdown Menu Item
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
interface DropdownMenuItemProps {
|
|
209
|
+
/** Item content */
|
|
210
|
+
children: React.ReactNode;
|
|
211
|
+
/** Click handler */
|
|
212
|
+
onClick?: () => void;
|
|
213
|
+
/** Disabled state */
|
|
214
|
+
disabled?: boolean;
|
|
215
|
+
/** Destructive styling */
|
|
216
|
+
destructive?: boolean;
|
|
217
|
+
/** Additional className */
|
|
218
|
+
className?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function DropdownMenuItem({
|
|
222
|
+
children,
|
|
223
|
+
onClick,
|
|
224
|
+
disabled = false,
|
|
225
|
+
destructive = false,
|
|
226
|
+
className = '',
|
|
227
|
+
}: DropdownMenuItemProps) {
|
|
228
|
+
const { setOpen } = useDropdownContext();
|
|
229
|
+
|
|
230
|
+
const handleClick = () => {
|
|
231
|
+
if (disabled) return;
|
|
232
|
+
onClick?.();
|
|
233
|
+
setOpen(false);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
role="menuitem"
|
|
240
|
+
onClick={handleClick}
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
className={`
|
|
243
|
+
w-full px-4 py-2
|
|
244
|
+
text-left
|
|
245
|
+
font-mondwest text-base
|
|
246
|
+
${destructive ? 'text-error-red' : 'text-black'}
|
|
247
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-black/5 cursor-pointer'}
|
|
248
|
+
transition-colors
|
|
249
|
+
${className}
|
|
250
|
+
`.trim()}
|
|
251
|
+
>
|
|
252
|
+
{children}
|
|
253
|
+
</button>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Dropdown Menu Separator
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
interface DropdownMenuSeparatorProps {
|
|
262
|
+
/** Additional className */
|
|
263
|
+
className?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function DropdownMenuSeparator({ className = '' }: DropdownMenuSeparatorProps) {
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
role="separator"
|
|
270
|
+
className={`h-px bg-warm-cloud/20 my-1 ${className}`.trim()}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Dropdown Menu Label
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
interface DropdownMenuLabelProps {
|
|
280
|
+
/** Label content */
|
|
281
|
+
children: React.ReactNode;
|
|
282
|
+
/** Additional className */
|
|
283
|
+
className?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function DropdownMenuLabel({ children, className = '' }: DropdownMenuLabelProps) {
|
|
287
|
+
return (
|
|
288
|
+
<div
|
|
289
|
+
className={`
|
|
290
|
+
px-4 py-1
|
|
291
|
+
font-joystix text-2xs uppercase
|
|
292
|
+
text-black/50
|
|
293
|
+
${className}
|
|
294
|
+
`.trim()}
|
|
295
|
+
>
|
|
296
|
+
{children}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default DropdownMenu;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
interface HelpPanelProps {
|
|
11
|
+
/** Whether the panel is open */
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
/** Callback when panel should close */
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
/** Help content to display */
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
/** Optional title for the help panel */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Additional className */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Component
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Slide-in help panel that appears from the right side of the window.
|
|
29
|
+
* Used to display contextual help content within app windows.
|
|
30
|
+
*/
|
|
31
|
+
export function HelpPanel({
|
|
32
|
+
isOpen,
|
|
33
|
+
onClose,
|
|
34
|
+
children,
|
|
35
|
+
title = 'Help',
|
|
36
|
+
className = '',
|
|
37
|
+
}: HelpPanelProps) {
|
|
38
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
|
|
40
|
+
// Handle escape key to close
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
43
|
+
if (e.key === 'Escape' && isOpen) {
|
|
44
|
+
onClose();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
document.addEventListener('keydown', handleEscape);
|
|
49
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
50
|
+
}, [isOpen, onClose]);
|
|
51
|
+
|
|
52
|
+
// Handle click outside to close
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
55
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node) && isOpen) {
|
|
56
|
+
onClose();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Add slight delay to prevent immediate close on open click
|
|
61
|
+
const timeoutId = setTimeout(() => {
|
|
62
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
63
|
+
}, 100);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
clearTimeout(timeoutId);
|
|
67
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
68
|
+
};
|
|
69
|
+
}, [isOpen, onClose]);
|
|
70
|
+
|
|
71
|
+
if (!isOpen) return null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={`
|
|
76
|
+
absolute inset-0 z-50
|
|
77
|
+
bg-black/20
|
|
78
|
+
flex justify-end
|
|
79
|
+
`}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
ref={panelRef}
|
|
83
|
+
className={`
|
|
84
|
+
h-full w-72 max-w-[80%]
|
|
85
|
+
bg-warm-cloud
|
|
86
|
+
border-l border-black
|
|
87
|
+
shadow-[-4px_0_0_0_var(--color-black)]
|
|
88
|
+
flex flex-col
|
|
89
|
+
animate-slide-in-right
|
|
90
|
+
${className}
|
|
91
|
+
`}
|
|
92
|
+
>
|
|
93
|
+
{/* Header */}
|
|
94
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-black">
|
|
95
|
+
<span className="font-joystix text-xs text-black uppercase">
|
|
96
|
+
{title}
|
|
97
|
+
</span>
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="md"
|
|
101
|
+
iconOnly={true}
|
|
102
|
+
iconName="close"
|
|
103
|
+
onClick={onClose}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Content */}
|
|
108
|
+
<div className="flex-1 overflow-auto p-4">
|
|
109
|
+
<div className="font-mondwest text-base text-black space-y-4">
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default HelpPanel;
|
|
119
|
+
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
4
|
+
import { Icon } from '@/components/icons';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
type InputSize = 'sm' | 'md' | 'lg';
|
|
11
|
+
|
|
12
|
+
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
|
13
|
+
/** Size preset */
|
|
14
|
+
size?: InputSize;
|
|
15
|
+
/** Error state */
|
|
16
|
+
error?: boolean;
|
|
17
|
+
/** Full width */
|
|
18
|
+
fullWidth?: boolean;
|
|
19
|
+
/** Icon name (filename without .svg extension) - displays on the left */
|
|
20
|
+
iconName?: string;
|
|
21
|
+
/** Additional classes */
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
26
|
+
/** Error state */
|
|
27
|
+
error?: boolean;
|
|
28
|
+
/** Full width */
|
|
29
|
+
fullWidth?: boolean;
|
|
30
|
+
/** Additional classes */
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Styles
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Base input styles matching the retro aesthetic
|
|
40
|
+
*/
|
|
41
|
+
const baseStyles = `
|
|
42
|
+
font-mondwest
|
|
43
|
+
bg-warm-cloud text-black
|
|
44
|
+
border border-black
|
|
45
|
+
rounded-sm
|
|
46
|
+
placeholder:text-black/40
|
|
47
|
+
focus:outline-none
|
|
48
|
+
focus:ring-2 focus:ring-sun-yellow focus:ring-offset-0
|
|
49
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Size presets
|
|
54
|
+
*/
|
|
55
|
+
const sizeStyles: Record<InputSize, string> = {
|
|
56
|
+
sm: 'h-8 px-2 text-sm',
|
|
57
|
+
md: 'h-10 px-3 text-base',
|
|
58
|
+
lg: 'h-12 px-4 text-base',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error state styles
|
|
63
|
+
*/
|
|
64
|
+
const errorStyles = `
|
|
65
|
+
border-sun-red
|
|
66
|
+
focus:ring-sun-red
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Components
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Text input with retro styling
|
|
75
|
+
*/
|
|
76
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
77
|
+
{
|
|
78
|
+
size = 'md',
|
|
79
|
+
error = false,
|
|
80
|
+
fullWidth = false,
|
|
81
|
+
iconName,
|
|
82
|
+
className = '',
|
|
83
|
+
...props
|
|
84
|
+
},
|
|
85
|
+
ref
|
|
86
|
+
) {
|
|
87
|
+
const iconSize = size === 'sm' ? 14 : size === 'lg' ? 18 : 16;
|
|
88
|
+
const paddingLeft = iconName ? (size === 'sm' ? 'pl-8' : size === 'lg' ? 'pl-12' : 'pl-10') : '';
|
|
89
|
+
|
|
90
|
+
const classes = [
|
|
91
|
+
baseStyles,
|
|
92
|
+
sizeStyles[size],
|
|
93
|
+
error ? errorStyles : '',
|
|
94
|
+
fullWidth ? 'w-full' : '',
|
|
95
|
+
paddingLeft,
|
|
96
|
+
className,
|
|
97
|
+
]
|
|
98
|
+
.join(' ')
|
|
99
|
+
.replace(/\s+/g, ' ')
|
|
100
|
+
.trim();
|
|
101
|
+
|
|
102
|
+
const input = (
|
|
103
|
+
<input ref={ref} className={classes} {...props} />
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (iconName) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="relative">
|
|
109
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
110
|
+
<Icon name={iconName} size={iconSize} className="text-black/40" />
|
|
111
|
+
</div>
|
|
112
|
+
{input}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return input;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Textarea with retro styling
|
|
122
|
+
*/
|
|
123
|
+
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
|
124
|
+
{
|
|
125
|
+
error = false,
|
|
126
|
+
fullWidth = false,
|
|
127
|
+
className = '',
|
|
128
|
+
...props
|
|
129
|
+
},
|
|
130
|
+
ref
|
|
131
|
+
) {
|
|
132
|
+
const classes = [
|
|
133
|
+
baseStyles,
|
|
134
|
+
'px-3 py-2 text-base',
|
|
135
|
+
'resize-y min-h-24',
|
|
136
|
+
error ? errorStyles : '',
|
|
137
|
+
fullWidth ? 'w-full' : '',
|
|
138
|
+
className,
|
|
139
|
+
]
|
|
140
|
+
.join(' ')
|
|
141
|
+
.replace(/\s+/g, ' ')
|
|
142
|
+
.trim();
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<textarea ref={ref} className={classes} {...props} />
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Label Component
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
154
|
+
children: React.ReactNode;
|
|
155
|
+
required?: boolean;
|
|
156
|
+
className?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Form label
|
|
161
|
+
*/
|
|
162
|
+
export function Label({ children, required, className = '', ...props }: LabelProps) {
|
|
163
|
+
return (
|
|
164
|
+
<label
|
|
165
|
+
className={className}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
{required && <span className="text-error-red ml-1">*</span>}
|
|
170
|
+
</label>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default Input;
|
|
175
|
+
|
|
176
|
+
|