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,316 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useEscapeKey, useLockBodyScroll } from './hooks/useModalBehavior';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
type SheetSide = 'left' | 'right' | 'top' | 'bottom';
|
|
12
|
+
|
|
13
|
+
interface SheetContextValue {
|
|
14
|
+
open: boolean;
|
|
15
|
+
setOpen: (open: boolean) => void;
|
|
16
|
+
side: SheetSide;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Context
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const SheetContext = createContext<SheetContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
function useSheetContext() {
|
|
26
|
+
const context = useContext(SheetContext);
|
|
27
|
+
if (!context) {
|
|
28
|
+
throw new Error('Sheet components must be used within a Sheet');
|
|
29
|
+
}
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Sheet Root
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
interface SheetProps {
|
|
38
|
+
/** Controlled open state */
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** Default open state */
|
|
41
|
+
defaultOpen?: boolean;
|
|
42
|
+
/** Callback when open state changes */
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
/** Side to slide in from */
|
|
45
|
+
side?: SheetSide;
|
|
46
|
+
/** Children */
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Sheet({
|
|
51
|
+
open: controlledOpen,
|
|
52
|
+
defaultOpen = false,
|
|
53
|
+
onOpenChange,
|
|
54
|
+
side = 'right',
|
|
55
|
+
children,
|
|
56
|
+
}: SheetProps) {
|
|
57
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
58
|
+
const isControlled = controlledOpen !== undefined;
|
|
59
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
60
|
+
|
|
61
|
+
const setOpen = useCallback((newOpen: boolean) => {
|
|
62
|
+
if (!isControlled) {
|
|
63
|
+
setInternalOpen(newOpen);
|
|
64
|
+
}
|
|
65
|
+
onOpenChange?.(newOpen);
|
|
66
|
+
}, [isControlled, onOpenChange]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<SheetContext.Provider value={{ open, setOpen, side }}>
|
|
70
|
+
{children}
|
|
71
|
+
</SheetContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Sheet Trigger
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
interface SheetTriggerProps {
|
|
80
|
+
/** Trigger element */
|
|
81
|
+
children: React.ReactElement;
|
|
82
|
+
/** Pass through as child instead of wrapping */
|
|
83
|
+
asChild?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function SheetTrigger({ children, asChild }: SheetTriggerProps) {
|
|
87
|
+
const { setOpen } = useSheetContext();
|
|
88
|
+
|
|
89
|
+
if (asChild && React.isValidElement(children)) {
|
|
90
|
+
return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
|
|
91
|
+
onClick: () => setOpen(true),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
97
|
+
{children}
|
|
98
|
+
</button>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Sheet Content
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
const sideStyles: Record<SheetSide, { container: string; open: string; closed: string }> = {
|
|
107
|
+
left: {
|
|
108
|
+
container: 'inset-y-0 left-0 h-full w-80 max-w-[90vw]',
|
|
109
|
+
open: 'translate-x-0',
|
|
110
|
+
closed: '-translate-x-full',
|
|
111
|
+
},
|
|
112
|
+
right: {
|
|
113
|
+
container: 'inset-y-0 right-0 h-full w-80 max-w-[90vw]',
|
|
114
|
+
open: 'translate-x-0',
|
|
115
|
+
closed: 'translate-x-full',
|
|
116
|
+
},
|
|
117
|
+
top: {
|
|
118
|
+
container: 'inset-x-0 top-0 w-full h-80 max-h-[90vh]',
|
|
119
|
+
open: 'translate-y-0',
|
|
120
|
+
closed: '-translate-y-full',
|
|
121
|
+
},
|
|
122
|
+
bottom: {
|
|
123
|
+
container: 'inset-x-0 bottom-0 w-full h-80 max-h-[90vh]',
|
|
124
|
+
open: 'translate-y-0',
|
|
125
|
+
closed: 'translate-y-full',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
interface SheetContentProps {
|
|
130
|
+
/** Additional className */
|
|
131
|
+
className?: string;
|
|
132
|
+
/** Children */
|
|
133
|
+
children: React.ReactNode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function SheetContent({ className = '', children }: SheetContentProps) {
|
|
137
|
+
const { open, setOpen, side } = useSheetContext();
|
|
138
|
+
const [mounted, setMounted] = useState(false);
|
|
139
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
140
|
+
const styles = sideStyles[side];
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
setMounted(true);
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
// Handle animation states
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (open) {
|
|
149
|
+
setIsVisible(true);
|
|
150
|
+
} else {
|
|
151
|
+
// Delay hiding until animation completes
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
setIsVisible(false);
|
|
154
|
+
}, 200);
|
|
155
|
+
return () => clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
}, [open]);
|
|
158
|
+
|
|
159
|
+
// Handle escape key
|
|
160
|
+
useEscapeKey(open, () => setOpen(false));
|
|
161
|
+
|
|
162
|
+
// Prevent body scroll when open
|
|
163
|
+
useLockBodyScroll(open);
|
|
164
|
+
|
|
165
|
+
if (!mounted || !isVisible) return null;
|
|
166
|
+
|
|
167
|
+
return createPortal(
|
|
168
|
+
<div className="fixed inset-0 z-50">
|
|
169
|
+
{/* Overlay */}
|
|
170
|
+
<div
|
|
171
|
+
className={`
|
|
172
|
+
absolute inset-0 bg-black/50
|
|
173
|
+
transition-opacity duration-200
|
|
174
|
+
${open ? 'opacity-100' : 'opacity-0'}
|
|
175
|
+
`.trim()}
|
|
176
|
+
onClick={() => setOpen(false)}
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{/* Content */}
|
|
181
|
+
<div
|
|
182
|
+
role="dialog"
|
|
183
|
+
aria-modal="true"
|
|
184
|
+
className={`
|
|
185
|
+
fixed
|
|
186
|
+
${styles.container}
|
|
187
|
+
bg-warm-cloud
|
|
188
|
+
border-black
|
|
189
|
+
${side === 'left' ? 'border-r-2' : ''}
|
|
190
|
+
${side === 'right' ? 'border-l-2' : ''}
|
|
191
|
+
${side === 'top' ? 'border-b-2' : ''}
|
|
192
|
+
${side === 'bottom' ? 'border-t-2' : ''}
|
|
193
|
+
shadow-[4px_4px_0_0_var(--color-black)]
|
|
194
|
+
transform transition-transform duration-200 ease-out
|
|
195
|
+
${open ? styles.open : styles.closed}
|
|
196
|
+
${className}
|
|
197
|
+
`.trim()}
|
|
198
|
+
>
|
|
199
|
+
{children}
|
|
200
|
+
</div>
|
|
201
|
+
</div>,
|
|
202
|
+
document.body
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Sheet Header, Title, Description
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
interface SheetHeaderProps {
|
|
211
|
+
/** Additional className */
|
|
212
|
+
className?: string;
|
|
213
|
+
/** Children */
|
|
214
|
+
children: React.ReactNode;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function SheetHeader({ className = '', children }: SheetHeaderProps) {
|
|
218
|
+
return (
|
|
219
|
+
<div className={`px-6 pt-6 pb-4 border-b border-black/20 ${className}`.trim()}>
|
|
220
|
+
{children}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface SheetTitleProps {
|
|
226
|
+
/** Additional className */
|
|
227
|
+
className?: string;
|
|
228
|
+
/** Children */
|
|
229
|
+
children: React.ReactNode;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function SheetTitle({ className = '', children }: SheetTitleProps) {
|
|
233
|
+
return (
|
|
234
|
+
<h2 className={`font-joystix text-base uppercase text-black ${className}`.trim()}>
|
|
235
|
+
{children}
|
|
236
|
+
</h2>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface SheetDescriptionProps {
|
|
241
|
+
/** Additional className */
|
|
242
|
+
className?: string;
|
|
243
|
+
/** Children */
|
|
244
|
+
children: React.ReactNode;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function SheetDescription({ className = '', children }: SheetDescriptionProps) {
|
|
248
|
+
return (
|
|
249
|
+
<p className={`font-mondwest text-base text-black/70 mt-2 ${className}`.trim()}>
|
|
250
|
+
{children}
|
|
251
|
+
</p>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// Sheet Body & Footer
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
interface SheetBodyProps {
|
|
260
|
+
/** Additional className */
|
|
261
|
+
className?: string;
|
|
262
|
+
/** Children */
|
|
263
|
+
children: React.ReactNode;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function SheetBody({ className = '', children }: SheetBodyProps) {
|
|
267
|
+
return (
|
|
268
|
+
<div className={`px-6 py-4 flex-1 overflow-auto ${className}`.trim()}>
|
|
269
|
+
{children}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface SheetFooterProps {
|
|
275
|
+
/** Additional className */
|
|
276
|
+
className?: string;
|
|
277
|
+
/** Children */
|
|
278
|
+
children: React.ReactNode;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function SheetFooter({ className = '', children }: SheetFooterProps) {
|
|
282
|
+
return (
|
|
283
|
+
<div className={`px-6 pb-6 pt-4 border-t border-black/20 flex justify-end gap-2 ${className}`.trim()}>
|
|
284
|
+
{children}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// Sheet Close
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
interface SheetCloseProps {
|
|
294
|
+
/** Close button element */
|
|
295
|
+
children: React.ReactElement;
|
|
296
|
+
/** Pass through as child instead of wrapping */
|
|
297
|
+
asChild?: boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function SheetClose({ children, asChild }: SheetCloseProps) {
|
|
301
|
+
const { setOpen } = useSheetContext();
|
|
302
|
+
|
|
303
|
+
if (asChild && React.isValidElement(children)) {
|
|
304
|
+
return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
|
|
305
|
+
onClick: () => setOpen(false),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<button type="button" onClick={() => setOpen(false)}>
|
|
311
|
+
{children}
|
|
312
|
+
</button>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default Sheet;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
type SliderSize = 'sm' | 'md' | 'lg';
|
|
10
|
+
|
|
11
|
+
interface SliderProps {
|
|
12
|
+
/** Current value */
|
|
13
|
+
value: number;
|
|
14
|
+
/** Change handler */
|
|
15
|
+
onChange: (value: number) => void;
|
|
16
|
+
/** Minimum value */
|
|
17
|
+
min?: number;
|
|
18
|
+
/** Maximum value */
|
|
19
|
+
max?: number;
|
|
20
|
+
/** Step increment */
|
|
21
|
+
step?: number;
|
|
22
|
+
/** Size preset */
|
|
23
|
+
size?: SliderSize;
|
|
24
|
+
/** Disabled state */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Show value label */
|
|
27
|
+
showValue?: boolean;
|
|
28
|
+
/** Label text */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Additional className */
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Styles
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const sizeStyles: Record<SliderSize, { track: string; thumb: string }> = {
|
|
39
|
+
sm: {
|
|
40
|
+
track: 'h-1',
|
|
41
|
+
thumb: 'w-3 h-3 -mt-1',
|
|
42
|
+
},
|
|
43
|
+
md: {
|
|
44
|
+
track: 'h-2',
|
|
45
|
+
thumb: 'w-4 h-4 -mt-1',
|
|
46
|
+
},
|
|
47
|
+
lg: {
|
|
48
|
+
track: 'h-3',
|
|
49
|
+
thumb: 'w-5 h-5 -mt-1',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Component
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Slider component - Numeric range input
|
|
59
|
+
*/
|
|
60
|
+
export function Slider({
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
min = 0,
|
|
64
|
+
max = 100,
|
|
65
|
+
step = 1,
|
|
66
|
+
size = 'md',
|
|
67
|
+
disabled = false,
|
|
68
|
+
showValue = false,
|
|
69
|
+
label,
|
|
70
|
+
className = '',
|
|
71
|
+
}: SliderProps) {
|
|
72
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
74
|
+
const styles = sizeStyles[size];
|
|
75
|
+
|
|
76
|
+
// Calculate percentage
|
|
77
|
+
const percentage = ((value - min) / (max - min)) * 100;
|
|
78
|
+
|
|
79
|
+
// Snap value to step
|
|
80
|
+
const snapToStep = useCallback((val: number) => {
|
|
81
|
+
const stepped = Math.round((val - min) / step) * step + min;
|
|
82
|
+
return Math.max(min, Math.min(max, stepped));
|
|
83
|
+
}, [min, max, step]);
|
|
84
|
+
|
|
85
|
+
// Calculate value from position
|
|
86
|
+
const getValueFromPosition = useCallback((clientX: number) => {
|
|
87
|
+
if (!trackRef.current) return value;
|
|
88
|
+
|
|
89
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
90
|
+
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
91
|
+
const newValue = min + percent * (max - min);
|
|
92
|
+
return snapToStep(newValue);
|
|
93
|
+
}, [min, max, value, snapToStep]);
|
|
94
|
+
|
|
95
|
+
// Handle mouse/touch events
|
|
96
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
97
|
+
if (disabled) return;
|
|
98
|
+
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
setIsDragging(true);
|
|
101
|
+
|
|
102
|
+
const newValue = getValueFromPosition(e.clientX);
|
|
103
|
+
onChange(newValue);
|
|
104
|
+
}, [disabled, getValueFromPosition, onChange]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!isDragging) return;
|
|
108
|
+
|
|
109
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
110
|
+
const newValue = getValueFromPosition(e.clientX);
|
|
111
|
+
onChange(newValue);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handlePointerUp = () => {
|
|
115
|
+
setIsDragging(false);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
document.addEventListener('pointermove', handlePointerMove);
|
|
119
|
+
document.addEventListener('pointerup', handlePointerUp);
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
document.removeEventListener('pointermove', handlePointerMove);
|
|
123
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
124
|
+
};
|
|
125
|
+
}, [isDragging, getValueFromPosition, onChange]);
|
|
126
|
+
|
|
127
|
+
// Handle keyboard
|
|
128
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
129
|
+
if (disabled) return;
|
|
130
|
+
|
|
131
|
+
let newValue = value;
|
|
132
|
+
switch (e.key) {
|
|
133
|
+
case 'ArrowRight':
|
|
134
|
+
case 'ArrowUp':
|
|
135
|
+
newValue = Math.min(max, value + step);
|
|
136
|
+
break;
|
|
137
|
+
case 'ArrowLeft':
|
|
138
|
+
case 'ArrowDown':
|
|
139
|
+
newValue = Math.max(min, value - step);
|
|
140
|
+
break;
|
|
141
|
+
case 'Home':
|
|
142
|
+
newValue = min;
|
|
143
|
+
break;
|
|
144
|
+
case 'End':
|
|
145
|
+
newValue = max;
|
|
146
|
+
break;
|
|
147
|
+
default:
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
onChange(newValue);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className={`space-y-2 ${className}`.trim()}>
|
|
157
|
+
{/* Label & Value */}
|
|
158
|
+
{(label || showValue) && (
|
|
159
|
+
<div className="flex items-center justify-between">
|
|
160
|
+
{label && (
|
|
161
|
+
<span className="font-mondwest text-base text-black">
|
|
162
|
+
{label}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{showValue && (
|
|
166
|
+
<span className="font-mondwest text-sm text-black/60">
|
|
167
|
+
{value}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Track */}
|
|
174
|
+
<div
|
|
175
|
+
ref={trackRef}
|
|
176
|
+
role="slider"
|
|
177
|
+
tabIndex={disabled ? -1 : 0}
|
|
178
|
+
aria-valuemin={min}
|
|
179
|
+
aria-valuemax={max}
|
|
180
|
+
aria-valuenow={value}
|
|
181
|
+
aria-disabled={disabled}
|
|
182
|
+
onPointerDown={handlePointerDown}
|
|
183
|
+
onKeyDown={handleKeyDown}
|
|
184
|
+
className={`
|
|
185
|
+
relative w-full
|
|
186
|
+
${styles.track}
|
|
187
|
+
bg-black/10
|
|
188
|
+
border border-black
|
|
189
|
+
rounded-sm
|
|
190
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
191
|
+
focus:outline-none focus:ring-2 focus:ring-tertiary focus:ring-offset-2
|
|
192
|
+
`.trim()}
|
|
193
|
+
>
|
|
194
|
+
{/* Filled Track */}
|
|
195
|
+
<div
|
|
196
|
+
className={`
|
|
197
|
+
absolute top-0 left-0 h-full
|
|
198
|
+
bg-sun-yellow
|
|
199
|
+
rounded-sm
|
|
200
|
+
`.trim()}
|
|
201
|
+
style={{ width: `${percentage}%` }}
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
{/* Thumb */}
|
|
205
|
+
<div
|
|
206
|
+
className={`
|
|
207
|
+
absolute top-[7px]
|
|
208
|
+
${styles.thumb}
|
|
209
|
+
bg-cream
|
|
210
|
+
border border-black
|
|
211
|
+
rounded
|
|
212
|
+
transform -translate-y-1/2
|
|
213
|
+
${isDragging ? 'scale-110' : ''}
|
|
214
|
+
transition-transform
|
|
215
|
+
`.trim()}
|
|
216
|
+
style={{ left: `calc(${percentage}% - ${parseInt(styles.thumb.split(' ')[0].replace('w-', '')) * 2}px)` }}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default Slider;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
type SwitchSize = 'sm' | 'md' | 'lg';
|
|
10
|
+
|
|
11
|
+
interface SwitchProps {
|
|
12
|
+
/** Checked state */
|
|
13
|
+
checked: boolean;
|
|
14
|
+
/** Change handler */
|
|
15
|
+
onChange: (checked: boolean) => void;
|
|
16
|
+
/** Size preset */
|
|
17
|
+
size?: SwitchSize;
|
|
18
|
+
/** Disabled state */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Label text */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Label position */
|
|
23
|
+
labelPosition?: 'left' | 'right';
|
|
24
|
+
/** Additional className */
|
|
25
|
+
className?: string;
|
|
26
|
+
/** ID for accessibility */
|
|
27
|
+
id?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Styles
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const sizeStyles: Record<SwitchSize, { track: string; thumb: string; translate: string }> = {
|
|
35
|
+
sm: {
|
|
36
|
+
track: 'w-8 h-4',
|
|
37
|
+
thumb: 'w-3 h-3',
|
|
38
|
+
translate: 'translate-x-4',
|
|
39
|
+
},
|
|
40
|
+
md: {
|
|
41
|
+
track: 'w-10 h-5',
|
|
42
|
+
thumb: 'w-4 h-4',
|
|
43
|
+
translate: 'translate-x-5',
|
|
44
|
+
},
|
|
45
|
+
lg: {
|
|
46
|
+
track: 'w-12 h-6',
|
|
47
|
+
thumb: 'w-5 h-5',
|
|
48
|
+
translate: 'translate-x-6',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Component
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Switch component - On/off toggle
|
|
58
|
+
*/
|
|
59
|
+
export function Switch({
|
|
60
|
+
checked,
|
|
61
|
+
onChange,
|
|
62
|
+
size = 'md',
|
|
63
|
+
disabled = false,
|
|
64
|
+
label,
|
|
65
|
+
labelPosition = 'right',
|
|
66
|
+
className = '',
|
|
67
|
+
id,
|
|
68
|
+
}: SwitchProps) {
|
|
69
|
+
const styles = sizeStyles[size];
|
|
70
|
+
const switchId = id || `switch-${Math.random().toString(36).slice(2)}`;
|
|
71
|
+
|
|
72
|
+
const handleClick = () => {
|
|
73
|
+
if (!disabled) {
|
|
74
|
+
onChange(!checked);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
79
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
handleClick();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const switchElement = (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
role="switch"
|
|
89
|
+
id={switchId}
|
|
90
|
+
aria-checked={checked}
|
|
91
|
+
disabled={disabled}
|
|
92
|
+
onClick={handleClick}
|
|
93
|
+
onKeyDown={handleKeyDown}
|
|
94
|
+
className={`
|
|
95
|
+
relative inline-flex items-center
|
|
96
|
+
${styles.track}
|
|
97
|
+
rounded-full
|
|
98
|
+
border border-black
|
|
99
|
+
transition-colors
|
|
100
|
+
${checked ? 'bg-sun-yellow' : 'bg-black/10'}
|
|
101
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
102
|
+
focus:outline-none focus:ring-2 focus:ring-tertiary focus:ring-offset-2
|
|
103
|
+
`.trim()}
|
|
104
|
+
>
|
|
105
|
+
{/* Thumb */}
|
|
106
|
+
<span
|
|
107
|
+
className={`
|
|
108
|
+
${styles.thumb}
|
|
109
|
+
rounded-full
|
|
110
|
+
bg-black
|
|
111
|
+
border border-black
|
|
112
|
+
transform transition-transform
|
|
113
|
+
${checked ? styles.translate : 'translate-x-0.5'}
|
|
114
|
+
`.trim()}
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
/>
|
|
117
|
+
</button>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (!label) {
|
|
121
|
+
return <div className={className}>{switchElement}</div>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={`inline-flex items-center gap-2 ${className}`.trim()}>
|
|
126
|
+
{labelPosition === 'left' && (
|
|
127
|
+
<label
|
|
128
|
+
htmlFor={switchId}
|
|
129
|
+
className={`
|
|
130
|
+
font-mondwest text-base text-black
|
|
131
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
132
|
+
`.trim()}
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</label>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{switchElement}
|
|
139
|
+
|
|
140
|
+
{labelPosition === 'right' && (
|
|
141
|
+
<label
|
|
142
|
+
htmlFor={switchId}
|
|
143
|
+
className={`
|
|
144
|
+
font-mondwest text-base text-black
|
|
145
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
146
|
+
`.trim()}
|
|
147
|
+
>
|
|
148
|
+
{label}
|
|
149
|
+
</label>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default Switch;
|