@aprovan/bobbin 0.1.0-dev.6bd527d

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 (45) hide show
  1. package/.turbo/turbo-build.log +16 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +402 -0
  4. package/dist/index.js +3704 -0
  5. package/package.json +30 -0
  6. package/src/Bobbin.tsx +89 -0
  7. package/src/components/EditPanel/EditPanel.tsx +376 -0
  8. package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
  9. package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
  10. package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
  11. package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
  12. package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
  13. package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
  14. package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
  15. package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
  16. package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
  17. package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
  18. package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
  19. package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
  20. package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
  21. package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
  22. package/src/components/Inspector/Inspector.tsx +221 -0
  23. package/src/components/Overlay/ControlHandles.tsx +572 -0
  24. package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
  25. package/src/components/Overlay/SelectionOverlay.tsx +73 -0
  26. package/src/components/Pill/Pill.tsx +155 -0
  27. package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
  28. package/src/core/changeSerializer.ts +139 -0
  29. package/src/core/useBobbin.ts +399 -0
  30. package/src/core/useChangeTracker.ts +186 -0
  31. package/src/core/useClipboard.ts +21 -0
  32. package/src/core/useElementSelection.ts +146 -0
  33. package/src/index.ts +46 -0
  34. package/src/tokens/borders.ts +19 -0
  35. package/src/tokens/colors.ts +150 -0
  36. package/src/tokens/index.ts +37 -0
  37. package/src/tokens/shadows.ts +10 -0
  38. package/src/tokens/spacing.ts +37 -0
  39. package/src/tokens/typography.ts +51 -0
  40. package/src/types.ts +157 -0
  41. package/src/utils/animation.ts +40 -0
  42. package/src/utils/dom.ts +36 -0
  43. package/src/utils/selectors.ts +76 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +10 -0
@@ -0,0 +1,142 @@
1
+ import { useState, useMemo } from 'react';
2
+
3
+ interface QuickSelectDropdownProps {
4
+ value: string;
5
+ tokens: Record<string, string>;
6
+ quickKeys: string[]; // Keys to show as quick buttons
7
+ onChange: (value: string) => void;
8
+ placeholder?: string;
9
+ }
10
+
11
+ export function QuickSelectDropdown({
12
+ value,
13
+ tokens,
14
+ quickKeys,
15
+ onChange,
16
+ placeholder = 'More...',
17
+ }: QuickSelectDropdownProps) {
18
+ const [showDropdown, setShowDropdown] = useState(false);
19
+
20
+ // Split tokens into quick buttons and dropdown items
21
+ const { quickItems, dropdownItems } = useMemo(() => {
22
+ const quick: Array<{ key: string; value: string }> = [];
23
+ const dropdown: Array<{ key: string; value: string }> = [];
24
+
25
+ for (const [key, tokenValue] of Object.entries(tokens)) {
26
+ if (quickKeys.includes(key)) {
27
+ quick.push({ key, value: tokenValue });
28
+ } else {
29
+ dropdown.push({ key, value: tokenValue });
30
+ }
31
+ }
32
+
33
+ // Sort quick items by quickKeys order
34
+ quick.sort((a, b) => quickKeys.indexOf(a.key) - quickKeys.indexOf(b.key));
35
+
36
+ return { quickItems: quick, dropdownItems: dropdown };
37
+ }, [tokens, quickKeys]);
38
+
39
+ // Check if current value matches a token
40
+ const isSelected = (tokenValue: string) => {
41
+ // Normalize values for comparison
42
+ const normalizeValue = (v: string) => v.replace(/\s+/g, '').toLowerCase();
43
+ return normalizeValue(value) === normalizeValue(tokenValue);
44
+ };
45
+
46
+ return (
47
+ <div style={{ display: 'flex', alignItems: 'center', gap: '3px', flexWrap: 'wrap' }}>
48
+ {/* Quick select buttons */}
49
+ {quickItems.map(({ key, value: tokenValue }) => (
50
+ <button
51
+ key={key}
52
+ onClick={() => onChange(tokenValue)}
53
+ style={{
54
+ padding: '3px 6px',
55
+ borderRadius: '3px',
56
+ border: '1px solid',
57
+ borderColor: isSelected(tokenValue) ? '#18181b' : '#e4e4e7',
58
+ backgroundColor: isSelected(tokenValue) ? '#18181b' : '#ffffff',
59
+ color: isSelected(tokenValue) ? '#fafafa' : '#18181b',
60
+ fontSize: '10px',
61
+ fontFamily: 'ui-monospace, monospace',
62
+ cursor: 'pointer',
63
+ transition: 'all 0.1s ease',
64
+ minWidth: '28px',
65
+ textAlign: 'center',
66
+ }}
67
+ title={`${key}: ${tokenValue}`}
68
+ >
69
+ {key}
70
+ </button>
71
+ ))}
72
+
73
+ {/* Dropdown for remaining options */}
74
+ {dropdownItems.length > 0 && (
75
+ <div style={{ position: 'relative' }}>
76
+ <button
77
+ onClick={() => setShowDropdown(!showDropdown)}
78
+ onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
79
+ style={{
80
+ padding: '3px 6px',
81
+ borderRadius: '3px',
82
+ border: '1px solid #e4e4e7',
83
+ backgroundColor: '#ffffff',
84
+ color: '#71717a',
85
+ fontSize: '10px',
86
+ cursor: 'pointer',
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ gap: '2px',
90
+ }}
91
+ >
92
+ <span>...</span>
93
+ </button>
94
+
95
+ {showDropdown && (
96
+ <div
97
+ style={{
98
+ position: 'absolute',
99
+ top: '100%',
100
+ left: 0,
101
+ marginTop: '2px',
102
+ backgroundColor: '#ffffff',
103
+ border: '1px solid #e4e4e7',
104
+ borderRadius: '4px',
105
+ boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
106
+ zIndex: 10,
107
+ maxHeight: '150px',
108
+ overflow: 'auto',
109
+ minWidth: '100px',
110
+ }}
111
+ >
112
+ {dropdownItems.map(({ key, value: tokenValue }) => (
113
+ <button
114
+ key={key}
115
+ onClick={() => {
116
+ onChange(tokenValue);
117
+ setShowDropdown(false);
118
+ }}
119
+ style={{
120
+ display: 'block',
121
+ width: '100%',
122
+ padding: '4px 8px',
123
+ border: 'none',
124
+ backgroundColor: isSelected(tokenValue) ? '#f4f4f5' : 'transparent',
125
+ color: '#18181b',
126
+ fontSize: '10px',
127
+ fontFamily: 'ui-monospace, monospace',
128
+ cursor: 'pointer',
129
+ textAlign: 'left',
130
+ }}
131
+ >
132
+ <span style={{ fontWeight: 500 }}>{key}</span>
133
+ <span style={{ color: '#71717a', marginLeft: '4px' }}>{tokenValue}</span>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ )}
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,94 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ interface SliderInputProps {
4
+ value: number;
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ unit?: string;
9
+ onChange: (value: number) => void;
10
+ label?: string;
11
+ }
12
+
13
+ export function SliderInput({
14
+ value,
15
+ min = 0,
16
+ max = 100,
17
+ step = 1,
18
+ unit = 'px',
19
+ onChange,
20
+ label,
21
+ }: SliderInputProps) {
22
+ const [isDragging, setIsDragging] = useState(false);
23
+ const [localValue, setLocalValue] = useState(value);
24
+
25
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
26
+ e.preventDefault();
27
+ setIsDragging(true);
28
+ const startX = e.clientX;
29
+ const startValue = localValue;
30
+
31
+ const handleMouseMove = (e: MouseEvent) => {
32
+ const delta = e.clientX - startX;
33
+ const sensitivity = e.shiftKey ? 0.1 : 1; // Shift for fine control
34
+ const newValue = Math.min(max, Math.max(min, startValue + delta * sensitivity));
35
+ const steppedValue = Math.round(newValue / step) * step;
36
+ setLocalValue(steppedValue);
37
+ onChange(steppedValue);
38
+ };
39
+
40
+ const handleMouseUp = () => {
41
+ setIsDragging(false);
42
+ document.removeEventListener('mousemove', handleMouseMove);
43
+ document.removeEventListener('mouseup', handleMouseUp);
44
+ };
45
+
46
+ document.addEventListener('mousemove', handleMouseMove);
47
+ document.addEventListener('mouseup', handleMouseUp);
48
+ }, [localValue, min, max, step, onChange]);
49
+
50
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
51
+ const newValue = parseFloat(e.target.value) || 0;
52
+ setLocalValue(newValue);
53
+ onChange(newValue);
54
+ };
55
+
56
+ return (
57
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
58
+ {label && (
59
+ <span style={{ fontSize: '10px', color: '#71717a', width: '18px' }}>{label}</span>
60
+ )}
61
+ <div
62
+ style={{
63
+ flex: 1,
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ backgroundColor: '#ffffff',
67
+ border: '1px solid #e4e4e7',
68
+ borderRadius: '4px',
69
+ padding: '3px 6px',
70
+ cursor: 'ew-resize',
71
+ }}
72
+ onMouseDown={handleMouseDown}
73
+ >
74
+ <input
75
+ type="number"
76
+ value={localValue}
77
+ onChange={handleInputChange}
78
+ style={{
79
+ width: '100%',
80
+ backgroundColor: 'transparent',
81
+ border: 'none',
82
+ color: '#18181b',
83
+ fontSize: '11px',
84
+ fontFamily: 'ui-monospace, monospace',
85
+ outline: 'none',
86
+ cursor: isDragging ? 'ew-resize' : 'text',
87
+ }}
88
+ onClick={(e) => e.stopPropagation()}
89
+ />
90
+ <span style={{ fontSize: '10px', color: '#a1a1aa', marginLeft: '4px' }}>{unit}</span>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,285 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ type Side = 'top' | 'right' | 'bottom' | 'left';
4
+
5
+ interface SpacingControlProps {
6
+ values: { top: string; right: string; bottom: string; left: string };
7
+ onChange: (side: Side, value: string) => void;
8
+ label: string;
9
+ hasChanges?: { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean };
10
+ }
11
+
12
+ // Chain link icon (for linked sides)
13
+ const ChainIcon = ({ linked }: { linked: boolean }) => (
14
+ <svg
15
+ width="12"
16
+ height="12"
17
+ viewBox="0 0 24 24"
18
+ fill="none"
19
+ stroke="currentColor"
20
+ strokeWidth="2"
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ style={{ opacity: linked ? 1 : 0.4 }}
24
+ >
25
+ {linked ? (
26
+ // Linked chain
27
+ <>
28
+ <path d="M9 17H7A5 5 0 0 1 7 7h2" />
29
+ <path d="M15 7h2a5 5 0 1 1 0 10h-2" />
30
+ <line x1="8" y1="12" x2="16" y2="12" />
31
+ </>
32
+ ) : (
33
+ // Broken chain
34
+ <>
35
+ <path d="M9 17H7A5 5 0 0 1 7 7h2" />
36
+ <path d="M15 7h2a5 5 0 1 1 0 10h-2" />
37
+ <line x1="8" y1="12" x2="10" y2="12" />
38
+ <line x1="14" y1="12" x2="16" y2="12" />
39
+ </>
40
+ )}
41
+ </svg>
42
+ );
43
+
44
+ // Check if a CSS value is valid
45
+ function isValidCSSValue(value: string): boolean {
46
+ if (!value || value.trim() === '') return true; // Empty is valid (will revert to default)
47
+
48
+ // Common valid patterns
49
+ const validPatterns = [
50
+ /^-?\d+(\.\d+)?(px|em|rem|%|vh|vw|pt|cm|mm|in|pc)?$/i,
51
+ /^auto$/i,
52
+ /^inherit$/i,
53
+ /^initial$/i,
54
+ /^unset$/i,
55
+ /^0$/,
56
+ ];
57
+
58
+ return validPatterns.some(pattern => pattern.test(value.trim()));
59
+ }
60
+
61
+ export function SpacingControl({ values, onChange, label, hasChanges = {} }: SpacingControlProps) {
62
+ const [editingSide, setEditingSide] = useState<Side | null>(null);
63
+ const [editValue, setEditValue] = useState('');
64
+ const [linkVertical, setLinkVertical] = useState(false);
65
+ const [linkHorizontal, setLinkHorizontal] = useState(false);
66
+ const [highlightLinked, setHighlightLinked] = useState<'vertical' | 'horizontal' | null>(null);
67
+ const inputRef = useRef<HTMLInputElement>(null);
68
+
69
+ // Focus input when editing starts
70
+ useEffect(() => {
71
+ if (editingSide && inputRef.current) {
72
+ inputRef.current.focus();
73
+ inputRef.current.select();
74
+ }
75
+ }, [editingSide]);
76
+
77
+ const handleChange = useCallback((side: Side, value: string) => {
78
+ onChange(side, value);
79
+
80
+ // Apply linked changes
81
+ if (linkVertical && (side === 'top' || side === 'bottom')) {
82
+ onChange(side === 'top' ? 'bottom' : 'top', value);
83
+ }
84
+ if (linkHorizontal && (side === 'left' || side === 'right')) {
85
+ onChange(side === 'left' ? 'right' : 'left', value);
86
+ }
87
+ }, [onChange, linkVertical, linkHorizontal]);
88
+
89
+ const handleBoxClick = (side: Side, e: React.MouseEvent) => {
90
+ e.stopPropagation();
91
+ setEditingSide(side);
92
+ setEditValue(values[side]);
93
+
94
+ // Highlight linked sides
95
+ if (linkVertical && (side === 'top' || side === 'bottom')) {
96
+ setHighlightLinked('vertical');
97
+ } else if (linkHorizontal && (side === 'left' || side === 'right')) {
98
+ setHighlightLinked('horizontal');
99
+ }
100
+ };
101
+
102
+ const handleInputChange = (value: string) => {
103
+ setEditValue(value);
104
+ // Apply changes even if temporarily invalid (user is typing)
105
+ if (editingSide) {
106
+ handleChange(editingSide, value);
107
+ }
108
+ };
109
+
110
+ const handleInputBlur = () => {
111
+ setEditingSide(null);
112
+ setHighlightLinked(null);
113
+ };
114
+
115
+ const handleKeyDown = (e: React.KeyboardEvent) => {
116
+ if (e.key === 'Enter' || e.key === 'Escape') {
117
+ setEditingSide(null);
118
+ setHighlightLinked(null);
119
+ }
120
+ };
121
+
122
+ const formatValue = (value: string) => {
123
+ // Extract just the numeric value for compact display
124
+ const match = value.match(/^([\d.]+)/);
125
+ return match ? match[1] : value || '0';
126
+ };
127
+
128
+ const isLinkedHighlighted = (side: Side): boolean => {
129
+ if (!highlightLinked) return false;
130
+ if (highlightLinked === 'vertical' && (side === 'top' || side === 'bottom')) return true;
131
+ if (highlightLinked === 'horizontal' && (side === 'left' || side === 'right')) return true;
132
+ return false;
133
+ };
134
+
135
+ const getBoxStyle = (side: Side): React.CSSProperties => {
136
+ const isEditing = editingSide === side;
137
+ const isLinked = isLinkedHighlighted(side);
138
+ const isValid = isEditing ? isValidCSSValue(editValue) : true;
139
+ const hasChange = hasChanges[side];
140
+
141
+ return {
142
+ padding: isEditing ? '0' : '2px 4px',
143
+ borderRadius: '3px',
144
+ border: `1px solid ${
145
+ !isValid ? '#ef4444' :
146
+ hasChange ? '#3b82f6' :
147
+ isLinked ? '#8b5cf6' :
148
+ isEditing ? '#18181b' :
149
+ '#e4e4e7'
150
+ }`,
151
+ backgroundColor:
152
+ !isValid ? '#fef2f2' :
153
+ hasChange ? '#eff6ff' :
154
+ isLinked ? '#f5f3ff' :
155
+ '#ffffff',
156
+ color: '#18181b',
157
+ fontSize: '9px',
158
+ textAlign: 'center',
159
+ cursor: 'text',
160
+ minWidth: '28px',
161
+ transition: 'all 0.1s ease',
162
+ outline: 'none',
163
+ };
164
+ };
165
+
166
+ const linkButtonStyle = (active: boolean): React.CSSProperties => ({
167
+ padding: '2px 4px',
168
+ borderRadius: '3px',
169
+ border: `1px solid ${active ? '#8b5cf6' : '#e4e4e7'}`,
170
+ backgroundColor: active ? '#f5f3ff' : '#ffffff',
171
+ color: active ? '#8b5cf6' : '#71717a',
172
+ cursor: 'pointer',
173
+ display: 'flex',
174
+ alignItems: 'center',
175
+ justifyContent: 'center',
176
+ transition: 'all 0.1s ease',
177
+ });
178
+
179
+ const renderValueBox = (side: Side, position: React.CSSProperties) => {
180
+ const isEditing = editingSide === side;
181
+
182
+ return (
183
+ <div style={{ position: 'absolute', ...position }}>
184
+ {isEditing ? (
185
+ <input
186
+ ref={inputRef}
187
+ type="text"
188
+ value={editValue}
189
+ onChange={(e) => handleInputChange(e.target.value)}
190
+ onBlur={handleInputBlur}
191
+ onKeyDown={handleKeyDown}
192
+ style={{
193
+ ...getBoxStyle(side),
194
+ width: '36px',
195
+ fontFamily: 'inherit',
196
+ }}
197
+ />
198
+ ) : (
199
+ <div
200
+ onClick={(e) => handleBoxClick(side, e)}
201
+ style={getBoxStyle(side)}
202
+ title={`${side}: ${values[side]}`}
203
+ >
204
+ {formatValue(values[side])}
205
+ </div>
206
+ )}
207
+ </div>
208
+ );
209
+ };
210
+
211
+ return (
212
+ <div>
213
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
214
+ <label style={{ fontSize: '10px', color: '#71717a' }}>
215
+ {label}
216
+ </label>
217
+ <div style={{ display: 'flex', gap: '2px' }}>
218
+ <button
219
+ style={linkButtonStyle(linkVertical)}
220
+ onClick={() => setLinkVertical(!linkVertical)}
221
+ title={linkVertical ? 'Unlink top/bottom' : 'Link top/bottom'}
222
+ >
223
+ <span style={{ transform: 'rotate(90deg)', display: 'flex' }}>
224
+ <ChainIcon linked={linkVertical} />
225
+ </span>
226
+ </button>
227
+ <button
228
+ style={linkButtonStyle(linkHorizontal)}
229
+ onClick={() => setLinkHorizontal(!linkHorizontal)}
230
+ title={linkHorizontal ? 'Unlink left/right' : 'Link left/right'}
231
+ >
232
+ <ChainIcon linked={linkHorizontal} />
233
+ </button>
234
+ </div>
235
+ </div>
236
+
237
+ {/* Visual spacing box with outline square in center */}
238
+ <div
239
+ style={{
240
+ position: 'relative',
241
+ width: '100px',
242
+ height: '40px',
243
+ margin: '0 auto',
244
+ }}
245
+ >
246
+ {/* Outline square connecting all sides */}
247
+ <svg
248
+ style={{
249
+ position: 'absolute',
250
+ top: 0,
251
+ left: 0,
252
+ width: '100%',
253
+ height: '100%',
254
+ pointerEvents: 'none',
255
+ }}
256
+ viewBox="0 0 100 70"
257
+ >
258
+ {/* Outer rectangle outline */}
259
+ <rect
260
+ x="10"
261
+ y="10"
262
+ width="80"
263
+ height="50"
264
+ fill="none"
265
+ stroke="lightgray"
266
+ strokeWidth="1"
267
+ strokeDasharray="3,2"
268
+ rx="2"
269
+ />
270
+ {/* Lines connecting to value boxes */}
271
+ <line x1="50" y1="10" x2="50" y2="2" stroke="lightgray" strokeWidth="1" />
272
+ <line x1="50" y1="60" x2="50" y2="68" stroke="lightgray" strokeWidth="1" />
273
+ <line x1="10" y1="35" x2="2" y2="35" stroke="lightgray" strokeWidth="1" />
274
+ <line x1="90" y1="35" x2="98" y2="35" stroke="lightgray" strokeWidth="1" />
275
+ </svg>
276
+
277
+ {/* Value boxes at each side */}
278
+ {renderValueBox('top', { top: '-8px', left: '50%', transform: 'translateX(-50%)' })}
279
+ {renderValueBox('bottom', { bottom: '-8px', left: '50%', transform: 'translateX(-50%)' })}
280
+ {renderValueBox('left', { left: '-12px', top: '50%', transform: 'translateY(-50%)' })}
281
+ {renderValueBox('right', { right: '-12px', top: '50%', transform: 'translateY(-50%)' })}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ interface ToggleGroupProps {
4
+ value: string;
5
+ options: Array<{ value: string; label: ReactNode }>;
6
+ onChange: (value: string) => void;
7
+ }
8
+
9
+ export function ToggleGroup({ value, options, onChange }: ToggleGroupProps) {
10
+ return (
11
+ <div style={{ display: 'flex', gap: '2px' }}>
12
+ {options.map(option => (
13
+ <button
14
+ key={option.value}
15
+ onClick={() => onChange(option.value)}
16
+ style={{
17
+ flex: 1,
18
+ padding: '4px 6px',
19
+ borderRadius: '4px',
20
+ border: '1px solid #e4e4e7',
21
+ backgroundColor: value === option.value ? '#18181b' : '#ffffff',
22
+ color: value === option.value ? '#fafafa' : '#71717a',
23
+ cursor: 'pointer',
24
+ fontSize: '10px',
25
+ fontWeight: 500,
26
+ transition: 'all 0.1s ease',
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ }}
31
+ >
32
+ {option.label}
33
+ </button>
34
+ ))}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,33 @@
1
+ interface TokenDropdownProps {
2
+ value: string;
3
+ tokens: Record<string, string>;
4
+ onChange: (value: string) => void;
5
+ placeholder?: string;
6
+ }
7
+
8
+ export function TokenDropdown({ value, tokens, onChange, placeholder = 'Select...' }: TokenDropdownProps) {
9
+ return (
10
+ <select
11
+ value={value}
12
+ onChange={(e) => onChange(e.target.value)}
13
+ style={{
14
+ width: '100%',
15
+ padding: '4px 8px',
16
+ borderRadius: '4px',
17
+ border: '1px solid #e4e4e7',
18
+ backgroundColor: '#ffffff',
19
+ color: '#18181b',
20
+ fontSize: '11px',
21
+ cursor: 'pointer',
22
+ outline: 'none',
23
+ }}
24
+ >
25
+ <option value="">{placeholder}</option>
26
+ {Object.entries(tokens).map(([key, tokenValue]) => (
27
+ <option key={key} value={tokenValue}>
28
+ {key}: {tokenValue}
29
+ </option>
30
+ ))}
31
+ </select>
32
+ );
33
+ }