@usefragments/ui 1.3.2 → 1.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.
Files changed (145) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/ui.css +1261 -849
  3. package/dist/chart.cjs +60 -24
  4. package/dist/chart.js +38 -24
  5. package/dist/codeblock.cjs +14 -6
  6. package/dist/codeblock.js +14 -6
  7. package/dist/colorpicker.cjs +56 -9
  8. package/dist/colorpicker.js +34 -9
  9. package/dist/components/Badge/Badge.module.scss.cjs +23 -16
  10. package/dist/components/Badge/Badge.module.scss.js +23 -16
  11. package/dist/components/Badge/index.cjs +8 -1
  12. package/dist/components/Badge/index.d.ts +3 -0
  13. package/dist/components/Badge/index.d.ts.map +1 -1
  14. package/dist/components/Badge/index.js +8 -1
  15. package/dist/components/Button/Button.module.scss.cjs +16 -13
  16. package/dist/components/Button/Button.module.scss.js +16 -13
  17. package/dist/components/Button/index.d.ts +2 -1
  18. package/dist/components/Button/index.d.ts.map +1 -1
  19. package/dist/components/Chart/index.d.ts +3 -0
  20. package/dist/components/Chart/index.d.ts.map +1 -1
  21. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs +21 -21
  22. package/dist/components/CodeBlock/CodeBlock.module.scss.js +21 -21
  23. package/dist/components/CodeBlock/index.d.ts.map +1 -1
  24. package/dist/components/ColorPicker/index.d.ts +4 -0
  25. package/dist/components/ColorPicker/index.d.ts.map +1 -1
  26. package/dist/components/Combobox/Combobox.module.scss.cjs +23 -23
  27. package/dist/components/Combobox/Combobox.module.scss.js +23 -23
  28. package/dist/components/Command/Command.module.scss.cjs +11 -11
  29. package/dist/components/Command/Command.module.scss.js +11 -11
  30. package/dist/components/ConversationList/ConversationList.module.scss.cjs +13 -10
  31. package/dist/components/ConversationList/ConversationList.module.scss.js +14 -11
  32. package/dist/components/ConversationList/index.cjs +26 -22
  33. package/dist/components/ConversationList/index.d.ts +5 -1
  34. package/dist/components/ConversationList/index.d.ts.map +1 -1
  35. package/dist/components/ConversationList/index.js +27 -23
  36. package/dist/components/DataTable/DataTable.module.scss.cjs +35 -35
  37. package/dist/components/DataTable/DataTable.module.scss.js +35 -35
  38. package/dist/components/DataTable/index.d.ts +4 -1
  39. package/dist/components/DataTable/index.d.ts.map +1 -1
  40. package/dist/components/DatePicker/index.d.ts +3 -0
  41. package/dist/components/DatePicker/index.d.ts.map +1 -1
  42. package/dist/components/Editor/index.d.ts +4 -1
  43. package/dist/components/Editor/index.d.ts.map +1 -1
  44. package/dist/components/Listbox/Listbox.module.scss.cjs +8 -8
  45. package/dist/components/Listbox/Listbox.module.scss.js +8 -8
  46. package/dist/components/Markdown/Markdown.module.scss.cjs +1 -1
  47. package/dist/components/Markdown/Markdown.module.scss.js +1 -1
  48. package/dist/components/Markdown/index.d.ts +16 -0
  49. package/dist/components/Markdown/index.d.ts.map +1 -1
  50. package/dist/components/Menu/Menu.module.scss.cjs +14 -14
  51. package/dist/components/Menu/Menu.module.scss.js +14 -14
  52. package/dist/components/Message/Message.module.scss.cjs +23 -20
  53. package/dist/components/Message/Message.module.scss.js +24 -21
  54. package/dist/components/Message/index.cjs +5 -1
  55. package/dist/components/Message/index.d.ts.map +1 -1
  56. package/dist/components/Message/index.js +5 -1
  57. package/dist/components/NavigationMenu/NavigationMenu.module.scss.cjs +28 -28
  58. package/dist/components/NavigationMenu/NavigationMenu.module.scss.js +28 -28
  59. package/dist/components/Pagination/Pagination.module.scss.cjs +7 -7
  60. package/dist/components/Pagination/Pagination.module.scss.js +7 -7
  61. package/dist/components/Prompt/Prompt.module.scss.cjs +52 -22
  62. package/dist/components/Prompt/Prompt.module.scss.js +52 -22
  63. package/dist/components/Prompt/index.cjs +197 -2
  64. package/dist/components/Prompt/index.d.ts +98 -3
  65. package/dist/components/Prompt/index.d.ts.map +1 -1
  66. package/dist/components/Prompt/index.js +198 -3
  67. package/dist/components/Select/Select.module.scss.cjs +29 -17
  68. package/dist/components/Select/Select.module.scss.js +30 -18
  69. package/dist/components/Select/index.cjs +45 -8
  70. package/dist/components/Select/index.d.ts +17 -2
  71. package/dist/components/Select/index.d.ts.map +1 -1
  72. package/dist/components/Select/index.js +45 -8
  73. package/dist/components/Sidebar/Sidebar.module.scss.cjs +45 -42
  74. package/dist/components/Sidebar/Sidebar.module.scss.js +45 -42
  75. package/dist/components/Sidebar/index.cjs +2 -0
  76. package/dist/components/Sidebar/index.d.ts +6 -1
  77. package/dist/components/Sidebar/index.d.ts.map +1 -1
  78. package/dist/components/Sidebar/index.js +2 -0
  79. package/dist/components/Text/Text.module.scss.cjs +63 -51
  80. package/dist/components/Text/Text.module.scss.js +63 -51
  81. package/dist/components/Text/index.d.ts +4 -2
  82. package/dist/components/Text/index.d.ts.map +1 -1
  83. package/dist/data-table.cjs +210 -28
  84. package/dist/data-table.js +189 -29
  85. package/dist/datepicker.cjs +63 -14
  86. package/dist/datepicker.js +41 -14
  87. package/dist/editor.cjs +67 -16
  88. package/dist/editor.js +45 -16
  89. package/dist/index.cjs +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +2 -1
  93. package/dist/markdown.cjs +63 -19
  94. package/dist/markdown.js +41 -19
  95. package/fragments.json +1 -1
  96. package/package.json +2 -2
  97. package/src/components/Badge/Badge.contract.json +28 -5
  98. package/src/components/Badge/Badge.module.scss +21 -0
  99. package/src/components/Badge/index.tsx +10 -1
  100. package/src/components/Button/Button.contract.json +9 -3
  101. package/src/components/Button/Button.module.scss +34 -0
  102. package/src/components/Button/index.tsx +11 -1
  103. package/src/components/Chart/Chart.contract.json +7 -0
  104. package/src/components/Chart/index.tsx +44 -27
  105. package/src/components/CodeBlock/CodeBlock.contract.json +7 -0
  106. package/src/components/CodeBlock/CodeBlock.module.scss +7 -2
  107. package/src/components/CodeBlock/index.tsx +38 -6
  108. package/src/components/ColorPicker/ColorPicker.contract.json +9 -1
  109. package/src/components/ColorPicker/index.tsx +42 -12
  110. package/src/components/Combobox/Combobox.module.scss +4 -13
  111. package/src/components/ConversationList/ConversationList.contract.json +16 -1
  112. package/src/components/ConversationList/ConversationList.module.scss +12 -0
  113. package/src/components/ConversationList/index.tsx +40 -26
  114. package/src/components/DataTable/DataTable.contract.json +8 -1
  115. package/src/components/DataTable/DataTable.module.scss +5 -0
  116. package/src/components/DataTable/index.tsx +282 -51
  117. package/src/components/DatePicker/DatePicker.contract.json +8 -1
  118. package/src/components/DatePicker/index.tsx +51 -22
  119. package/src/components/Editor/Editor.contract.json +19 -1
  120. package/src/components/Editor/index.tsx +67 -23
  121. package/src/components/Listbox/Listbox.module.scss +3 -11
  122. package/src/components/Markdown/Markdown.contract.json +15 -1
  123. package/src/components/Markdown/Markdown.module.scss +18 -1
  124. package/src/components/Markdown/index.tsx +58 -24
  125. package/src/components/Menu/Menu.module.scss +12 -2
  126. package/src/components/Message/Message.contract.json +3 -0
  127. package/src/components/Message/Message.module.scss +33 -4
  128. package/src/components/Message/index.tsx +7 -1
  129. package/src/components/Pagination/Pagination.contract.json +1 -0
  130. package/src/components/Pagination/Pagination.module.scss +5 -4
  131. package/src/components/Prompt/Prompt.contract.json +40 -1
  132. package/src/components/Prompt/Prompt.module.scss +351 -4
  133. package/src/components/Prompt/index.tsx +324 -0
  134. package/src/components/Select/Select.contract.json +11 -1
  135. package/src/components/Select/Select.module.scss +80 -13
  136. package/src/components/Select/index.tsx +73 -7
  137. package/src/components/Sidebar/Sidebar.contract.json +1 -0
  138. package/src/components/Sidebar/Sidebar.module.scss +10 -0
  139. package/src/components/Sidebar/index.tsx +7 -0
  140. package/src/components/Text/Text.contract.json +7 -4
  141. package/src/components/Text/Text.module.scss +18 -0
  142. package/src/components/Text/index.tsx +4 -2
  143. package/src/index.ts +1 -0
  144. package/src/tokens/_mixins.scss +39 -4
  145. package/src/tokens/_variables.scss +30 -0
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react';
4
4
  import styles from './Prompt.module.scss';
5
5
  import { Loading } from '../Loading';
6
+ import { Select, type SelectOption, type SelectValue } from '../Select';
6
7
 
7
8
  // ============================================
8
9
  // Types
@@ -10,6 +11,8 @@ import { Loading } from '../Loading';
10
11
 
11
12
  export type PromptVariant = 'default' | 'fixed' | 'sticky';
12
13
 
14
+ export type PromptAppearance = 'panel' | 'seamless';
15
+
13
16
  export interface PromptProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'onSubmit' | 'defaultValue'> {
14
17
  children: React.ReactNode;
15
18
  /** Controlled input value */
@@ -34,8 +37,19 @@ export interface PromptProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
34
37
  autoResize?: boolean;
35
38
  /** Submit on Enter key (Shift+Enter for newline) */
36
39
  submitOnEnter?: boolean;
40
+ /** Files added to the prompt, by any of the three routes people actually
41
+ * use: the attach button, a paste, or a drop anywhere on the card. Providing
42
+ * it is what turns all three on. */
43
+ onFiles?: (files: File[]) => void;
44
+ /** `accept` for the attach button's picker, e.g. `"image/*"`. */
45
+ accept?: string;
37
46
  /** Visual variant - "fixed" for bottom-fixed elevated prompt */
38
47
  variant?: PromptVariant;
48
+ /** How the card is divided up. `panel` keeps the toolbar as a filled footer
49
+ * under a rule; `seamless` makes the whole card one writing surface with the
50
+ * controls floating on it — the shape most agent composers use.
51
+ * @default "panel" */
52
+ appearance?: PromptAppearance;
39
53
  }
40
54
 
41
55
  export interface PromptTextareaProps extends Omit<
@@ -48,6 +62,8 @@ export interface PromptTextareaProps extends Omit<
48
62
  onChange?: React.ChangeEventHandler<HTMLTextAreaElement>;
49
63
  /** Composed with internal submit-on-enter logic */
50
64
  onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
65
+ /** Composed with the prompt's paste-to-attach behaviour */
66
+ onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
51
67
  }
52
68
 
53
69
  export interface PromptToolbarProps {
@@ -101,6 +117,59 @@ export interface PromptModeButtonProps {
101
117
  className?: string;
102
118
  }
103
119
 
120
+ /** One thing attached to the prompt, as the strip needs to render it. */
121
+ export interface PromptAttachment {
122
+ id: string;
123
+ name: string;
124
+ /** Size in bytes. Shown beside the name when known. */
125
+ size?: number;
126
+ /** Object URL or data URI. An image gets a thumbnail instead of an icon. */
127
+ previewUrl?: string;
128
+ }
129
+
130
+ export interface PromptAttachmentsProps {
131
+ items: PromptAttachment[];
132
+ /** Omit to render the strip read-only. */
133
+ onRemove?: (id: string) => void;
134
+ className?: string;
135
+ }
136
+
137
+ export interface PromptAttachProps {
138
+ /** Accessible name.
139
+ * @default "Attach files" */
140
+ 'aria-label'?: string;
141
+ /** Custom glyph. Defaults to a plus. */
142
+ children?: React.ReactNode;
143
+ /** Overrides the prompt's own `accept` for this control. */
144
+ accept?: string;
145
+ /** @default true */
146
+ multiple?: boolean;
147
+ disabled?: boolean;
148
+ className?: string;
149
+ }
150
+
151
+ export interface PromptSelectProps {
152
+ /** Leading glyph — says what the choice is about, so the visible text can be
153
+ * the choice itself. */
154
+ icon?: React.ReactNode;
155
+ /** Accessible name. The trigger shows the current choice, not this. */
156
+ 'aria-label': string;
157
+ /** Controlled value */
158
+ value?: SelectValue | null;
159
+ /** Default value for uncontrolled usage */
160
+ defaultValue?: SelectValue;
161
+ /** Called when the choice changes */
162
+ onValueChange?: (value: SelectValue | null) => void;
163
+ /** The choices. Omit and pass `Select.Item` children for richer options. */
164
+ options?: SelectOption[];
165
+ children?: React.ReactNode;
166
+ /** Shown before anything is chosen */
167
+ placeholder?: string;
168
+ /** Disabled independently of the prompt's own disabled state */
169
+ disabled?: boolean;
170
+ className?: string;
171
+ }
172
+
104
173
  export interface PromptUsageProps {
105
174
  children: React.ReactNode;
106
175
  className?: string;
@@ -118,6 +187,36 @@ export interface PromptSubmitProps {
118
187
  // Icons
119
188
  // ============================================
120
189
 
190
+ function PlusIcon() {
191
+ return (
192
+ <svg
193
+ xmlns="http://www.w3.org/2000/svg"
194
+ width="16"
195
+ height="16"
196
+ viewBox="0 0 256 256"
197
+ fill="currentColor"
198
+ aria-hidden="true"
199
+ >
200
+ <path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z" />
201
+ </svg>
202
+ );
203
+ }
204
+
205
+ function CloseIcon() {
206
+ return (
207
+ <svg
208
+ xmlns="http://www.w3.org/2000/svg"
209
+ width="16"
210
+ height="16"
211
+ viewBox="0 0 256 256"
212
+ fill="currentColor"
213
+ aria-hidden="true"
214
+ >
215
+ <path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
216
+ </svg>
217
+ );
218
+ }
219
+
121
220
  function ArrowUpIcon() {
122
221
  return (
123
222
  <svg
@@ -149,6 +248,10 @@ interface PromptContextValue {
149
248
  submitOnEnter: boolean;
150
249
  handleSubmit: () => void;
151
250
  textareaRef: React.RefObject<HTMLTextAreaElement | null>;
251
+ accept?: string;
252
+ /** Undefined when the consumer has not opted into files at all, which is how
253
+ * Attach knows to disable itself and the root knows not to accept drops. */
254
+ onFiles?: (files: File[]) => void;
152
255
  }
153
256
 
154
257
  const PromptContext = React.createContext<PromptContextValue | null>(null);
@@ -205,10 +308,19 @@ function PromptRoot({
205
308
  autoResize = true,
206
309
  submitOnEnter = true,
207
310
  variant = 'default',
311
+ appearance = 'panel',
312
+ onFiles,
313
+ accept,
208
314
  className,
209
315
  ...htmlProps
210
316
  }: PromptProps) {
211
317
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
318
+ const [dragging, setDragging] = React.useState(false);
319
+ // Drag events fire for every element the pointer crosses inside the card, so
320
+ // a naive enter/leave pair flickers as the cursor moves between children.
321
+ // Counting them means the highlight only drops when the pointer has actually
322
+ // left the composer.
323
+ const dragDepth = React.useRef(0);
212
324
 
213
325
  const [value, setValue] = useControllableState(
214
326
  controlledValue,
@@ -221,6 +333,14 @@ function PromptRoot({
221
333
  onSubmit?.(value);
222
334
  }, [disabled, loading, value, onSubmit]);
223
335
 
336
+ const acceptFiles = React.useCallback(
337
+ (files: File[]) => {
338
+ if (!onFiles || disabled || loading || files.length === 0) return;
339
+ onFiles(files);
340
+ },
341
+ [onFiles, disabled, loading]
342
+ );
343
+
224
344
  const contextValue: PromptContextValue = {
225
345
  value,
226
346
  setValue,
@@ -233,12 +353,43 @@ function PromptRoot({
233
353
  submitOnEnter,
234
354
  handleSubmit,
235
355
  textareaRef,
356
+ accept,
357
+ onFiles: onFiles ? acceptFiles : undefined,
236
358
  };
237
359
 
360
+ const dropHandlers = onFiles
361
+ ? {
362
+ onDragEnter: (event: React.DragEvent<HTMLDivElement>) => {
363
+ if (!event.dataTransfer.types.includes('Files')) return;
364
+ dragDepth.current += 1;
365
+ setDragging(true);
366
+ },
367
+ onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
368
+ if (!event.dataTransfer.types.includes('Files')) return;
369
+ // Without this the browser navigates to the dropped file.
370
+ event.preventDefault();
371
+ event.dataTransfer.dropEffect = 'copy';
372
+ },
373
+ onDragLeave: () => {
374
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
375
+ if (dragDepth.current === 0) setDragging(false);
376
+ },
377
+ onDrop: (event: React.DragEvent<HTMLDivElement>) => {
378
+ if (!event.dataTransfer.types.includes('Files')) return;
379
+ event.preventDefault();
380
+ dragDepth.current = 0;
381
+ setDragging(false);
382
+ acceptFiles(Array.from(event.dataTransfer.files));
383
+ },
384
+ }
385
+ : null;
386
+
238
387
  const classes = [
239
388
  styles.prompt,
240
389
  variant === 'fixed' && styles.fixed,
241
390
  variant === 'sticky' && styles.sticky,
391
+ appearance === 'seamless' && styles.seamless,
392
+ dragging && styles.dragging,
242
393
  disabled && styles.disabled,
243
394
  loading && styles.loading,
244
395
  className,
@@ -248,10 +399,13 @@ function PromptRoot({
248
399
  <PromptContext.Provider value={contextValue}>
249
400
  <div
250
401
  {...htmlProps}
402
+ {...dropHandlers}
251
403
  className={classes}
252
404
  data-disabled={disabled || undefined}
253
405
  data-loading={loading || undefined}
254
406
  data-variant={variant}
407
+ data-appearance={appearance}
408
+ data-dragging={dragging || undefined}
255
409
  >
256
410
  {children}
257
411
  </div>
@@ -264,6 +418,7 @@ function PromptTextarea({
264
418
  className,
265
419
  onChange,
266
420
  onKeyDown,
421
+ onPaste,
267
422
  'aria-label': ariaLabel,
268
423
  ...htmlProps
269
424
  }: PromptTextareaProps) {
@@ -279,6 +434,7 @@ function PromptTextarea({
279
434
  submitOnEnter,
280
435
  handleSubmit,
281
436
  textareaRef,
437
+ onFiles,
282
438
  } = usePromptContext();
283
439
 
284
440
  const lineHeight = 1.5;
@@ -321,6 +477,26 @@ function PromptTextarea({
321
477
  }
322
478
  };
323
479
 
480
+ // Pasting a screenshot is how people actually attach one. The clipboard
481
+ // carries it as a file with no name, so it gets one here — a bare "image.png"
482
+ // in the strip is worse than useless when there are two of them.
483
+ const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
484
+ onPaste?.(e);
485
+ if (e.defaultPrevented || !onFiles) return;
486
+ const files = Array.from(e.clipboardData.files);
487
+ if (files.length === 0) return;
488
+ e.preventDefault();
489
+ onFiles(
490
+ files.map((file, index) =>
491
+ file.name && file.name !== 'image.png'
492
+ ? file
493
+ : new File([file], `pasted-${index + 1}.${file.type.split('/')[1] || 'png'}`, {
494
+ type: file.type,
495
+ })
496
+ )
497
+ );
498
+ };
499
+
324
500
  const classes = [styles.textarea, className].filter(Boolean).join(' ');
325
501
 
326
502
  return (
@@ -331,6 +507,7 @@ function PromptTextarea({
331
507
  value={value}
332
508
  onChange={handleChange}
333
509
  onKeyDown={handleKeyDown}
510
+ onPaste={handlePaste}
334
511
  placeholder={overridePlaceholder ?? placeholder}
335
512
  disabled={disabled || loading}
336
513
  rows={minRows}
@@ -431,6 +608,147 @@ function PromptModeButton({
431
608
  );
432
609
  }
433
610
 
611
+ /** Bytes as something a person reads at a glance, not as a precise count. */
612
+ function formatSize(bytes: number): string {
613
+ if (bytes < 1024) return `${bytes} B`;
614
+ const kb = bytes / 1024;
615
+ if (kb < 1024) return `${Math.round(kb)} KB`;
616
+ return `${(kb / 1024).toFixed(kb / 1024 < 10 ? 1 : 0)} MB`;
617
+ }
618
+
619
+ /**
620
+ * What is going with the message. Renders above the textarea rather than under
621
+ * the toolbar, because these are part of what you are sending and not a
622
+ * setting on the sending.
623
+ */
624
+ function PromptAttachments({ items, onRemove, className }: PromptAttachmentsProps) {
625
+ if (items.length === 0) return null;
626
+ const classes = [styles.attachments, className].filter(Boolean).join(' ');
627
+
628
+ return (
629
+ <ul className={classes} aria-label="Attachments">
630
+ {items.map((item) => (
631
+ <li key={item.id} className={styles.attachment}>
632
+ {item.previewUrl ? (
633
+ // Decorative: the filename beside it is the accessible name, and a
634
+ // thumbnail of a screenshot has no description worth inventing.
635
+ <img src={item.previewUrl} alt="" className={styles.attachmentThumb} />
636
+ ) : null}
637
+ <span className={styles.attachmentName} title={item.name}>
638
+ {item.name}
639
+ </span>
640
+ {item.size != null && (
641
+ <span className={styles.attachmentSize}>{formatSize(item.size)}</span>
642
+ )}
643
+ {onRemove && (
644
+ <button
645
+ type="button"
646
+ className={styles.attachmentRemove}
647
+ onClick={() => onRemove(item.id)}
648
+ aria-label={`Remove ${item.name}`}
649
+ >
650
+ <CloseIcon />
651
+ </button>
652
+ )}
653
+ </li>
654
+ ))}
655
+ </ul>
656
+ );
657
+ }
658
+
659
+ /**
660
+ * The attach control. Does nothing on its own — it opens a picker and hands
661
+ * the result to the prompt's `onFiles`, the same callback that paste and drop
662
+ * go through, so a consumer writes one handler for all three.
663
+ */
664
+ function PromptAttach({
665
+ 'aria-label': ariaLabel = 'Attach files',
666
+ children,
667
+ accept: acceptOverride,
668
+ multiple = true,
669
+ disabled: buttonDisabled,
670
+ className,
671
+ }: PromptAttachProps) {
672
+ const { disabled, loading, accept, onFiles } = usePromptContext();
673
+ const inputRef = React.useRef<HTMLInputElement>(null);
674
+ const isDisabled = disabled || loading || buttonDisabled || !onFiles;
675
+
676
+ const classes = [styles.attach, className].filter(Boolean).join(' ');
677
+
678
+ return (
679
+ <>
680
+ <button
681
+ type="button"
682
+ className={classes}
683
+ onClick={() => inputRef.current?.click()}
684
+ disabled={isDisabled}
685
+ aria-label={ariaLabel}
686
+ >
687
+ {children ?? <PlusIcon />}
688
+ </button>
689
+ {/* `hidden` rather than a class: the button above is the control, and
690
+ this must be out of the accessibility tree entirely or it reads as a
691
+ second, unlabelled one. A hidden input still opens its picker when
692
+ clicked programmatically. */}
693
+ <input
694
+ ref={inputRef}
695
+ type="file"
696
+ hidden
697
+ accept={acceptOverride ?? accept}
698
+ multiple={multiple}
699
+ onChange={(event) => {
700
+ const files = Array.from(event.target.files ?? []);
701
+ // Reset first, or picking the same file twice in a row is a no-op.
702
+ event.target.value = '';
703
+ onFiles?.(files);
704
+ }}
705
+ />
706
+ </>
707
+ );
708
+ }
709
+
710
+ /**
711
+ * A choice that scopes the submission — model, agent, tone, which project this
712
+ * runs against. `ModeButton` covers a setting you toggle; this covers one you
713
+ * pick from a list, which every non-trivial composer ends up needing.
714
+ *
715
+ * It is a `Select` throughout, in its ghost variant, so the popup, keyboard
716
+ * handling and selection state are the same ones the rest of the library uses.
717
+ * The trigger shows the current choice rather than a field label, because in a
718
+ * toolbar the choice is the only part worth the width — what kind of choice it
719
+ * is comes from the icon and the accessible name.
720
+ */
721
+ function PromptSelect({
722
+ icon,
723
+ 'aria-label': ariaLabel,
724
+ options,
725
+ children,
726
+ placeholder,
727
+ disabled: selectDisabled,
728
+ className,
729
+ ...selectProps
730
+ }: PromptSelectProps) {
731
+ const { disabled, loading } = usePromptContext();
732
+ const classes = [styles.select, className].filter(Boolean).join(' ');
733
+
734
+ return (
735
+ <Select
736
+ {...selectProps}
737
+ size="sm"
738
+ variant="ghost"
739
+ options={options}
740
+ placeholder={placeholder}
741
+ disabled={disabled || loading || selectDisabled}
742
+ >
743
+ <Select.Trigger className={classes} icon={icon} aria-label={ariaLabel} />
744
+ {/* No children: Content renders what `options` describes, which is how a
745
+ hint or anything else the root knows about an option survives being
746
+ composed into a custom trigger. */}
747
+ <Select.Content>{children}</Select.Content>
748
+ </Select>
749
+ );
750
+ }
751
+
434
752
  function PromptUsage({ children, className }: PromptUsageProps) {
435
753
  const classes = [styles.usage, className].filter(Boolean).join(' ');
436
754
  return <span className={classes}>{children}</span>;
@@ -486,6 +804,9 @@ export const Prompt = Object.assign(PromptRoot, {
486
804
  Info: PromptInfo,
487
805
  ActionButton: PromptActionButton,
488
806
  ModeButton: PromptModeButton,
807
+ Select: PromptSelect,
808
+ Attach: PromptAttach,
809
+ Attachments: PromptAttachments,
489
810
  Usage: PromptUsage,
490
811
  Submit: PromptSubmit,
491
812
  });
@@ -500,6 +821,9 @@ export {
500
821
  PromptInfo,
501
822
  PromptActionButton,
502
823
  PromptModeButton,
824
+ PromptSelect,
825
+ PromptAttach,
826
+ PromptAttachments,
503
827
  PromptUsage,
504
828
  PromptSubmit,
505
829
  };
@@ -21,6 +21,7 @@
21
21
  "placeholder: string",
22
22
  "options: array",
23
23
  "size: sm|md|lg (default: md)",
24
+ "variant: field|ghost (default: field)",
24
25
  "className: string",
25
26
  "label: string",
26
27
  "helperText: string",
@@ -96,6 +97,13 @@
96
97
  "required": false,
97
98
  "values": ["sm", "md", "lg"]
98
99
  },
100
+ "variant": {
101
+ "type": "enum",
102
+ "description": "Visual treatment. \"ghost\" drops the field shell for a compact borderless control, for toolbars and dense rows. Pair with size=\"sm\".",
103
+ "default": "field",
104
+ "required": false,
105
+ "values": ["field", "ghost"]
106
+ },
99
107
  "className": {
100
108
  "type": "string",
101
109
  "description": "Wrapper class name",
@@ -225,8 +233,10 @@
225
233
  "placeholder: string - placeholder text",
226
234
  "disabled: boolean - disable select",
227
235
  "size: sm|md|lg (default: md)",
236
+ "variant: field|ghost (default: field) - ghost is borderless, for toolbars",
228
237
  "options: SelectOption[] - convenience API for simple option lists",
229
- "maxVisibleItems: number - max visible options before scrolling (default 4)"
238
+ "maxVisibleItems: number - max visible options before scrolling (default 4)",
239
+ "Select.Trigger accepts an icon prop for a leading adornment"
230
240
  ],
231
241
  "a11yRules": ["A11Y_SELECT_KEYBOARD", "A11Y_SELECT_LABEL"]
232
242
  },
@@ -34,6 +34,74 @@
34
34
  @include control-size("lg", "input");
35
35
  }
36
36
 
37
+ // Ghost trigger — the same control with the field shell taken off, for
38
+ // toolbars and dense rows where a bordered field would read as a form rather
39
+ // than as a setting. Deliberately identical in padding, radius, type scale and
40
+ // hover to the other compact controls that share those rows, so a select sits
41
+ // among them as a sibling instead of a fourth vocabulary.
42
+ //
43
+ // Must be declared after `.trigger` and the size variants: it unsets what they
44
+ // set, and they are all single-class selectors.
45
+ .triggerGhost {
46
+ width: auto;
47
+ min-width: 0;
48
+ // No block padding: the control is exactly the target size tall and centres
49
+ // its own contents, the way a button does. Padding here instead would add to
50
+ // the line box and leave a ghost taller than every other control in the row
51
+ // — and taller again wherever the surface has scaled the target down.
52
+ height: auto;
53
+ gap: var(--fui-space-1, $fui-space-1);
54
+ padding: 0 var(--fui-space-2, $fui-space-2);
55
+ border: none;
56
+ border-radius: var(--fui-radius-md, $fui-radius-md);
57
+ background-color: transparent;
58
+ box-shadow: none;
59
+ color: var(--fui-text-secondary, $fui-text-secondary);
60
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
61
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
62
+
63
+ &:hover:not(:disabled):not([data-disabled]) {
64
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
65
+ color: var(--fui-text-primary, $fui-text-primary);
66
+ }
67
+
68
+ &:active:not(:disabled):not([data-disabled]) {
69
+ background-color: var(--fui-bg-active, $fui-bg-active);
70
+ }
71
+
72
+ // Open is a held state, not a hover: the control stays lit while the popup
73
+ // it owns is on screen.
74
+ &[data-popup-open] {
75
+ border-color: transparent;
76
+ background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
77
+ color: var(--fui-text-primary, $fui-text-primary);
78
+ }
79
+
80
+ // No shell to fill, so disabled reads as recession rather than as a
81
+ // greyed-out box.
82
+ &:disabled,
83
+ &[data-disabled] {
84
+ background-color: transparent;
85
+ opacity: 0.5;
86
+ }
87
+ }
88
+
89
+ // Ghost sizes to its content and may shrink; the wrapper has to allow both or
90
+ // the trigger measures against its label and escapes a tight row.
91
+ .wrapperGhost {
92
+ display: inline-flex;
93
+ min-width: 0;
94
+ }
95
+
96
+ // Leading adornment on the trigger
97
+ .triggerIcon {
98
+ display: flex;
99
+ flex-shrink: 0;
100
+ align-items: center;
101
+ justify-content: center;
102
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
103
+ }
104
+
37
105
  // Value display
38
106
  .value {
39
107
  flex: 1;
@@ -54,6 +122,14 @@
54
122
  color: var(--fui-text-tertiary, $fui-text-tertiary);
55
123
  }
56
124
 
125
+ // The qualifier on an option — recessed, because it tells you which one this is
126
+ // rather than being the thing you are choosing.
127
+ .hint {
128
+ margin-inline-start: var(--fui-space-1, $fui-space-1);
129
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
130
+ font-weight: var(--fui-font-weight-normal, $fui-font-weight-normal);
131
+ }
132
+
57
133
  // Chevron icon
58
134
  .icon {
59
135
  display: flex;
@@ -83,22 +159,13 @@
83
159
  @include surface-elevated;
84
160
  @include popup-container;
85
161
 
86
- border-color: var(--fui-form-group-border, $fui-border);
87
-
88
- // Item height derived from text-base font + vertical padding
89
- --_select-item-h: calc(
90
- var(--fui-font-size-sm, #{$fui-font-size-sm}) *
91
- var(--fui-line-height-normal, #{$fui-line-height-normal}) +
92
- var(--fui-space-2, #{$fui-space-2}) * 2
93
- );
162
+ @include popup-scroll-viewport;
94
163
 
164
+ border-color: var(--fui-form-group-border, $fui-border);
95
165
  min-width: var(--anchor-width);
96
- // Show N items + half-peek scroll hint (default 4.5 items)
166
+ // Show N items + half-peek scroll hint (default 4.5 items).
97
167
  // !important needed to override Base UI's inline max-height: 100%
98
- max-height: calc(
99
- var(--_select-item-h) * var(--fui-select-max-items, 4.5) + var(--fui-space-1, #{$fui-space-1}) *
100
- 2
101
- ) !important;
168
+ max-height: var(--_popup-scroll-max-h) !important;
102
169
  overflow-y: auto !important;
103
170
 
104
171
  // Animation