@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.
Files changed (60) hide show
  1. package/README.md +12 -2
  2. package/fragments.json +1 -1
  3. package/package.json +4 -2
  4. package/src/blocks/ChatInterface.block.ts +3 -3
  5. package/src/blocks/DashboardLayout.block.ts +1 -1
  6. package/src/blocks/DashboardPage.block.ts +4 -3
  7. package/src/blocks/EmptyState.block.ts +4 -4
  8. package/src/blocks/SettingsPanel.block.ts +4 -4
  9. package/src/components/Accordion/Accordion.fragment.tsx +67 -1
  10. package/src/components/Alert/Alert.fragment.tsx +69 -1
  11. package/src/components/Alert/Alert.module.scss +7 -3
  12. package/src/components/AppShell/AppShell.fragment.tsx +326 -87
  13. package/src/components/Avatar/Avatar.fragment.tsx +35 -2
  14. package/src/components/Badge/Badge.fragment.tsx +47 -9
  15. package/src/components/Badge/Badge.module.scss +1 -0
  16. package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
  17. package/src/components/Breadcrumbs/Breadcrumbs.module.scss +3 -0
  18. package/src/components/Button/Button.fragment.tsx +1 -1
  19. package/src/components/Checkbox/Checkbox.fragment.tsx +4 -4
  20. package/src/components/Checkbox/Checkbox.module.scss +7 -7
  21. package/src/components/Checkbox/index.tsx +6 -1
  22. package/src/components/Chip/Chip.fragment.tsx +1 -1
  23. package/src/components/CodeBlock/CodeBlock.fragment.tsx +10 -2
  24. package/src/components/CodeBlock/CodeBlock.module.scss +48 -11
  25. package/src/components/CodeBlock/CodeBlock.test.tsx +51 -1
  26. package/src/components/CodeBlock/index.tsx +221 -3
  27. package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
  28. package/src/components/ColorPicker/ColorPicker.module.scss +8 -7
  29. package/src/components/Combobox/Combobox.fragment.tsx +1 -1
  30. package/src/components/Combobox/Combobox.module.scss +1 -3
  31. package/src/components/Field/index.tsx +1 -1
  32. package/src/components/Form/Form.fragment.tsx +4 -4
  33. package/src/components/Input/Input.fragment.tsx +1 -1
  34. package/src/components/Message/Message.module.scss +3 -3
  35. package/src/components/Popover/Popover.fragment.tsx +1 -1
  36. package/src/components/Popover/Popover.module.scss +1 -3
  37. package/src/components/Prompt/Prompt.module.scss +6 -19
  38. package/src/components/Prompt/Prompt.test.tsx +8 -0
  39. package/src/components/Prompt/index.tsx +12 -1
  40. package/src/components/RadioGroup/RadioGroup.fragment.tsx +4 -3
  41. package/src/components/RadioGroup/RadioGroup.module.scss +9 -9
  42. package/src/components/RadioGroup/index.tsx +5 -1
  43. package/src/components/Sidebar/Sidebar.module.scss +9 -2
  44. package/src/components/Sidebar/Sidebar.test.tsx +6 -0
  45. package/src/components/Sidebar/index.tsx +4 -1
  46. package/src/components/Slider/Slider.fragment.tsx +2 -2
  47. package/src/components/Slider/Slider.module.scss +2 -0
  48. package/src/components/Switch/index.ts +1 -0
  49. package/src/components/Theme/Theme.fragment.tsx +16 -0
  50. package/src/components/Theme/ThemeToggle.module.scss +4 -3
  51. package/src/components/Toast/Toast.fragment.tsx +1 -0
  52. package/src/components/Toast/Toast.module.scss +9 -4
  53. package/src/components/Toggle/Toggle.fragment.tsx +32 -32
  54. package/src/components/Toggle/Toggle.module.scss +33 -26
  55. package/src/components/Toggle/Toggle.test.tsx +10 -10
  56. package/src/components/Toggle/index.tsx +23 -15
  57. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +2 -2
  58. package/src/index.ts +6 -1
  59. package/src/tokens/_mixins.scss +14 -0
  60. 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 = dedent(code.trim());
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
- {(filename || (showCopy && !persistentCopy)) && (
639
+ {shouldRenderHeader && (
436
640
  <div className={styles.header}>
437
641
  <span className={styles.filename}>{filename ?? ''}</span>
438
- {showCopy && !persistentCopy && (
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
- width: 40px;
49
- height: 36px;
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: 12px;
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: 16px;
110
- height: 16px;
111
- border: 2px solid white;
112
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
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: {
@@ -98,10 +98,8 @@
98
98
  // Chip remove button
99
99
  .chipRemove {
100
100
  @include button-reset;
101
+ @include touch-target;
101
102
 
102
- display: inline-flex;
103
- align-items: center;
104
- justify-content: center;
105
103
  flex-shrink: 0;
106
104
  width: 1rem;
107
105
  height: 1rem;
@@ -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, Toggle, Select, or any element.
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 { Toggle } from '../Toggle';
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, toggles, and radio group',
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
- <Toggle label="Email notifications" />
185
+ <Switch label="Email notifications" />
186
186
  </Field.Control>
187
187
  </Field>
188
188
  <Field name="marketingEmails">
189
189
  <Field.Control>
190
- <Toggle label="Marketing emails" />
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 Toggle)',
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-inverse, $fui-text-inverse);
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: rgba(0, 0, 0, 0.1);
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: rgba(0, 0, 0, 0.1);
158
+ background-color: var(--fui-bg-subtle, $fui-bg-subtle);
159
159
  overflow-x: auto;
160
160
 
161
161
  code {
@@ -87,7 +87,7 @@ export default defineFragment({
87
87
  'form.inline',
88
88
  'content.preview',
89
89
  ],
90
- a11yRules: ['A11Y_POPOVER_FOCUS', 'A11Y_POPOVER_ESCAPE'],
90
+ a11yRules: ['A11Y_POPOVER_FOCUS', 'A11Y_POPOVER_ESCAPE', 'A11Y_TARGET_SIZE_MIN'],
91
91
  },
92
92
 
93
93
  ai: {
@@ -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
- position: relative;
230
-
231
- svg {
232
- animation: pulse 1s ease-in-out infinite;
233
- }
226
+ pointer-events: none;
234
227
  }
235
228
 
236
- @keyframes pulse {
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
- {children ?? <ArrowUpIcon />}
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 Toggle/Switch)',
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: 'Toggle',
99
+ component: 'Switch',
100
100
  relationship: 'alternative',
101
- note: 'Use Toggle for binary on/off choices',
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: flex-start;
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: var(--fui-space-0-5, $fui-space-0-5);
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.5rem;
98
- height: 0.5rem;
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