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,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;