@fragments-sdk/ui 0.8.2 → 0.8.3
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 +12 -2
- package/fragments.json +1 -1
- package/package.json +4 -2
- package/src/blocks/ChatInterface.block.ts +3 -3
- package/src/blocks/DashboardLayout.block.ts +1 -1
- package/src/blocks/DashboardPage.block.ts +4 -3
- package/src/blocks/EmptyState.block.ts +4 -4
- package/src/blocks/SettingsPanel.block.ts +4 -4
- package/src/components/Accordion/Accordion.fragment.tsx +67 -1
- package/src/components/Alert/Alert.fragment.tsx +69 -1
- package/src/components/Alert/Alert.module.scss +7 -3
- package/src/components/AppShell/AppShell.fragment.tsx +326 -87
- package/src/components/Avatar/Avatar.fragment.tsx +35 -2
- package/src/components/Badge/Badge.fragment.tsx +47 -9
- package/src/components/Badge/Badge.module.scss +1 -0
- package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
- package/src/components/Breadcrumbs/Breadcrumbs.module.scss +3 -0
- package/src/components/Button/Button.fragment.tsx +1 -1
- package/src/components/Checkbox/Checkbox.fragment.tsx +4 -4
- package/src/components/Checkbox/Checkbox.module.scss +7 -7
- package/src/components/Checkbox/index.tsx +6 -1
- package/src/components/Chip/Chip.fragment.tsx +1 -1
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +10 -2
- package/src/components/CodeBlock/CodeBlock.module.scss +48 -11
- package/src/components/CodeBlock/CodeBlock.test.tsx +51 -1
- package/src/components/CodeBlock/index.tsx +221 -3
- package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
- package/src/components/ColorPicker/ColorPicker.module.scss +8 -7
- package/src/components/Combobox/Combobox.fragment.tsx +1 -1
- package/src/components/Combobox/Combobox.module.scss +1 -3
- package/src/components/Field/index.tsx +1 -1
- package/src/components/Form/Form.fragment.tsx +4 -4
- package/src/components/Input/Input.fragment.tsx +1 -1
- package/src/components/Message/Message.module.scss +3 -3
- package/src/components/Popover/Popover.fragment.tsx +1 -1
- package/src/components/Popover/Popover.module.scss +1 -3
- package/src/components/Prompt/Prompt.module.scss +6 -19
- package/src/components/Prompt/Prompt.test.tsx +8 -0
- package/src/components/Prompt/index.tsx +12 -1
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +4 -3
- package/src/components/RadioGroup/RadioGroup.module.scss +9 -9
- package/src/components/RadioGroup/index.tsx +5 -1
- package/src/components/Sidebar/Sidebar.module.scss +9 -2
- package/src/components/Sidebar/Sidebar.test.tsx +6 -0
- package/src/components/Sidebar/index.tsx +4 -1
- package/src/components/Slider/Slider.fragment.tsx +2 -2
- package/src/components/Slider/Slider.module.scss +2 -0
- package/src/components/Switch/index.ts +1 -0
- package/src/components/Theme/Theme.fragment.tsx +16 -0
- package/src/components/Theme/ThemeToggle.module.scss +4 -3
- package/src/components/Toast/Toast.fragment.tsx +1 -0
- package/src/components/Toast/Toast.module.scss +9 -4
- package/src/components/Toggle/Toggle.fragment.tsx +32 -32
- package/src/components/Toggle/Toggle.module.scss +33 -26
- package/src/components/Toggle/Toggle.test.tsx +10 -10
- package/src/components/Toggle/index.tsx +23 -15
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +2 -2
- package/src/index.ts +6 -1
- package/src/tokens/_mixins.scss +14 -0
- package/src/tokens/_variables.scss +12 -6
|
@@ -56,6 +56,8 @@ export type CodeBlockTheme =
|
|
|
56
56
|
| 'min-dark'
|
|
57
57
|
| 'min-light';
|
|
58
58
|
|
|
59
|
+
export type CodeBlockCopyPlacement = 'auto' | 'header' | 'overlay';
|
|
60
|
+
|
|
59
61
|
export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
60
62
|
/** Code string to display */
|
|
61
63
|
code: string;
|
|
@@ -95,6 +97,8 @@ export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
95
97
|
compact?: boolean;
|
|
96
98
|
/** Show a persistent copy button (always visible, uses Button component) */
|
|
97
99
|
persistentCopy?: boolean;
|
|
100
|
+
/** Placement of copy button when not using persistent copy */
|
|
101
|
+
copyPlacement?: CodeBlockCopyPlacement;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
function CopyIcon({ className }: { className?: string }) {
|
|
@@ -211,6 +215,198 @@ function dedent(str: string): string {
|
|
|
211
215
|
.join('\n');
|
|
212
216
|
}
|
|
213
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Normalize indentation while handling JSX where first line is already at column 0.
|
|
220
|
+
*/
|
|
221
|
+
function normalizeIndentation(str: string): string {
|
|
222
|
+
const lines = str.split('\n');
|
|
223
|
+
if (lines.length <= 1) return str;
|
|
224
|
+
|
|
225
|
+
let minIndent = Infinity;
|
|
226
|
+
const firstLineIndent = lines[0].match(/^(\s*)/)?.[1].length ?? 0;
|
|
227
|
+
|
|
228
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
229
|
+
const line = lines[i];
|
|
230
|
+
if (line.trim().length === 0) continue;
|
|
231
|
+
const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
232
|
+
minIndent = Math.min(minIndent, indent);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (firstLineIndent > 0) {
|
|
236
|
+
minIndent = Math.min(minIndent, firstLineIndent);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (minIndent === Infinity || minIndent === 0) return str;
|
|
240
|
+
|
|
241
|
+
return lines
|
|
242
|
+
.map((line) => line.slice(Math.min(minIndent, line.match(/^(\s*)/)?.[1].length ?? 0)))
|
|
243
|
+
.join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function trimTrailingWhitespace(str: string): string {
|
|
247
|
+
return str
|
|
248
|
+
.split('\n')
|
|
249
|
+
.map((line) => line.replace(/[ \t]+$/g, ''))
|
|
250
|
+
.join('\n');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findTagEnd(line: string): number {
|
|
254
|
+
let quote: '"' | '\'' | '`' | null = null;
|
|
255
|
+
let escaped = false;
|
|
256
|
+
let braceDepth = 0;
|
|
257
|
+
let bracketDepth = 0;
|
|
258
|
+
let parenDepth = 0;
|
|
259
|
+
|
|
260
|
+
for (let i = 1; i < line.length; i += 1) {
|
|
261
|
+
const char = line[i];
|
|
262
|
+
|
|
263
|
+
if (quote) {
|
|
264
|
+
if (char === '\\' && !escaped) {
|
|
265
|
+
escaped = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (char === quote && !escaped) {
|
|
269
|
+
quote = null;
|
|
270
|
+
}
|
|
271
|
+
escaped = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
276
|
+
quote = char;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (char === '{') braceDepth += 1;
|
|
281
|
+
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
|
|
282
|
+
else if (char === '[') bracketDepth += 1;
|
|
283
|
+
else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
|
|
284
|
+
else if (char === '(') parenDepth += 1;
|
|
285
|
+
else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
|
|
286
|
+
else if (char === '>' && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
|
|
287
|
+
return i;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return -1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function splitJsxAttributes(attrs: string): string[] {
|
|
295
|
+
const parts: string[] = [];
|
|
296
|
+
let current = '';
|
|
297
|
+
let quote: '"' | '\'' | '`' | null = null;
|
|
298
|
+
let escaped = false;
|
|
299
|
+
let braceDepth = 0;
|
|
300
|
+
let bracketDepth = 0;
|
|
301
|
+
let parenDepth = 0;
|
|
302
|
+
|
|
303
|
+
for (const char of attrs) {
|
|
304
|
+
if (quote) {
|
|
305
|
+
current += char;
|
|
306
|
+
if (char === '\\' && !escaped) {
|
|
307
|
+
escaped = true;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (char === quote && !escaped) {
|
|
311
|
+
quote = null;
|
|
312
|
+
}
|
|
313
|
+
escaped = false;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
318
|
+
quote = char;
|
|
319
|
+
current += char;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (char === '{') braceDepth += 1;
|
|
324
|
+
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
|
|
325
|
+
else if (char === '[') bracketDepth += 1;
|
|
326
|
+
else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
|
|
327
|
+
else if (char === '(') parenDepth += 1;
|
|
328
|
+
else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
|
|
329
|
+
|
|
330
|
+
if (/\s/.test(char) && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
|
|
331
|
+
if (current.trim().length > 0) {
|
|
332
|
+
parts.push(current.trim());
|
|
333
|
+
current = '';
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
current += char;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (current.trim().length > 0) {
|
|
342
|
+
parts.push(current.trim());
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return parts;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function formatLongJsxTagLine(line: string): string {
|
|
349
|
+
const maxInlineLength = 110;
|
|
350
|
+
if (line.length <= maxInlineLength) return line;
|
|
351
|
+
|
|
352
|
+
const indent = line.match(/^(\s*)/)?.[1] ?? '';
|
|
353
|
+
const trimmed = line.trimStart();
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
!trimmed.startsWith('<')
|
|
357
|
+
|| trimmed.startsWith('</')
|
|
358
|
+
|| trimmed.startsWith('<!')
|
|
359
|
+
|| trimmed.startsWith('<?')
|
|
360
|
+
) {
|
|
361
|
+
return line;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const tagEnd = findTagEnd(trimmed);
|
|
365
|
+
if (tagEnd === -1) return line;
|
|
366
|
+
if (trimmed.slice(tagEnd + 1).trim().length > 0) return line;
|
|
367
|
+
|
|
368
|
+
const rawTagBody = trimmed.slice(1, tagEnd).trim();
|
|
369
|
+
const isSelfClosing = rawTagBody.endsWith('/');
|
|
370
|
+
const tagBody = isSelfClosing ? rawTagBody.slice(0, -1).trimEnd() : rawTagBody;
|
|
371
|
+
const firstSpace = tagBody.search(/\s/);
|
|
372
|
+
if (firstSpace === -1) return line;
|
|
373
|
+
|
|
374
|
+
const tagName = tagBody.slice(0, firstSpace);
|
|
375
|
+
if (!/^[A-Za-z][\w.:-]*$/.test(tagName)) return line;
|
|
376
|
+
|
|
377
|
+
const attrsSource = tagBody.slice(firstSpace).trim();
|
|
378
|
+
if (!attrsSource.includes('=') && !attrsSource.includes('{...')) return line;
|
|
379
|
+
|
|
380
|
+
const attrs = splitJsxAttributes(attrsSource);
|
|
381
|
+
if (attrs.length === 0) return line;
|
|
382
|
+
|
|
383
|
+
const attrIndent = `${indent} `;
|
|
384
|
+
const close = isSelfClosing ? '/>' : '>';
|
|
385
|
+
|
|
386
|
+
return [
|
|
387
|
+
`${indent}<${tagName}`,
|
|
388
|
+
...attrs.map((attr) => `${attrIndent}${attr}`),
|
|
389
|
+
`${indent}${close}`,
|
|
390
|
+
].join('\n');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function formatLongJsxTags(code: string): string {
|
|
394
|
+
return code
|
|
395
|
+
.split('\n')
|
|
396
|
+
.flatMap((line) => formatLongJsxTagLine(line).split('\n'))
|
|
397
|
+
.join('\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function normalizeCode(code: string): string {
|
|
401
|
+
const trimmed = code.trim();
|
|
402
|
+
if (trimmed.length === 0) return '';
|
|
403
|
+
|
|
404
|
+
const normalized = normalizeIndentation(trimmed);
|
|
405
|
+
const dedented = dedent(normalized);
|
|
406
|
+
const withoutTrailingWhitespace = trimTrailingWhitespace(dedented);
|
|
407
|
+
return formatLongJsxTags(withoutTrailingWhitespace);
|
|
408
|
+
}
|
|
409
|
+
|
|
214
410
|
/**
|
|
215
411
|
* Parse line specification into a Set of line numbers.
|
|
216
412
|
* Supports: [1, 3, '5-7'] -> Set {1, 3, 5, 6, 7}
|
|
@@ -326,6 +522,7 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
326
522
|
collapsedLines = 5,
|
|
327
523
|
compact = false,
|
|
328
524
|
persistentCopy = false,
|
|
525
|
+
copyPlacement = 'auto',
|
|
329
526
|
className,
|
|
330
527
|
...htmlProps
|
|
331
528
|
},
|
|
@@ -336,7 +533,7 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
336
533
|
const [isLoading, setIsLoading] = useState(true);
|
|
337
534
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
|
338
535
|
|
|
339
|
-
const trimmedCode =
|
|
536
|
+
const trimmedCode = useMemo(() => normalizeCode(code), [code]);
|
|
340
537
|
const codeLines = trimmedCode.split('\n');
|
|
341
538
|
const totalLines = codeLines.length;
|
|
342
539
|
const shouldShowCollapse = collapsible && totalLines > collapsedLines;
|
|
@@ -350,6 +547,12 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
350
547
|
const addedSet = useMemo(() => parseLineSpec(addedLines), [addedLines]);
|
|
351
548
|
const removedSet = useMemo(() => parseLineSpec(removedLines), [removedLines]);
|
|
352
549
|
const hasDiff = addedSet.size > 0 || removedSet.size > 0;
|
|
550
|
+
const resolvedCopyPlacement = copyPlacement === 'auto'
|
|
551
|
+
? (filename ? 'header' : 'overlay')
|
|
552
|
+
: copyPlacement;
|
|
553
|
+
const shouldShowHeaderCopy = showCopy && !persistentCopy && resolvedCopyPlacement === 'header';
|
|
554
|
+
const shouldShowOverlayCopy = showCopy && !persistentCopy && resolvedCopyPlacement === 'overlay';
|
|
555
|
+
const shouldRenderHeader = Boolean(filename) || shouldShowHeaderCopy;
|
|
353
556
|
|
|
354
557
|
// Apply syntax highlighting
|
|
355
558
|
useEffect(() => {
|
|
@@ -422,6 +625,7 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
422
625
|
const wrapperClasses = [
|
|
423
626
|
styles.wrapper,
|
|
424
627
|
persistentCopy && styles.persistentCopyWrapper,
|
|
628
|
+
shouldShowOverlayCopy && styles.withCopyOverlay,
|
|
425
629
|
].filter(Boolean).join(' ');
|
|
426
630
|
|
|
427
631
|
const codeContainerStyle: React.CSSProperties = maxHeight
|
|
@@ -432,10 +636,10 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
432
636
|
<div ref={ref} {...htmlProps} className={classNames}>
|
|
433
637
|
{title && <div className={styles.title}>{title}</div>}
|
|
434
638
|
<div className={wrapperClasses}>
|
|
435
|
-
{
|
|
639
|
+
{shouldRenderHeader && (
|
|
436
640
|
<div className={styles.header}>
|
|
437
641
|
<span className={styles.filename}>{filename ?? ''}</span>
|
|
438
|
-
{
|
|
642
|
+
{shouldShowHeaderCopy && (
|
|
439
643
|
<button
|
|
440
644
|
type="button"
|
|
441
645
|
onClick={handleCopy}
|
|
@@ -447,6 +651,16 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
|
447
651
|
)}
|
|
448
652
|
</div>
|
|
449
653
|
)}
|
|
654
|
+
{shouldShowOverlayCopy && (
|
|
655
|
+
<button
|
|
656
|
+
type="button"
|
|
657
|
+
onClick={handleCopy}
|
|
658
|
+
className={`${styles.copyButton} ${styles.copyOverlay} ${copied ? styles.copied : ''}`}
|
|
659
|
+
aria-label={copied ? 'Copied!' : 'Copy code'}
|
|
660
|
+
>
|
|
661
|
+
{copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
|
|
662
|
+
</button>
|
|
663
|
+
)}
|
|
450
664
|
{isLoading ? (
|
|
451
665
|
<div className={styles.loading} style={codeContainerStyle}>
|
|
452
666
|
<pre>
|
|
@@ -520,6 +734,8 @@ export interface TabbedCodeBlockProps {
|
|
|
520
734
|
defaultTab?: string;
|
|
521
735
|
/** Show copy button */
|
|
522
736
|
showCopy?: boolean;
|
|
737
|
+
/** Placement of copy button when not using persistent copy */
|
|
738
|
+
copyPlacement?: CodeBlockCopyPlacement;
|
|
523
739
|
/** Show line numbers */
|
|
524
740
|
showLineNumbers?: boolean;
|
|
525
741
|
/** Syntax highlighting theme (applies to all tabs) */
|
|
@@ -538,6 +754,7 @@ function TabbedCodeBlock({
|
|
|
538
754
|
tabs,
|
|
539
755
|
defaultTab,
|
|
540
756
|
showCopy = true,
|
|
757
|
+
copyPlacement = 'auto',
|
|
541
758
|
showLineNumbers = false,
|
|
542
759
|
theme,
|
|
543
760
|
tabsVariant = 'pills',
|
|
@@ -564,6 +781,7 @@ function TabbedCodeBlock({
|
|
|
564
781
|
language={tab.language}
|
|
565
782
|
theme={theme}
|
|
566
783
|
showCopy={showCopy}
|
|
784
|
+
copyPlacement={copyPlacement}
|
|
567
785
|
showLineNumbers={showLineNumbers}
|
|
568
786
|
wordWrap={wordWrap}
|
|
569
787
|
maxHeight={maxHeight}
|
|
@@ -102,7 +102,7 @@ export default defineFragment({
|
|
|
102
102
|
'input.specialized',
|
|
103
103
|
'theme.customization',
|
|
104
104
|
],
|
|
105
|
-
a11yRules: ['A11Y_LABEL_REQUIRED', 'A11Y_FOCUS_VISIBLE'],
|
|
105
|
+
a11yRules: ['A11Y_LABEL_REQUIRED', 'A11Y_FOCUS_VISIBLE', 'A11Y_TARGET_SIZE_MIN'],
|
|
106
106
|
},
|
|
107
107
|
|
|
108
108
|
variants: [
|
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
.swatch {
|
|
46
46
|
@include button-reset;
|
|
47
47
|
@include interactive-base;
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
@include min-target-size;
|
|
49
|
+
width: var(--fui-target-size-comfortable, $fui-target-size-comfortable);
|
|
50
|
+
height: var(--fui-input-height, $fui-input-height);
|
|
50
51
|
border: 1px solid var(--fui-border, $fui-border);
|
|
51
52
|
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
52
53
|
flex-shrink: 0;
|
|
@@ -101,15 +102,15 @@
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
:global(.react-colorful__hue) {
|
|
104
|
-
height:
|
|
105
|
+
height: var(--fui-colorpicker-hue-height, #{$fui-colorpicker-hue-height});
|
|
105
106
|
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
:global(.react-colorful__pointer) {
|
|
109
|
-
width:
|
|
110
|
-
height:
|
|
111
|
-
border:
|
|
112
|
-
box-shadow:
|
|
110
|
+
width: var(--fui-colorpicker-pointer-size, #{$fui-colorpicker-pointer-size});
|
|
111
|
+
height: var(--fui-colorpicker-pointer-size, #{$fui-colorpicker-pointer-size});
|
|
112
|
+
border: $fui-colorpicker-pointer-border solid var(--fui-text-inverse, $fui-text-inverse);
|
|
113
|
+
box-shadow: var(--fui-shadow-md, $fui-shadow-md);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -132,7 +132,7 @@ export default defineFragment({
|
|
|
132
132
|
'form.multiselect',
|
|
133
133
|
'input.search',
|
|
134
134
|
],
|
|
135
|
-
a11yRules: ['A11Y_COMBOBOX_KEYBOARD', 'A11Y_COMBOBOX_LABEL'],
|
|
135
|
+
a11yRules: ['A11Y_COMBOBOX_KEYBOARD', 'A11Y_COMBOBOX_LABEL', 'A11Y_TARGET_SIZE_MIN'],
|
|
136
136
|
},
|
|
137
137
|
|
|
138
138
|
ai: {
|
|
@@ -87,7 +87,7 @@ function FieldLabel({ children, className }: FieldLabelProps) {
|
|
|
87
87
|
* Wraps the child with Base UI's Field.Control via the `render` prop,
|
|
88
88
|
* which merges aria attributes and field state onto the child element.
|
|
89
89
|
*
|
|
90
|
-
* Works with Input, Textarea, Checkbox,
|
|
90
|
+
* Works with Input, Textarea, Checkbox, Switch, Select, or any element.
|
|
91
91
|
*/
|
|
92
92
|
function FieldControl({ children, className }: FieldControlProps) {
|
|
93
93
|
const classes = [styles.control, className].filter(Boolean).join(' ');
|
|
@@ -8,7 +8,7 @@ import { Textarea } from '../Textarea';
|
|
|
8
8
|
import { Select } from '../Select';
|
|
9
9
|
import { Checkbox } from '../Checkbox';
|
|
10
10
|
import { RadioGroup } from '../RadioGroup';
|
|
11
|
-
import {
|
|
11
|
+
import { Switch } from '../Switch';
|
|
12
12
|
import { Button } from '../Button';
|
|
13
13
|
import { Grid } from '../Grid';
|
|
14
14
|
|
|
@@ -141,7 +141,7 @@ export default defineFragment({
|
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
name: 'Profile settings',
|
|
144
|
-
description: 'Multi-section form with Fieldsets,
|
|
144
|
+
description: 'Multi-section form with Fieldsets, switches, and radio group',
|
|
145
145
|
render: () => (
|
|
146
146
|
<Form onFormSubmit={(e) => { e.preventDefault(); }}>
|
|
147
147
|
<Fieldset>
|
|
@@ -182,12 +182,12 @@ export default defineFragment({
|
|
|
182
182
|
<Fieldset.Legend>Notifications</Fieldset.Legend>
|
|
183
183
|
<Field name="emailNotifs">
|
|
184
184
|
<Field.Control>
|
|
185
|
-
<
|
|
185
|
+
<Switch label="Email notifications" />
|
|
186
186
|
</Field.Control>
|
|
187
187
|
</Field>
|
|
188
188
|
<Field name="marketingEmails">
|
|
189
189
|
<Field.Control>
|
|
190
|
-
<
|
|
190
|
+
<Switch label="Marketing emails" />
|
|
191
191
|
</Field.Control>
|
|
192
192
|
</Field>
|
|
193
193
|
<Field name="frequency">
|
|
@@ -23,7 +23,7 @@ export default defineFragment({
|
|
|
23
23
|
whenNot: [
|
|
24
24
|
'Multi-line text (use Textarea)',
|
|
25
25
|
'Selecting from predefined options (use Select)',
|
|
26
|
-
'Boolean input (use Checkbox or
|
|
26
|
+
'Boolean input (use Checkbox or Switch)',
|
|
27
27
|
'Date/time input (use DatePicker)',
|
|
28
28
|
],
|
|
29
29
|
guidelines: [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
.content {
|
|
38
38
|
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
39
|
-
color: var(--fui-text
|
|
39
|
+
color: var(--fui-color-danger-text, $fui-color-danger-text);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -148,14 +148,14 @@
|
|
|
148
148
|
font-size: 0.9em;
|
|
149
149
|
padding: 0.125em 0.375em;
|
|
150
150
|
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
151
|
-
background-color:
|
|
151
|
+
background-color: var(--fui-bg-subtle, $fui-bg-subtle);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
pre {
|
|
155
155
|
margin: var(--fui-space-2, $fui-space-2) 0;
|
|
156
156
|
padding: var(--fui-padding-inline-sm);
|
|
157
157
|
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
158
|
-
background-color:
|
|
158
|
+
background-color: var(--fui-bg-subtle, $fui-bg-subtle);
|
|
159
159
|
overflow-x: auto;
|
|
160
160
|
|
|
161
161
|
code {
|
|
@@ -96,13 +96,11 @@
|
|
|
96
96
|
.close {
|
|
97
97
|
@include button-reset;
|
|
98
98
|
@include interactive-base;
|
|
99
|
+
@include touch-target;
|
|
99
100
|
|
|
100
101
|
position: absolute;
|
|
101
102
|
top: var(--fui-space-2, $fui-space-2);
|
|
102
103
|
right: var(--fui-space-2, $fui-space-2);
|
|
103
|
-
display: flex;
|
|
104
|
-
align-items: center;
|
|
105
|
-
justify-content: center;
|
|
106
104
|
width: 1.5rem;
|
|
107
105
|
height: 1.5rem;
|
|
108
106
|
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
width: calc(100% - var(--fui-space-8, $fui-space-8));
|
|
28
28
|
max-width: 800px;
|
|
29
29
|
z-index: 100;
|
|
30
|
-
box-shadow:
|
|
31
|
-
0 -4px 16px rgba(0, 0, 0, 0.08),
|
|
32
|
-
0 4px 24px rgba(0, 0, 0, 0.12);
|
|
30
|
+
box-shadow: var(--fui-shadow-lg, $fui-shadow-lg);
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
// Sticky variant - for content-area fixed prompts (accounts for sidebar)
|
|
@@ -44,9 +42,7 @@
|
|
|
44
42
|
width: calc(100% - var(--fui-prompt-inset-left, 0px) - var(--fui-space-8, $fui-space-8));
|
|
45
43
|
max-width: 800px;
|
|
46
44
|
z-index: 100;
|
|
47
|
-
box-shadow:
|
|
48
|
-
0 -4px 16px rgba(0, 0, 0, 0.08),
|
|
49
|
-
0 4px 24px rgba(0, 0, 0, 0.12);
|
|
45
|
+
box-shadow: var(--fui-shadow-lg, $fui-shadow-lg);
|
|
50
46
|
|
|
51
47
|
// Center within the available space (after sidebar)
|
|
52
48
|
left: calc(var(--fui-prompt-inset-left, 0px) + ((100% - var(--fui-prompt-inset-left, 0px)) / 2));
|
|
@@ -113,6 +109,7 @@
|
|
|
113
109
|
display: flex;
|
|
114
110
|
align-items: center;
|
|
115
111
|
gap: var(--fui-space-2, $fui-space-2);
|
|
112
|
+
margin-left: auto;
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
// ============================================
|
|
@@ -226,19 +223,9 @@
|
|
|
226
223
|
}
|
|
227
224
|
|
|
228
225
|
.submitLoading {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
svg {
|
|
232
|
-
animation: pulse 1s ease-in-out infinite;
|
|
233
|
-
}
|
|
226
|
+
pointer-events: none;
|
|
234
227
|
}
|
|
235
228
|
|
|
236
|
-
|
|
237
|
-
0
|
|
238
|
-
100% {
|
|
239
|
-
opacity: 1;
|
|
240
|
-
}
|
|
241
|
-
50% {
|
|
242
|
-
opacity: 0.5;
|
|
243
|
-
}
|
|
229
|
+
.submitSpinner {
|
|
230
|
+
line-height: 0;
|
|
244
231
|
}
|
|
@@ -7,6 +7,7 @@ function renderPrompt(props: {
|
|
|
7
7
|
placeholder?: string;
|
|
8
8
|
disabled?: boolean;
|
|
9
9
|
defaultValue?: string;
|
|
10
|
+
loading?: boolean;
|
|
10
11
|
} = {}) {
|
|
11
12
|
return render(
|
|
12
13
|
<Prompt
|
|
@@ -14,6 +15,7 @@ function renderPrompt(props: {
|
|
|
14
15
|
onSubmit={props.onSubmit}
|
|
15
16
|
disabled={props.disabled}
|
|
16
17
|
defaultValue={props.defaultValue}
|
|
18
|
+
loading={props.loading}
|
|
17
19
|
>
|
|
18
20
|
<Prompt.Textarea />
|
|
19
21
|
<Prompt.Toolbar>
|
|
@@ -82,6 +84,12 @@ describe('Prompt', () => {
|
|
|
82
84
|
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
it('shows loading spinner icon in submit button when loading', () => {
|
|
88
|
+
renderPrompt({ loading: true, defaultValue: 'Submitting...' });
|
|
89
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
90
|
+
expect(screen.getByRole('status', { name: /submitting/i })).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
85
93
|
it('has no accessibility violations', async () => {
|
|
86
94
|
const { container } = renderPrompt();
|
|
87
95
|
await expectNoA11yViolations(container);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import styles from './Prompt.module.scss';
|
|
5
5
|
import '../../styles/globals.scss';
|
|
6
|
+
import { Loading } from '../Loading';
|
|
6
7
|
|
|
7
8
|
// ============================================
|
|
8
9
|
// Types
|
|
@@ -404,7 +405,17 @@ function PromptSubmit({
|
|
|
404
405
|
disabled={isDisabled}
|
|
405
406
|
aria-label={ariaLabel}
|
|
406
407
|
>
|
|
407
|
-
{
|
|
408
|
+
{loading ? (
|
|
409
|
+
<Loading
|
|
410
|
+
size="sm"
|
|
411
|
+
variant="spinner"
|
|
412
|
+
color="current"
|
|
413
|
+
label="Submitting"
|
|
414
|
+
className={styles.submitSpinner}
|
|
415
|
+
/>
|
|
416
|
+
) : (
|
|
417
|
+
children ?? <ArrowUpIcon />
|
|
418
|
+
)}
|
|
408
419
|
</button>
|
|
409
420
|
);
|
|
410
421
|
}
|
|
@@ -23,7 +23,7 @@ export default defineFragment({
|
|
|
23
23
|
whenNot: [
|
|
24
24
|
'Multiple selections allowed (use Checkbox group)',
|
|
25
25
|
'Many options (use Select)',
|
|
26
|
-
'Binary on/off choice (use
|
|
26
|
+
'Binary on/off choice (use Switch)',
|
|
27
27
|
'Options need to be searchable (use Combobox)',
|
|
28
28
|
],
|
|
29
29
|
guidelines: [
|
|
@@ -96,9 +96,9 @@ export default defineFragment({
|
|
|
96
96
|
note: 'Use Select for many options or limited space',
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
|
-
component: '
|
|
99
|
+
component: 'Switch',
|
|
100
100
|
relationship: 'alternative',
|
|
101
|
-
note: 'Use
|
|
101
|
+
note: 'Use Switch for binary on/off choices',
|
|
102
102
|
},
|
|
103
103
|
],
|
|
104
104
|
|
|
@@ -118,6 +118,7 @@ export default defineFragment({
|
|
|
118
118
|
a11yRules: [
|
|
119
119
|
'A11Y_RADIO_GROUP',
|
|
120
120
|
'A11Y_LABEL_REQUIRED',
|
|
121
|
+
'A11Y_TARGET_SIZE_MIN',
|
|
121
122
|
],
|
|
122
123
|
bans: [],
|
|
123
124
|
},
|
|
@@ -30,11 +30,15 @@
|
|
|
30
30
|
// Individual item wrapper
|
|
31
31
|
.itemWrapper {
|
|
32
32
|
display: inline-flex;
|
|
33
|
-
align-items:
|
|
33
|
+
align-items: center;
|
|
34
34
|
gap: var(--fui-space-2, $fui-space-2);
|
|
35
35
|
cursor: pointer;
|
|
36
36
|
font-family: var(--fui-font-sans, $fui-font-sans);
|
|
37
37
|
|
|
38
|
+
&[data-has-description] {
|
|
39
|
+
align-items: flex-start;
|
|
40
|
+
}
|
|
41
|
+
|
|
38
42
|
&[data-disabled] {
|
|
39
43
|
cursor: not-allowed;
|
|
40
44
|
opacity: 0.5;
|
|
@@ -44,15 +48,13 @@
|
|
|
44
48
|
// The radio circle
|
|
45
49
|
.radio {
|
|
46
50
|
@include interactive-base;
|
|
51
|
+
@include touch-target;
|
|
47
52
|
|
|
48
53
|
position: relative;
|
|
49
|
-
display: inline-flex;
|
|
50
|
-
align-items: center;
|
|
51
|
-
justify-content: center;
|
|
52
54
|
flex-shrink: 0;
|
|
53
55
|
width: 1rem;
|
|
54
56
|
height: 1rem;
|
|
55
|
-
margin-top:
|
|
57
|
+
margin-top: 0;
|
|
56
58
|
background-color: var(--fui-bg-elevated, $fui-bg-elevated);
|
|
57
59
|
border: 1px solid var(--fui-border-strong, $fui-border-strong);
|
|
58
60
|
border-radius: var(--fui-radius-full, $fui-radius-full);
|
|
@@ -80,13 +82,11 @@
|
|
|
80
82
|
.radioSm {
|
|
81
83
|
width: 0.875rem;
|
|
82
84
|
height: 0.875rem;
|
|
83
|
-
margin-top: var(--fui-space-0-75, $fui-space-0-75);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
.radioLg {
|
|
87
88
|
width: 1.25rem;
|
|
88
89
|
height: 1.25rem;
|
|
89
|
-
margin-top: 0;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// The indicator dot - use absolute positioning for perfect centering
|
|
@@ -94,8 +94,8 @@
|
|
|
94
94
|
position: absolute;
|
|
95
95
|
top: 50%;
|
|
96
96
|
left: 50%;
|
|
97
|
-
width: 0.
|
|
98
|
-
height: 0.
|
|
97
|
+
width: 0.75rem;
|
|
98
|
+
height: 0.75rem;
|
|
99
99
|
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
100
100
|
border-radius: var(--fui-radius-full, $fui-radius-full);
|
|
101
101
|
|