@fragments-sdk/ui 0.2.3 → 0.4.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/fragments.json +1 -1
- package/package.json +9 -4
- package/src/components/Accordion/Accordion.fragment.tsx +186 -0
- package/src/components/Accordion/Accordion.module.scss +111 -0
- package/src/components/Accordion/index.tsx +271 -0
- package/src/components/Alert/Alert.fragment.tsx +66 -41
- package/src/components/Alert/Alert.module.scss +31 -21
- package/src/components/Alert/index.tsx +202 -73
- package/src/components/AppShell/AppShell.fragment.tsx +315 -0
- package/src/components/AppShell/AppShell.module.scss +213 -0
- package/src/components/AppShell/index.tsx +398 -0
- package/src/components/Avatar/index.tsx +8 -9
- package/src/components/Badge/Badge.module.scss +16 -10
- package/src/components/Badge/index.tsx +20 -6
- package/src/components/Box/Box.fragment.tsx +168 -0
- package/src/components/Box/Box.module.scss +84 -0
- package/src/components/Box/index.tsx +78 -0
- package/src/components/Button/Button.module.scss +42 -0
- package/src/components/Button/index.tsx +67 -33
- package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
- package/src/components/ButtonGroup/index.tsx +40 -0
- package/src/components/Card/Card.fragment.tsx +51 -25
- package/src/components/Card/Card.module.scss +52 -5
- package/src/components/Card/index.tsx +154 -53
- package/src/components/Checkbox/Checkbox.module.scss +4 -4
- package/src/components/Checkbox/index.tsx +3 -4
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +201 -0
- package/src/components/CodeBlock/CodeBlock.module.scss +224 -0
- package/src/components/CodeBlock/index.tsx +385 -0
- package/src/components/ColorChip/ColorChip.module.scss +165 -0
- package/src/components/ColorChip/index.tsx +157 -0
- package/src/components/ColorPicker/ColorPicker.module.scss +109 -0
- package/src/components/ColorPicker/index.tsx +107 -0
- package/src/components/Dialog/Dialog.fragment.tsx +9 -0
- package/src/components/Dialog/Dialog.module.scss +26 -7
- package/src/components/Dialog/index.tsx +12 -15
- package/src/components/EmptyState/EmptyState.fragment.tsx +54 -71
- package/src/components/EmptyState/EmptyState.module.scss +9 -9
- package/src/components/EmptyState/index.tsx +104 -69
- package/src/components/Field/Field.fragment.tsx +165 -0
- package/src/components/Field/Field.module.scss +31 -0
- package/src/components/Field/index.tsx +143 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
- package/src/components/Fieldset/Fieldset.module.scss +22 -0
- package/src/components/Fieldset/index.tsx +47 -0
- package/src/components/Form/Form.fragment.tsx +286 -0
- package/src/components/Form/Form.module.scss +8 -0
- package/src/components/Form/index.tsx +53 -0
- package/src/components/Grid/Grid.fragment.tsx +17 -17
- package/src/components/Grid/index.tsx +6 -1
- package/src/components/Header/Header.fragment.tsx +192 -0
- package/src/components/Header/Header.module.scss +209 -0
- package/src/components/Header/index.tsx +363 -0
- package/src/components/Icon/Icon.fragment.tsx +138 -0
- package/src/components/Icon/Icon.module.scss +38 -0
- package/src/components/Icon/index.tsx +58 -0
- package/src/components/Image/Image.fragment.tsx +195 -0
- package/src/components/Image/Image.module.scss +77 -0
- package/src/components/Image/index.tsx +95 -0
- package/src/components/Input/Input.module.scss +75 -2
- package/src/components/Input/index.tsx +60 -21
- package/src/components/Link/Link.fragment.tsx +132 -0
- package/src/components/Link/Link.module.scss +67 -0
- package/src/components/Link/index.tsx +57 -0
- package/src/components/List/List.fragment.tsx +152 -0
- package/src/components/List/List.module.scss +71 -0
- package/src/components/List/index.tsx +106 -0
- package/src/components/Listbox/Listbox.fragment.tsx +191 -0
- package/src/components/Listbox/Listbox.module.scss +97 -0
- package/src/components/Listbox/index.tsx +121 -0
- package/src/components/Menu/Menu.fragment.tsx +9 -0
- package/src/components/Menu/Menu.module.scss +17 -1
- package/src/components/Menu/index.tsx +3 -3
- package/src/components/Popover/Popover.fragment.tsx +9 -0
- package/src/components/Popover/Popover.module.scss +33 -10
- package/src/components/Popover/index.tsx +9 -11
- package/src/components/Progress/Progress.module.scss +11 -11
- package/src/components/Progress/index.tsx +34 -7
- package/src/components/Prompt/Prompt.fragment.tsx +231 -0
- package/src/components/Prompt/Prompt.module.scss +243 -0
- package/src/components/Prompt/index.tsx +439 -0
- package/src/components/RadioGroup/RadioGroup.module.scss +3 -3
- package/src/components/RadioGroup/index.tsx +3 -4
- package/src/components/Select/Select.fragment.tsx +9 -0
- package/src/components/Select/index.tsx +6 -7
- package/src/components/Separator/index.tsx +7 -3
- package/src/components/Sidebar/Sidebar.fragment.tsx +783 -0
- package/src/components/Sidebar/Sidebar.module.scss +586 -0
- package/src/components/Sidebar/index.tsx +1013 -0
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -5
- package/src/components/Skeleton/Skeleton.module.scss +11 -0
- package/src/components/Slider/Slider.module.scss +87 -0
- package/src/components/Slider/index.tsx +88 -0
- package/src/components/Stack/Stack.module.scss +120 -0
- package/src/components/Stack/index.tsx +148 -0
- package/src/components/Table/Table.fragment.tsx +7 -0
- package/src/components/Table/Table.module.scss +57 -0
- package/src/components/Table/index.tsx +44 -6
- package/src/components/Tabs/Tabs.fragment.tsx +9 -0
- package/src/components/Tabs/Tabs.module.scss +25 -10
- package/src/components/Tabs/index.tsx +11 -8
- package/src/components/Text/Text.module.scss +82 -0
- package/src/components/Text/index.tsx +58 -0
- package/src/components/Textarea/index.tsx +3 -7
- package/src/components/Theme/Theme.fragment.tsx +128 -0
- package/src/components/Theme/ThemeToggle.module.scss +82 -0
- package/src/components/Theme/index.tsx +343 -0
- package/src/components/Toast/Toast.fragment.tsx +5 -5
- package/src/components/Toast/Toast.module.scss +16 -1
- package/src/components/Toast/index.tsx +27 -11
- package/src/components/Toggle/Toggle.module.scss +25 -10
- package/src/components/Toggle/index.tsx +12 -0
- package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
- package/src/components/ToggleGroup/index.tsx +144 -0
- package/src/components/Tooltip/Tooltip.module.scss +4 -4
- package/src/components/Tooltip/index.tsx +4 -2
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
- package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
- package/src/components/VisuallyHidden/index.tsx +29 -0
- package/src/index.ts +241 -3
- package/src/recipes/AppShell.recipe.ts +175 -0
- package/src/recipes/CardGrid.recipe.ts +6 -2
- package/src/recipes/ChatInterface.recipe.ts +87 -0
- package/src/recipes/CodeExamples.recipe.ts +66 -0
- package/src/recipes/DashboardLayout.recipe.ts +46 -12
- package/src/recipes/DashboardNav.recipe.ts +183 -0
- package/src/recipes/LoginForm.recipe.ts +8 -1
- package/src/recipes/SettingsPage.recipe.ts +37 -20
- package/src/styles/globals.scss +31 -0
- package/src/tokens/_index.scss +3 -0
- package/src/tokens/_mixins.scss +54 -1
- package/src/tokens/_variables.scss +429 -64
- package/src/utils/a11y.tsx +439 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
5
|
+
import { codeToHtml } from 'shiki';
|
|
6
|
+
import { TabsRoot, TabsList, Tab, TabsPanel } from '../Tabs';
|
|
7
|
+
import styles from './CodeBlock.module.scss';
|
|
8
|
+
import '../../styles/globals.scss';
|
|
9
|
+
|
|
10
|
+
export type CodeBlockLanguage =
|
|
11
|
+
| 'tsx'
|
|
12
|
+
| 'typescript'
|
|
13
|
+
| 'javascript'
|
|
14
|
+
| 'bash'
|
|
15
|
+
| 'css'
|
|
16
|
+
| 'scss'
|
|
17
|
+
| 'json'
|
|
18
|
+
| 'html';
|
|
19
|
+
|
|
20
|
+
export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
21
|
+
/** Code string to display */
|
|
22
|
+
code: string;
|
|
23
|
+
/** Programming language for syntax highlighting */
|
|
24
|
+
language?: CodeBlockLanguage;
|
|
25
|
+
/** Show copy button */
|
|
26
|
+
showCopy?: boolean;
|
|
27
|
+
/** Optional title above code block (external label) */
|
|
28
|
+
title?: string;
|
|
29
|
+
/** Optional filename shown in header bar inside code block */
|
|
30
|
+
filename?: string;
|
|
31
|
+
/** Show line numbers */
|
|
32
|
+
showLineNumbers?: boolean;
|
|
33
|
+
/** Highlight specific lines (e.g., [1, 3, '5-7']) */
|
|
34
|
+
highlightLines?: (number | string)[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function CopyIcon({ className }: { className?: string }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg
|
|
40
|
+
className={className}
|
|
41
|
+
width="14"
|
|
42
|
+
height="14"
|
|
43
|
+
viewBox="0 0 24 24"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
strokeWidth="2"
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
strokeLinejoin="round"
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
>
|
|
51
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
52
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CheckIcon({ className }: { className?: string }) {
|
|
58
|
+
return (
|
|
59
|
+
<svg
|
|
60
|
+
className={className}
|
|
61
|
+
width="14"
|
|
62
|
+
height="14"
|
|
63
|
+
viewBox="0 0 24 24"
|
|
64
|
+
fill="none"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
strokeWidth="2"
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
strokeLinejoin="round"
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
>
|
|
71
|
+
<polyline points="20 6 9 17 4 12" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function escapeHtml(str: string): string {
|
|
77
|
+
return str
|
|
78
|
+
.replace(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'/g, ''');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove common leading whitespace from all lines (dedent).
|
|
87
|
+
* This handles template literals that have extra indentation from code formatting.
|
|
88
|
+
*/
|
|
89
|
+
function dedent(str: string): string {
|
|
90
|
+
const lines = str.split('\n');
|
|
91
|
+
|
|
92
|
+
// Find the minimum indentation (ignoring empty lines)
|
|
93
|
+
let minIndent = Infinity;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (line.trim() === '') continue;
|
|
96
|
+
const match = line.match(/^(\s*)/);
|
|
97
|
+
if (match) {
|
|
98
|
+
minIndent = Math.min(minIndent, match[1].length);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If no indentation found, return as-is
|
|
103
|
+
if (minIndent === Infinity || minIndent === 0) {
|
|
104
|
+
return str;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Remove the common indentation from all lines
|
|
108
|
+
return lines
|
|
109
|
+
.map(line => line.slice(minIndent))
|
|
110
|
+
.join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse highlight lines specification into a Set of line numbers.
|
|
115
|
+
* Supports: [1, 3, '5-7'] -> Set {1, 3, 5, 6, 7}
|
|
116
|
+
*/
|
|
117
|
+
function parseHighlightLines(
|
|
118
|
+
highlightLines?: (number | string)[]
|
|
119
|
+
): Set<number> {
|
|
120
|
+
const lines = new Set<number>();
|
|
121
|
+
if (!highlightLines) return lines;
|
|
122
|
+
|
|
123
|
+
for (const spec of highlightLines) {
|
|
124
|
+
if (typeof spec === 'number') {
|
|
125
|
+
lines.add(spec);
|
|
126
|
+
} else if (typeof spec === 'string') {
|
|
127
|
+
const rangeMatch = spec.match(/^(\d+)-(\d+)$/);
|
|
128
|
+
if (rangeMatch) {
|
|
129
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
130
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
131
|
+
for (let i = start; i <= end; i++) {
|
|
132
|
+
lines.add(i);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const num = parseInt(spec, 10);
|
|
136
|
+
if (!isNaN(num)) {
|
|
137
|
+
lines.add(num);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Add line numbers and highlight classes to Shiki HTML output.
|
|
148
|
+
*/
|
|
149
|
+
function processShikiHtml(
|
|
150
|
+
html: string,
|
|
151
|
+
showLineNumbers: boolean,
|
|
152
|
+
highlightLines: Set<number>
|
|
153
|
+
): string {
|
|
154
|
+
if (!showLineNumbers && highlightLines.size === 0) {
|
|
155
|
+
return html;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extract the code content from Shiki output
|
|
159
|
+
// Shiki outputs: <pre class="shiki ..."><code>...lines...</code></pre>
|
|
160
|
+
const codeMatch = html.match(/<code[^>]*>([\s\S]*?)<\/code>/);
|
|
161
|
+
if (!codeMatch) return html;
|
|
162
|
+
|
|
163
|
+
const codeContent = codeMatch[1];
|
|
164
|
+
const lines = codeContent.split('\n');
|
|
165
|
+
|
|
166
|
+
// Process each line
|
|
167
|
+
const processedLines = lines.map((line, index) => {
|
|
168
|
+
const lineNum = index + 1;
|
|
169
|
+
const isHighlighted = highlightLines.has(lineNum);
|
|
170
|
+
const lineClass = isHighlighted ? 'line highlighted' : 'line';
|
|
171
|
+
|
|
172
|
+
if (showLineNumbers) {
|
|
173
|
+
return `<span class="${lineClass}"><span class="line-number">${lineNum}</span>${line}</span>`;
|
|
174
|
+
}
|
|
175
|
+
return `<span class="${lineClass}">${line}</span>`;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Reconstruct the HTML
|
|
179
|
+
return html.replace(
|
|
180
|
+
/<code[^>]*>[\s\S]*?<\/code>/,
|
|
181
|
+
`<code>${processedLines.join('\n')}</code>`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
186
|
+
function CodeBlock(
|
|
187
|
+
{
|
|
188
|
+
code,
|
|
189
|
+
language = 'tsx',
|
|
190
|
+
showCopy = true,
|
|
191
|
+
title,
|
|
192
|
+
filename,
|
|
193
|
+
showLineNumbers = false,
|
|
194
|
+
highlightLines,
|
|
195
|
+
className,
|
|
196
|
+
...htmlProps
|
|
197
|
+
},
|
|
198
|
+
ref
|
|
199
|
+
) {
|
|
200
|
+
const [copied, setCopied] = useState(false);
|
|
201
|
+
const [highlightedHtml, setHighlightedHtml] = useState<string>('');
|
|
202
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
203
|
+
|
|
204
|
+
const trimmedCode = dedent(code.trim());
|
|
205
|
+
const highlightSet = parseHighlightLines(highlightLines);
|
|
206
|
+
|
|
207
|
+
// Apply syntax highlighting
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
let cancelled = false;
|
|
210
|
+
setIsLoading(true);
|
|
211
|
+
|
|
212
|
+
codeToHtml(trimmedCode, {
|
|
213
|
+
lang: language,
|
|
214
|
+
theme: 'one-dark-pro',
|
|
215
|
+
})
|
|
216
|
+
.then((html) => {
|
|
217
|
+
if (!cancelled) {
|
|
218
|
+
const processed = processShikiHtml(
|
|
219
|
+
html,
|
|
220
|
+
showLineNumbers,
|
|
221
|
+
highlightSet
|
|
222
|
+
);
|
|
223
|
+
setHighlightedHtml(processed);
|
|
224
|
+
setIsLoading(false);
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
.catch((err) => {
|
|
228
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
229
|
+
console.error('Syntax highlighting failed:', err);
|
|
230
|
+
}
|
|
231
|
+
if (!cancelled) {
|
|
232
|
+
// Fallback to plain text
|
|
233
|
+
setHighlightedHtml(
|
|
234
|
+
`<pre class="shiki"><code>${escapeHtml(trimmedCode)}</code></pre>`
|
|
235
|
+
);
|
|
236
|
+
setIsLoading(false);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return () => {
|
|
241
|
+
cancelled = true;
|
|
242
|
+
};
|
|
243
|
+
}, [trimmedCode, language, showLineNumbers, highlightSet.size]);
|
|
244
|
+
|
|
245
|
+
const handleCopy = useCallback(async () => {
|
|
246
|
+
try {
|
|
247
|
+
await navigator.clipboard.writeText(trimmedCode);
|
|
248
|
+
setCopied(true);
|
|
249
|
+
setTimeout(() => setCopied(false), 2000);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
252
|
+
console.error('Failed to copy:', err);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}, [trimmedCode]);
|
|
256
|
+
|
|
257
|
+
const classNames = [
|
|
258
|
+
styles.container,
|
|
259
|
+
showLineNumbers && styles.withLineNumbers,
|
|
260
|
+
className,
|
|
261
|
+
]
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.join(' ');
|
|
264
|
+
|
|
265
|
+
const wrapperClasses = [
|
|
266
|
+
styles.wrapper,
|
|
267
|
+
filename && styles.hasHeader,
|
|
268
|
+
].filter(Boolean).join(' ');
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div ref={ref} {...htmlProps} className={classNames}>
|
|
272
|
+
{title && <div className={styles.title}>{title}</div>}
|
|
273
|
+
<div className={wrapperClasses}>
|
|
274
|
+
{filename && (
|
|
275
|
+
<div className={styles.header}>
|
|
276
|
+
<span className={styles.filename}>{filename}</span>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
{showCopy && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={handleCopy}
|
|
283
|
+
className={`${styles.copyButton} ${copied ? styles.copied : ''}`}
|
|
284
|
+
aria-label={copied ? 'Copied!' : 'Copy code'}
|
|
285
|
+
>
|
|
286
|
+
{copied ? (
|
|
287
|
+
<>
|
|
288
|
+
<CheckIcon className={styles.icon} />
|
|
289
|
+
<span>Copied!</span>
|
|
290
|
+
</>
|
|
291
|
+
) : (
|
|
292
|
+
<>
|
|
293
|
+
<CopyIcon className={styles.icon} />
|
|
294
|
+
<span>Copy</span>
|
|
295
|
+
</>
|
|
296
|
+
)}
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
{isLoading ? (
|
|
300
|
+
<div className={styles.loading}>
|
|
301
|
+
<pre>
|
|
302
|
+
<code>{trimmedCode}</code>
|
|
303
|
+
</pre>
|
|
304
|
+
</div>
|
|
305
|
+
) : (
|
|
306
|
+
<div
|
|
307
|
+
className={styles.codeContainer}
|
|
308
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ============================================
|
|
318
|
+
// Tabbed Code Block
|
|
319
|
+
// ============================================
|
|
320
|
+
|
|
321
|
+
export interface CodeBlockTab {
|
|
322
|
+
/** Label shown in the tab */
|
|
323
|
+
label: string;
|
|
324
|
+
/** Code string to display */
|
|
325
|
+
code: string;
|
|
326
|
+
/** Programming language for syntax highlighting */
|
|
327
|
+
language?: CodeBlockLanguage;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface TabbedCodeBlockProps {
|
|
331
|
+
/** Array of code tabs */
|
|
332
|
+
tabs: CodeBlockTab[];
|
|
333
|
+
/** Default selected tab (by label) */
|
|
334
|
+
defaultTab?: string;
|
|
335
|
+
/** Show copy button */
|
|
336
|
+
showCopy?: boolean;
|
|
337
|
+
/** Show line numbers */
|
|
338
|
+
showLineNumbers?: boolean;
|
|
339
|
+
/** Additional class name */
|
|
340
|
+
className?: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function TabbedCodeBlock({
|
|
344
|
+
tabs,
|
|
345
|
+
defaultTab,
|
|
346
|
+
showCopy = true,
|
|
347
|
+
showLineNumbers = false,
|
|
348
|
+
className,
|
|
349
|
+
}: TabbedCodeBlockProps) {
|
|
350
|
+
const defaultValue = defaultTab || tabs[0]?.label || '';
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<div className={className}>
|
|
354
|
+
<TabsRoot defaultValue={defaultValue}>
|
|
355
|
+
<TabsList variant="pills">
|
|
356
|
+
{tabs.map((tab) => (
|
|
357
|
+
<Tab key={tab.label} value={tab.label}>
|
|
358
|
+
{tab.label}
|
|
359
|
+
</Tab>
|
|
360
|
+
))}
|
|
361
|
+
</TabsList>
|
|
362
|
+
{tabs.map((tab) => (
|
|
363
|
+
<TabsPanel key={tab.label} value={tab.label} flush className={styles.tabbedPanel}>
|
|
364
|
+
<CodeBlockBase
|
|
365
|
+
code={tab.code}
|
|
366
|
+
language={tab.language}
|
|
367
|
+
showCopy={showCopy}
|
|
368
|
+
showLineNumbers={showLineNumbers}
|
|
369
|
+
/>
|
|
370
|
+
</TabsPanel>
|
|
371
|
+
))}
|
|
372
|
+
</TabsRoot>
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================
|
|
378
|
+
// Export compound component
|
|
379
|
+
// ============================================
|
|
380
|
+
|
|
381
|
+
export const CodeBlock = Object.assign(CodeBlockBase, {
|
|
382
|
+
Tabbed: TabbedCodeBlock,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
export { TabbedCodeBlock };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
@use '../../tokens/variables' as *;
|
|
2
|
+
@use '../../tokens/mixins' as *;
|
|
3
|
+
|
|
4
|
+
.wrapper {
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--fui-space-2, $fui-space-2);
|
|
8
|
+
|
|
9
|
+
&.disabled {
|
|
10
|
+
opacity: 0.5;
|
|
11
|
+
pointer-events: none;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Size variants
|
|
16
|
+
.sizeSm {
|
|
17
|
+
gap: var(--fui-space-0-5, $fui-space-0-5);
|
|
18
|
+
|
|
19
|
+
.swatch {
|
|
20
|
+
width: $fui-touch-sm;
|
|
21
|
+
height: $fui-touch-sm;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.label {
|
|
25
|
+
font-size: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hexInput {
|
|
29
|
+
font-size: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
30
|
+
width: 60px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.lockButton {
|
|
34
|
+
width: $fui-icon-lg;
|
|
35
|
+
height: $fui-icon-lg;
|
|
36
|
+
|
|
37
|
+
svg {
|
|
38
|
+
width: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
39
|
+
height: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sizeMd {
|
|
45
|
+
.swatch {
|
|
46
|
+
width: $fui-touch-sm;
|
|
47
|
+
height: $fui-touch-sm;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.label {
|
|
51
|
+
font-size: var(--fui-font-size-sm, $fui-font-size-sm);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.hexInput {
|
|
55
|
+
font-size: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
56
|
+
width: 70px;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.swatch {
|
|
61
|
+
@include button-reset;
|
|
62
|
+
@include interactive-base;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
border: 1px solid var(--fui-border, $fui-border);
|
|
65
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
66
|
+
|
|
67
|
+
&:hover:not(:disabled) {
|
|
68
|
+
border-color: var(--fui-border-strong, $fui-border-strong);
|
|
69
|
+
transform: scale(1.05);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&:focus-visible {
|
|
73
|
+
@include focus-ring;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.label {
|
|
78
|
+
flex: 1;
|
|
79
|
+
color: var(--fui-text-primary, $fui-text-primary);
|
|
80
|
+
font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.hexInput {
|
|
85
|
+
@include interactive-base;
|
|
86
|
+
padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
|
|
87
|
+
background-color: transparent;
|
|
88
|
+
border: 1px solid transparent;
|
|
89
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
90
|
+
font-family: var(--fui-font-mono, $fui-font-mono);
|
|
91
|
+
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
92
|
+
text-transform: lowercase;
|
|
93
|
+
|
|
94
|
+
&:hover:not(:disabled):not(:focus) {
|
|
95
|
+
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&:focus {
|
|
99
|
+
@include focus-ring;
|
|
100
|
+
background-color: var(--fui-bg-elevated, $fui-bg-elevated);
|
|
101
|
+
border-color: var(--fui-border-strong, $fui-border-strong);
|
|
102
|
+
color: var(--fui-text-primary, $fui-text-primary);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.lockButton {
|
|
107
|
+
@include button-reset;
|
|
108
|
+
@include interactive-base;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
112
|
+
width: $fui-touch-sm;
|
|
113
|
+
height: $fui-touch-sm;
|
|
114
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
115
|
+
color: var(--fui-text-tertiary, $fui-text-tertiary);
|
|
116
|
+
|
|
117
|
+
&:hover:not(:disabled) {
|
|
118
|
+
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
119
|
+
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&:focus-visible {
|
|
123
|
+
@include focus-ring;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
&.locked {
|
|
127
|
+
color: var(--fui-color-accent, $fui-color-accent);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.positioner {
|
|
132
|
+
z-index: 100;
|
|
133
|
+
outline: none;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.popup {
|
|
137
|
+
background-color: var(--fui-bg-elevated, $fui-bg-elevated);
|
|
138
|
+
border: 1px solid var(--fui-border, $fui-border);
|
|
139
|
+
border-radius: var(--fui-radius-lg, $fui-radius-lg);
|
|
140
|
+
box-shadow: var(--fui-shadow-md, $fui-shadow-md);
|
|
141
|
+
padding: var(--fui-space-2, $fui-space-2);
|
|
142
|
+
|
|
143
|
+
// react-colorful styling overrides
|
|
144
|
+
:global(.react-colorful) {
|
|
145
|
+
width: $fui-colorpicker-size;
|
|
146
|
+
height: $fui-colorpicker-size;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
:global(.react-colorful__saturation) {
|
|
150
|
+
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
151
|
+
margin-bottom: var(--fui-space-2, $fui-space-2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
:global(.react-colorful__hue) {
|
|
155
|
+
height: $fui-colorpicker-hue-height;
|
|
156
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
:global(.react-colorful__pointer) {
|
|
160
|
+
width: $fui-colorpicker-pointer-size;
|
|
161
|
+
height: $fui-colorpicker-pointer-size;
|
|
162
|
+
border: $fui-colorpicker-pointer-border solid white;
|
|
163
|
+
box-shadow: var(--fui-shadow-sm, $fui-shadow-sm);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { HexColorPicker } from 'react-colorful';
|
|
5
|
+
import { Popover as BasePopover } from '@base-ui/react/popover';
|
|
6
|
+
import styles from './ColorChip.module.scss';
|
|
7
|
+
import '../../styles/globals.scss';
|
|
8
|
+
|
|
9
|
+
export interface ColorChipProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
10
|
+
/** Color value in hex format */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Callback when color changes */
|
|
13
|
+
onChange?: (color: string) => void;
|
|
14
|
+
/** Label for the color */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Show the hex value */
|
|
17
|
+
showHex?: boolean;
|
|
18
|
+
/** Size variant */
|
|
19
|
+
size?: 'sm' | 'md';
|
|
20
|
+
/** Whether the chip is locked (won't change on palette generation) */
|
|
21
|
+
locked?: boolean;
|
|
22
|
+
/** Callback when lock state changes */
|
|
23
|
+
onLockChange?: (locked: boolean) => void;
|
|
24
|
+
/** Disable editing */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ColorChip = React.forwardRef<HTMLDivElement, ColorChipProps>(
|
|
29
|
+
function ColorChip(
|
|
30
|
+
{
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
label,
|
|
34
|
+
showHex = true,
|
|
35
|
+
size = 'md',
|
|
36
|
+
locked = false,
|
|
37
|
+
onLockChange,
|
|
38
|
+
disabled = false,
|
|
39
|
+
className,
|
|
40
|
+
...htmlProps
|
|
41
|
+
},
|
|
42
|
+
ref
|
|
43
|
+
) {
|
|
44
|
+
const [inputValue, setInputValue] = React.useState(value);
|
|
45
|
+
const [isEditing, setIsEditing] = React.useState(false);
|
|
46
|
+
|
|
47
|
+
// Sync input when value changes externally
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
setInputValue(value);
|
|
50
|
+
}, [value]);
|
|
51
|
+
|
|
52
|
+
const handleColorChange = (color: string) => {
|
|
53
|
+
setInputValue(color);
|
|
54
|
+
onChange?.(color);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
58
|
+
const newValue = e.target.value;
|
|
59
|
+
setInputValue(newValue);
|
|
60
|
+
|
|
61
|
+
// Only update if valid hex
|
|
62
|
+
if (/^#[0-9A-Fa-f]{6}$/.test(newValue)) {
|
|
63
|
+
onChange?.(newValue);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleInputBlur = () => {
|
|
68
|
+
setIsEditing(false);
|
|
69
|
+
// Reset to valid value on blur if invalid
|
|
70
|
+
if (!/^#[0-9A-Fa-f]{6}$/.test(inputValue)) {
|
|
71
|
+
setInputValue(value);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
|
76
|
+
if (e.key === 'Enter') {
|
|
77
|
+
(e.target as HTMLInputElement).blur();
|
|
78
|
+
}
|
|
79
|
+
if (e.key === 'Escape') {
|
|
80
|
+
setInputValue(value);
|
|
81
|
+
(e.target as HTMLInputElement).blur();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleLockClick = (e: React.MouseEvent) => {
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
onLockChange?.(!locked);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const classes = [
|
|
91
|
+
styles.wrapper,
|
|
92
|
+
styles[`size${size.charAt(0).toUpperCase() + size.slice(1)}`],
|
|
93
|
+
disabled && styles.disabled,
|
|
94
|
+
className,
|
|
95
|
+
].filter(Boolean).join(' ');
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div ref={ref} {...htmlProps} className={classes}>
|
|
99
|
+
<BasePopover.Root>
|
|
100
|
+
<BasePopover.Trigger
|
|
101
|
+
className={styles.swatch}
|
|
102
|
+
style={{ backgroundColor: value }}
|
|
103
|
+
disabled={disabled}
|
|
104
|
+
aria-label={label ? `Edit ${label} color` : 'Edit color'}
|
|
105
|
+
/>
|
|
106
|
+
<BasePopover.Portal>
|
|
107
|
+
<BasePopover.Positioner side="bottom" align="start" sideOffset={4} className={styles.positioner}>
|
|
108
|
+
<BasePopover.Popup className={styles.popup}>
|
|
109
|
+
<HexColorPicker color={value} onChange={handleColorChange} />
|
|
110
|
+
</BasePopover.Popup>
|
|
111
|
+
</BasePopover.Positioner>
|
|
112
|
+
</BasePopover.Portal>
|
|
113
|
+
</BasePopover.Root>
|
|
114
|
+
|
|
115
|
+
{label && <span className={styles.label}>{label}</span>}
|
|
116
|
+
|
|
117
|
+
{showHex && (
|
|
118
|
+
<input
|
|
119
|
+
type="text"
|
|
120
|
+
value={inputValue}
|
|
121
|
+
onChange={handleInputChange}
|
|
122
|
+
onBlur={handleInputBlur}
|
|
123
|
+
onFocus={() => setIsEditing(true)}
|
|
124
|
+
onKeyDown={handleInputKeyDown}
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
className={styles.hexInput}
|
|
127
|
+
spellCheck={false}
|
|
128
|
+
aria-label={label ? `${label} hex value` : 'Hex value'}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{onLockChange && (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={handleLockClick}
|
|
136
|
+
className={[styles.lockButton, locked && styles.locked].filter(Boolean).join(' ')}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
aria-label={locked ? 'Unlock color' : 'Lock color'}
|
|
139
|
+
aria-pressed={locked}
|
|
140
|
+
>
|
|
141
|
+
{locked ? (
|
|
142
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
143
|
+
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
144
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
145
|
+
</svg>
|
|
146
|
+
) : (
|
|
147
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
148
|
+
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
149
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
150
|
+
</svg>
|
|
151
|
+
)}
|
|
152
|
+
</button>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
);
|