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,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
|
+
}
|