@flamingo-stack/openframe-frontend-core 0.0.327 → 0.0.328-snapshot.20260626183749

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 (116) hide show
  1. package/dist/{chunk-CWXO6VCD.js → chunk-2UVIHKQQ.js} +4146 -3950
  2. package/dist/chunk-2UVIHKQQ.js.map +1 -0
  3. package/dist/{chunk-CRHOAKG5.cjs → chunk-5BEJ74WU.cjs} +70 -70
  4. package/dist/{chunk-CRHOAKG5.cjs.map → chunk-5BEJ74WU.cjs.map} +1 -1
  5. package/dist/{chunk-A6NBGRGZ.js → chunk-65VXW6GZ.js} +2 -2
  6. package/dist/{chunk-J4HHXHMC.js → chunk-67MYKAAK.js} +6 -6
  7. package/dist/{chunk-MWAEG3XK.cjs → chunk-6B7YS7O6.cjs} +12 -12
  8. package/dist/{chunk-MWAEG3XK.cjs.map → chunk-6B7YS7O6.cjs.map} +1 -1
  9. package/dist/{chunk-HQ6BILVP.cjs → chunk-6CYMYELP.cjs} +31 -31
  10. package/dist/{chunk-HQ6BILVP.cjs.map → chunk-6CYMYELP.cjs.map} +1 -1
  11. package/dist/{chunk-GABTFTDE.cjs → chunk-C34M45FI.cjs} +822 -626
  12. package/dist/chunk-C34M45FI.cjs.map +1 -0
  13. package/dist/{chunk-GXRWE7NB.cjs → chunk-CCG4DARM.cjs} +2 -2
  14. package/dist/{chunk-GXRWE7NB.cjs.map → chunk-CCG4DARM.cjs.map} +1 -1
  15. package/dist/{chunk-27IO7V3P.cjs → chunk-DCIB6AK3.cjs} +9 -9
  16. package/dist/{chunk-27IO7V3P.cjs.map → chunk-DCIB6AK3.cjs.map} +1 -1
  17. package/dist/{chunk-LESTX5DM.js → chunk-EUMMHRR4.js} +3 -3
  18. package/dist/{chunk-UHHJIHUZ.js → chunk-HT67OPIE.js} +6 -6
  19. package/dist/{chunk-PGFQ6WQR.cjs → chunk-HWFVPOHJ.cjs} +13 -13
  20. package/dist/{chunk-PGFQ6WQR.cjs.map → chunk-HWFVPOHJ.cjs.map} +1 -1
  21. package/dist/{chunk-U7GDY7Y3.js → chunk-IYPVN3DV.js} +5 -5
  22. package/dist/{chunk-3UVR2JIB.js → chunk-KDHZVMH5.js} +3 -3
  23. package/dist/{chunk-3AXH7BDM.cjs → chunk-KMIYWEPB.cjs} +29 -29
  24. package/dist/{chunk-3AXH7BDM.cjs.map → chunk-KMIYWEPB.cjs.map} +1 -1
  25. package/dist/{chunk-I4OFXYJR.cjs → chunk-MKLDE5OZ.cjs} +19 -19
  26. package/dist/{chunk-I4OFXYJR.cjs.map → chunk-MKLDE5OZ.cjs.map} +1 -1
  27. package/dist/{chunk-XDNQJQGP.cjs → chunk-NNP323TB.cjs} +5 -5
  28. package/dist/{chunk-XDNQJQGP.cjs.map → chunk-NNP323TB.cjs.map} +1 -1
  29. package/dist/{chunk-QF4XERYJ.js → chunk-OUUA36U2.js} +2 -2
  30. package/dist/{chunk-62O2VTR2.js → chunk-OZK7XALE.js} +2 -2
  31. package/dist/{chunk-4ZHA24HS.cjs → chunk-QCVM3ZSD.cjs} +40 -40
  32. package/dist/{chunk-4ZHA24HS.cjs.map → chunk-QCVM3ZSD.cjs.map} +1 -1
  33. package/dist/{chunk-46GJFN4V.js → chunk-QGBSE4JV.js} +5 -5
  34. package/dist/{chunk-BWWUHOE3.cjs → chunk-S2Z6HDRF.cjs} +73 -73
  35. package/dist/{chunk-BWWUHOE3.cjs.map → chunk-S2Z6HDRF.cjs.map} +1 -1
  36. package/dist/{chunk-7FY2OM5W.cjs → chunk-SH6X376N.cjs} +39 -39
  37. package/dist/{chunk-7FY2OM5W.cjs.map → chunk-SH6X376N.cjs.map} +1 -1
  38. package/dist/{chunk-7KZJV4MP.js → chunk-SWGXCGQ3.js} +2 -2
  39. package/dist/{chunk-K5HBVPJ5.cjs → chunk-SY37FQHF.cjs} +3 -3
  40. package/dist/{chunk-K5HBVPJ5.cjs.map → chunk-SY37FQHF.cjs.map} +1 -1
  41. package/dist/{chunk-5WDRXBM3.js → chunk-TH65GPIK.js} +3 -3
  42. package/dist/{chunk-MJUYWNQB.js → chunk-UNL2RMOC.js} +3 -3
  43. package/dist/{chunk-4MD2TPFY.js → chunk-UZTGDV6N.js} +2 -2
  44. package/dist/{chunk-NROSNR5J.cjs → chunk-V6LUP7PN.cjs} +15 -15
  45. package/dist/{chunk-NROSNR5J.cjs.map → chunk-V6LUP7PN.cjs.map} +1 -1
  46. package/dist/{chunk-IDMM6DVL.js → chunk-YDUV3VQ2.js} +4 -4
  47. package/dist/components/case-studies/index.cjs +10 -10
  48. package/dist/components/case-studies/index.js +4 -4
  49. package/dist/components/chat/index.cjs +4 -4
  50. package/dist/components/chat/index.js +3 -3
  51. package/dist/components/contact/index.cjs +5 -5
  52. package/dist/components/contact/index.js +4 -4
  53. package/dist/components/docs/index.cjs +7 -7
  54. package/dist/components/docs/index.js +6 -6
  55. package/dist/components/embeds/index.cjs +5 -5
  56. package/dist/components/embeds/index.js +4 -4
  57. package/dist/components/faq/index.cjs +6 -6
  58. package/dist/components/faq/index.js +5 -5
  59. package/dist/components/features/index.cjs +4 -4
  60. package/dist/components/features/index.js +3 -3
  61. package/dist/components/help-center-pages/index.cjs +23 -23
  62. package/dist/components/help-center-pages/index.js +14 -14
  63. package/dist/components/icons-v2-generated/index.cjs +2 -2
  64. package/dist/components/icons-v2-generated/index.js +1 -1
  65. package/dist/components/index.cjs +127 -125
  66. package/dist/components/index.cjs.map +1 -1
  67. package/dist/components/index.js +14 -12
  68. package/dist/components/index.js.map +1 -1
  69. package/dist/components/navigation/index.cjs +4 -4
  70. package/dist/components/navigation/index.js +3 -3
  71. package/dist/components/onboarding-guides/index.cjs +8 -8
  72. package/dist/components/onboarding-guides/index.js +7 -7
  73. package/dist/components/related-content/index.cjs +6 -6
  74. package/dist/components/related-content/index.js +5 -5
  75. package/dist/components/tickets/index.cjs +7 -7
  76. package/dist/components/tickets/index.js +6 -6
  77. package/dist/components/ui/autocomplete.d.ts +5 -0
  78. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  79. package/dist/components/ui/file-upload.d.ts +3 -1
  80. package/dist/components/ui/file-upload.d.ts.map +1 -1
  81. package/dist/components/ui/image-uploader.d.ts +6 -1
  82. package/dist/components/ui/image-uploader.d.ts.map +1 -1
  83. package/dist/components/ui/index.cjs +6 -4
  84. package/dist/components/ui/index.cjs.map +1 -1
  85. package/dist/components/ui/index.d.ts +1 -0
  86. package/dist/components/ui/index.d.ts.map +1 -1
  87. package/dist/components/ui/index.js +5 -3
  88. package/dist/components/ui/tag-select-dropdown.d.ts +36 -0
  89. package/dist/components/ui/tag-select-dropdown.d.ts.map +1 -0
  90. package/dist/hooks/index.cjs +3 -3
  91. package/dist/hooks/index.js +2 -2
  92. package/dist/index.cjs +6 -4
  93. package/dist/index.cjs.map +1 -1
  94. package/dist/index.js +5 -3
  95. package/package.json +1 -1
  96. package/src/components/ui/autocomplete.tsx +50 -18
  97. package/src/components/ui/file-upload.tsx +74 -76
  98. package/src/components/ui/image-uploader.tsx +7 -0
  99. package/src/components/ui/index.ts +1 -0
  100. package/src/components/ui/tag-select-dropdown.tsx +220 -0
  101. package/dist/chunk-CWXO6VCD.js.map +0 -1
  102. package/dist/chunk-GABTFTDE.cjs.map +0 -1
  103. /package/dist/{chunk-A6NBGRGZ.js.map → chunk-65VXW6GZ.js.map} +0 -0
  104. /package/dist/{chunk-J4HHXHMC.js.map → chunk-67MYKAAK.js.map} +0 -0
  105. /package/dist/{chunk-LESTX5DM.js.map → chunk-EUMMHRR4.js.map} +0 -0
  106. /package/dist/{chunk-UHHJIHUZ.js.map → chunk-HT67OPIE.js.map} +0 -0
  107. /package/dist/{chunk-U7GDY7Y3.js.map → chunk-IYPVN3DV.js.map} +0 -0
  108. /package/dist/{chunk-3UVR2JIB.js.map → chunk-KDHZVMH5.js.map} +0 -0
  109. /package/dist/{chunk-QF4XERYJ.js.map → chunk-OUUA36U2.js.map} +0 -0
  110. /package/dist/{chunk-62O2VTR2.js.map → chunk-OZK7XALE.js.map} +0 -0
  111. /package/dist/{chunk-46GJFN4V.js.map → chunk-QGBSE4JV.js.map} +0 -0
  112. /package/dist/{chunk-7KZJV4MP.js.map → chunk-SWGXCGQ3.js.map} +0 -0
  113. /package/dist/{chunk-5WDRXBM3.js.map → chunk-TH65GPIK.js.map} +0 -0
  114. /package/dist/{chunk-MJUYWNQB.js.map → chunk-UNL2RMOC.js.map} +0 -0
  115. /package/dist/{chunk-4MD2TPFY.js.map → chunk-UZTGDV6N.js.map} +0 -0
  116. /package/dist/{chunk-IDMM6DVL.js.map → chunk-YDUV3VQ2.js.map} +0 -0
@@ -19,6 +19,7 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
19
19
  import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
20
20
 
21
21
  import { CheckIcon } from "../icons-v2-generated/signs-and-symbols/check-icon"
22
+ import { TrashIcon } from "../icons-v2-generated/interface/trash-icon"
22
23
  import { XmarkCircleIcon } from "../icons-v2-generated/signs-and-symbols/xmark-circle-icon"
23
24
  import { Chevron02DownIcon } from "../icons-v2-generated/arrows/chevron-02-down-icon"
24
25
 
@@ -77,6 +78,11 @@ interface AutocompleteBaseProps<T = string> {
77
78
  creatable?: boolean
78
79
  /** Callback fired after a new option is created via creatable. Use it to persist the new option server-side, etc. */
79
80
  onCreateOption?: (inputValue: string) => void
81
+ /** Max length for a created option. When exceeded, creation is blocked and a hint is shown. Omit for no limit. */
82
+ maxCreateLength?: number
83
+ /** When set, each unselected option shows a hover trash button that calls this. Omit to hide delete. */
84
+ onDeleteOption?: (value: T) => void
85
+ isDeletingOption?: boolean
80
86
  /** When true, disables built-in client-side filtering (useful when options are filtered server-side via onInputChange) */
81
87
  disableClientFilter?: boolean
82
88
  /** Whether to show the chevron icon. Default true */
@@ -145,6 +151,9 @@ function AutocompleteInner<T = string>(
145
151
  loadingText = "Loading...",
146
152
  creatable = false,
147
153
  onCreateOption,
154
+ maxCreateLength,
155
+ onDeleteOption,
156
+ isDeletingOption = false,
148
157
  disableClientFilter = false,
149
158
  showChevron = true,
150
159
  clearOnOpen = true,
@@ -269,11 +278,12 @@ function AutocompleteInner<T = string>(
269
278
 
270
279
  // Show "+ Create" option when creatable is on, user typed something, and nothing matched
271
280
  const showCreateOption = creatable && inputValue.trim().length > 0 && filteredOptions.length === 0
281
+ const isCreateTooLong = maxCreateLength != null && inputValue.trim().length > maxCreateLength
272
282
 
273
283
  // Handle creating a new option
274
284
  const handleCreate = () => {
275
285
  const trimmed = inputValue.trim()
276
- if (!trimmed) return
286
+ if (!trimmed || (maxCreateLength != null && trimmed.length > maxCreateLength)) return
277
287
 
278
288
  const newValue = trimmed as T
279
289
 
@@ -564,18 +574,24 @@ function AutocompleteInner<T = string>(
564
574
  </div>
565
575
  ) : filteredOptions.length === 0 ? (
566
576
  showCreateOption ? (
567
- <div
568
- role="option"
569
- aria-selected={false}
570
- className={cn(
571
- "flex items-center h-11 md:h-12 px-4 cursor-pointer transition-colors min-w-0",
572
- "text-h4 text-ods-accent",
573
- "hover:bg-ods-bg-hover"
574
- )}
575
- onClick={handleCreate}
576
- >
577
- <span className="truncate" title={`+ Create "${inputValue.trim()}"`}>+ Create &quot;{inputValue.trim()}&quot;</span>
578
- </div>
577
+ isCreateTooLong ? (
578
+ <div className="flex items-center h-11 md:h-12 px-[var(--spacing-system-mf)] text-h5 text-ods-error min-w-0">
579
+ Maximum {maxCreateLength} characters
580
+ </div>
581
+ ) : (
582
+ <div
583
+ role="option"
584
+ aria-selected={false}
585
+ className={cn(
586
+ "flex items-center h-11 md:h-12 px-[var(--spacing-system-mf)] cursor-pointer transition-colors min-w-0",
587
+ "text-h4 text-ods-accent",
588
+ "hover:bg-ods-bg-hover"
589
+ )}
590
+ onClick={handleCreate}
591
+ >
592
+ <span className="truncate" title={`+ Create "${inputValue.trim()}"`}>+ Create &quot;{inputValue.trim()}&quot;</span>
593
+ </div>
594
+ )
579
595
  ) : (
580
596
  <div className="px-3 py-2 text-ods-text-secondary text-[14px]">
581
597
  {freeSolo && inputValue.trim() ? (
@@ -596,7 +612,7 @@ function AutocompleteInner<T = string>(
596
612
  role="option"
597
613
  aria-selected={isSelected}
598
614
  className={cn(
599
- "flex items-center h-11 md:h-12 px-4 cursor-pointer transition-colors border-b border-ods-border last:border-b-0",
615
+ "group/item flex items-center h-11 md:h-12 px-[var(--spacing-system-mf)] cursor-pointer transition-colors border-b border-ods-border last:border-b-0",
600
616
  "text-h4",
601
617
  isHighlighted && "bg-ods-bg-surface",
602
618
  isSelected ? "text-ods-accent" : "text-ods-text-primary",
@@ -606,11 +622,27 @@ function AutocompleteInner<T = string>(
606
622
  onMouseEnter={() => setHighlightedIndex(index)}
607
623
  >
608
624
  {renderOption ? renderOption(option, isSelected) : (
609
- <div className="flex items-center justify-between w-full min-w-0">
625
+ <div className="flex items-center justify-between gap-[var(--spacing-system-xsf)] w-full min-w-0">
610
626
  <span className="truncate" title={option.label}>{option.label}</span>
611
- {isSelected && (
612
- <CheckIcon className="text-ods-accent" size={20} />
613
- )}
627
+ <div className="flex items-center gap-[var(--spacing-system-xsf)] shrink-0">
628
+ {isSelected && (
629
+ <CheckIcon className="text-ods-accent" size={20} />
630
+ )}
631
+ {onDeleteOption && !isSelected && (
632
+ <button
633
+ type="button"
634
+ aria-label={`Delete ${option.label}`}
635
+ onClick={e => {
636
+ e.stopPropagation()
637
+ onDeleteOption(option.value)
638
+ }}
639
+ disabled={isDeletingOption}
640
+ className="opacity-0 transition-opacity group-hover/item:opacity-100 focus-visible:opacity-100 disabled:opacity-50 disabled:pointer-events-none"
641
+ >
642
+ <TrashIcon className="size-4 text-ods-error" />
643
+ </button>
644
+ )}
645
+ </div>
614
646
  </div>
615
647
  )}
616
648
  </div>
@@ -1,8 +1,11 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Loader2, X } from "lucide-react"
4
+ import { Loader2 } from "lucide-react"
5
5
  import { ImagePlusIcon } from "../icons-v2-generated/audio-and-visual/image-plus-icon"
6
+ import { Download02Icon } from "../icons-v2-generated/interface/download-02-icon"
7
+ import { TrashIcon } from "../icons-v2-generated/interface/trash-icon"
8
+ import { FileIcon } from "../icons-v2-generated/documents/file-icon"
6
9
  import { cn } from "../../utils/cn"
7
10
  import { FieldWrapper } from "./field-wrapper"
8
11
 
@@ -33,6 +36,8 @@ export interface FileUploadProps {
33
36
  managedFiles?: ManagedFileEntry[]
34
37
  /** Callback to remove a managed file by its id. Required when `managedFiles` is provided. */
35
38
  onRemoveManagedFile?: (id: string) => void
39
+ /** When provided, an uploaded managed file shows a download action that calls this with its id. */
40
+ onDownloadManagedFile?: (id: string) => void
36
41
  /** Accepted MIME types (e.g., "image/*", ".pdf,.doc") */
37
42
  accept?: string
38
43
  /** Maximum file size in bytes. Default: 10MB */
@@ -103,6 +108,7 @@ export function FileUpload({
103
108
  onChange,
104
109
  managedFiles,
105
110
  onRemoveManagedFile,
111
+ onDownloadManagedFile,
106
112
  accept = "*/*",
107
113
  maxSize = 10 * 1024 * 1024,
108
114
  maxFiles = 10,
@@ -283,8 +289,9 @@ export function FileUpload({
283
289
 
284
290
  const displayError = error || validationError || undefined
285
291
  const hasFiles = isManaged ? managedFiles.length > 0 : files.length > 0
286
- const fileCount = currentCount
287
- const canAddMore = multiple && fileCount < maxFiles
292
+ // Single-file mode hides the dropzone once a file is picked (no more to add);
293
+ // multiple keeps it visible to keep attaching.
294
+ const showDropzone = multiple || !hasFiles
288
295
 
289
296
  return (
290
297
  <FieldWrapper label={fieldLabel} error={displayError} className={className}>
@@ -298,42 +305,14 @@ export function FileUpload({
298
305
  disabled={disabled}
299
306
  />
300
307
 
301
- {/* Primary dropzone */}
302
- {!hasFiles && (
303
- <button
304
- type="button"
305
- onDragEnter={handleDrag}
306
- onDragLeave={handleDrag}
307
- onDragOver={handleDrag}
308
- onDrop={handleDrop}
309
- onClick={openDialog}
310
- disabled={disabled}
311
- className={cn(
312
- "flex w-full items-center gap-2 p-3 rounded-[6px] border border-dashed cursor-pointer text-left",
313
- "transition-colors duration-200",
314
- "bg-ods-card border-ods-border",
315
- dragActive && "border-ods-accent bg-ods-accent/5",
316
- !dragActive && "hover:border-ods-accent/30",
317
- disabled && "opacity-50 cursor-not-allowed hover:border-ods-border",
318
- )}
319
- >
320
- <div className="flex items-center p-3 rounded-full bg-ods-card border border-ods-border shrink-0">
321
- {icon || <ImagePlusIcon className="size-6 text-ods-text-primary" />}
322
- </div>
323
- <div className="flex flex-1 flex-col font-['DM_Sans'] font-medium min-w-0">
324
- <span className="text-heading-4 text-ods-text-primary">{label}</span>
325
- <span className="text-heading-5 text-ods-text-secondary">{description}</span>
326
- </div>
327
- </button>
328
- )}
329
-
330
- {/* File list */}
331
- {hasFiles && (
332
- <div className="flex flex-col gap-2">
308
+ {/* File list (above) + upload dropzone — single bordered list, file-type icon + name/size + actions per row */}
309
+ <div className="flex flex-col gap-[var(--spacing-system-xsf)]">
310
+ {hasFiles && (
333
311
  <div
334
312
  className={cn(
335
- "flex flex-col gap-2 rounded-[6px] transition-colors duration-200",
336
- dragActive && "bg-ods-accent/5",
313
+ "flex flex-col rounded-[6px] border border-ods-border bg-ods-card transition-colors duration-200",
314
+ !maxListHeight && "overflow-hidden",
315
+ dragActive && "border-ods-accent",
337
316
  )}
338
317
  style={maxListHeight ? { maxHeight: typeof maxListHeight === "number" ? `${maxListHeight}px` : maxListHeight, overflowY: "auto" } : undefined}
339
318
  >
@@ -342,39 +321,52 @@ export function FileUpload({
342
321
  <div
343
322
  key={entry.id}
344
323
  className={cn(
345
- "flex items-center gap-3 p-3 rounded-[6px] bg-ods-card border",
346
- entry.status === "error" ? "border-[var(--ods-attention-red-error)]/40" : "border-ods-border",
324
+ "flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-sf)] border-b border-ods-border last:border-b-0",
325
+ entry.status === "error" && "bg-ods-error/5",
347
326
  )}
348
327
  >
328
+ <div className="flex items-center justify-center size-10 rounded-[6px] border border-ods-border bg-ods-card shrink-0">
329
+ <FileIcon className="size-6 text-ods-text-secondary" />
330
+ </div>
349
331
  <div className="flex-1 min-w-0">
350
- <p className="text-heading-5 font-medium text-ods-text-primary truncate" title={entry.fileName}>
332
+ <p className="text-heading-4 font-medium text-ods-text-primary truncate" title={entry.fileName}>
351
333
  {entry.fileName}
352
334
  </p>
353
- <div className="flex items-center gap-2">
354
- <span className="text-heading-6 text-ods-text-secondary">
335
+ <div className="flex items-center gap-[var(--spacing-system-xsf)]">
336
+ <span className="text-heading-5 text-ods-text-secondary">
355
337
  {formatFileSize(entry.fileSize)}
356
338
  </span>
357
339
  {entry.status === "uploading" && (
358
- <span className="flex items-center gap-1 text-heading-6 text-ods-text-secondary">
340
+ <span className="flex items-center gap-[var(--spacing-system-xxs)] text-heading-5 text-ods-text-secondary">
359
341
  <Loader2 className="size-3 animate-spin" />
360
342
  Uploading...
361
343
  </span>
362
344
  )}
363
345
  {entry.status === "error" && (
364
- <span className="text-heading-6 text-[var(--ods-attention-red-error)]">
346
+ <span className="text-heading-5 text-ods-error">
365
347
  {entry.error || "Upload failed"}
366
348
  </span>
367
349
  )}
368
350
  </div>
369
351
  </div>
352
+ {onDownloadManagedFile && entry.status === "uploaded" && (
353
+ <button
354
+ type="button"
355
+ onClick={() => onDownloadManagedFile(entry.id)}
356
+ className="shrink-0 text-ods-text-secondary hover:text-ods-text-primary transition-colors"
357
+ aria-label={`Download ${entry.fileName}`}
358
+ >
359
+ <Download02Icon className="size-6" />
360
+ </button>
361
+ )}
370
362
  {!disabled && (
371
363
  <button
372
364
  type="button"
373
365
  onClick={() => onRemoveManagedFile?.(entry.id)}
374
- className="shrink-0 p-1 rounded hover:bg-ods-bg transition-colors"
366
+ className="shrink-0 text-ods-error hover:opacity-80 transition-opacity"
375
367
  aria-label={`Remove ${entry.fileName}`}
376
368
  >
377
- <X className="size-4 text-ods-text-secondary" />
369
+ <TrashIcon className="size-6" />
378
370
  </button>
379
371
  )}
380
372
  </div>
@@ -382,13 +374,16 @@ export function FileUpload({
382
374
  : files.map((file, index) => (
383
375
  <div
384
376
  key={`${file.name}-${index}`}
385
- className="flex items-center gap-3 p-3 rounded-[6px] bg-ods-card border border-ods-border"
377
+ className="flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-sf)] border-b border-ods-border last:border-b-0"
386
378
  >
379
+ <div className="flex items-center justify-center size-10 rounded-[6px] border border-ods-border bg-ods-card shrink-0">
380
+ <FileIcon className="size-6 text-ods-text-secondary" />
381
+ </div>
387
382
  <div className="flex-1 min-w-0">
388
- <p className="text-heading-5 font-medium text-ods-text-primary truncate" title={file.name}>
383
+ <p className="text-heading-4 font-medium text-ods-text-primary truncate" title={file.name}>
389
384
  {file.name}
390
385
  </p>
391
- <p className="text-heading-6 text-ods-text-secondary">
386
+ <p className="text-heading-5 text-ods-text-secondary">
392
387
  {formatFileSize(file.size)}
393
388
  </p>
394
389
  </div>
@@ -396,43 +391,46 @@ export function FileUpload({
396
391
  <button
397
392
  type="button"
398
393
  onClick={() => handleRemoveFile(index)}
399
- className="shrink-0 p-1 rounded hover:bg-ods-bg transition-colors"
394
+ className="shrink-0 text-ods-error hover:opacity-80 transition-opacity"
400
395
  aria-label={`Remove ${file.name}`}
401
396
  >
402
- <X className="size-4 text-ods-text-secondary" />
397
+ <TrashIcon className="size-6" />
403
398
  </button>
404
399
  )}
405
400
  </div>
406
401
  ))
407
402
  }
408
403
  </div>
404
+ )}
409
405
 
410
- {/* Add more button */}
411
- {canAddMore && (
412
- <button
413
- type="button"
414
- onClick={openDialog}
415
- onDragEnter={handleDrag}
416
- onDragLeave={handleDrag}
417
- onDragOver={handleDrag}
418
- onDrop={handleDrop}
419
- disabled={disabled}
420
- className={cn(
421
- "flex items-center justify-center gap-2 p-3 rounded-[6px]",
422
- "border border-dashed",
423
- "text-heading-5 font-medium",
424
- "transition-colors duration-200",
425
- dragActive
426
- ? "border-ods-accent bg-ods-accent/5 text-ods-text-primary"
427
- : "border-ods-border text-ods-text-secondary hover:border-ods-accent/30 hover:text-ods-text-primary",
428
- disabled && "opacity-50 cursor-not-allowed",
429
- )}
430
- >
431
- Add more files
432
- </button>
406
+ {showDropzone && (
407
+ <button
408
+ type="button"
409
+ onDragEnter={handleDrag}
410
+ onDragLeave={handleDrag}
411
+ onDragOver={handleDrag}
412
+ onDrop={handleDrop}
413
+ onClick={openDialog}
414
+ disabled={disabled}
415
+ className={cn(
416
+ "flex w-full items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-sf)] rounded-[6px] border border-dashed cursor-pointer text-left",
417
+ "transition-colors duration-200",
418
+ "bg-ods-card border-ods-border",
419
+ dragActive && "border-ods-accent bg-ods-accent/5",
420
+ !dragActive && "hover:border-ods-accent/30",
421
+ disabled && "opacity-50 cursor-not-allowed hover:border-ods-border",
433
422
  )}
434
- </div>
435
- )}
423
+ >
424
+ <div className="flex items-center p-[var(--spacing-system-sf)] rounded-full bg-ods-card border border-ods-border shrink-0">
425
+ {icon || <ImagePlusIcon className="size-6 text-ods-text-primary" />}
426
+ </div>
427
+ <div className="flex flex-1 flex-col font-['DM_Sans'] font-medium min-w-0">
428
+ <span className="text-heading-4 text-ods-text-primary">{label}</span>
429
+ <span className="text-heading-5 text-ods-text-secondary">{description}</span>
430
+ </div>
431
+ </button>
432
+ )}
433
+ </div>
436
434
  </FieldWrapper>
437
435
  )
438
436
  }
@@ -41,6 +41,11 @@ export interface ImageUploaderProps {
41
41
  alt?: string
42
42
  /** Extra className on the FieldWrapper root. */
43
43
  className?: string
44
+ /**
45
+ * Extra className on the dropzone element itself. Merged after the defaults, so it can
46
+ * override the built-in sizing (e.g. pass `h-[148px]` to replace the default `h-44`).
47
+ */
48
+ dropzoneClassName?: string
44
49
  }
45
50
 
46
51
  const DEFAULT_MAX_SIZE = 25 * 1024 * 1024
@@ -85,6 +90,7 @@ export function ImageUploader({
85
90
  objectFit = "cover",
86
91
  alt = "Uploaded image",
87
92
  className,
93
+ dropzoneClassName,
88
94
  }: ImageUploaderProps) {
89
95
  const inputRef = React.useRef<HTMLInputElement>(null)
90
96
  const [dragActive, setDragActive] = React.useState(false)
@@ -183,6 +189,7 @@ export function ImageUploader({
183
189
  !hasImage && interactive && !isActionState && "cursor-pointer hover:bg-ods-bg-hover hover:border-ods-border-hover",
184
190
  !interactive && "opacity-60",
185
191
  !hasImage && !interactive && "cursor-not-allowed",
192
+ dropzoneClassName,
186
193
  )}
187
194
  >
188
195
 
@@ -26,6 +26,7 @@ export * from './progress-bar'
26
26
  export * from './radio-group'
27
27
  export * from './select'
28
28
  export * from './switch'
29
+ export * from './tag-select-dropdown'
29
30
  export * from './tags-input'
30
31
  export * from './tags-manager'
31
32
  export * from './textarea'
@@ -0,0 +1,220 @@
1
+ 'use client'
2
+
3
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
4
+ import * as React from 'react'
5
+ import { cn } from '../../utils/cn'
6
+ import { CheckIcon, PlusIcon, SearchIcon, TrashIcon } from '../icons-v2-generated'
7
+ import { Button } from './button'
8
+ import { Input } from './input'
9
+
10
+ export interface TagSelectOption {
11
+ id: string
12
+ label: string
13
+ }
14
+
15
+ export interface TagSelectDropdownProps {
16
+ /** All selectable tags. */
17
+ options: TagSelectOption[]
18
+ /** IDs of the currently selected tags. */
19
+ selectedIds: string[]
20
+ /** Called with the next full selection whenever a tag is toggled. */
21
+ onChange: (ids: string[]) => void
22
+ /** Called when the inline "Create" row is chosen. Omit to disable creation. */
23
+ onCreate?: (name: string) => void
24
+ /** Max length for a newly created tag name. Omit for no limit. */
25
+ maxCreateLength?: number
26
+ /** Called when a row's hover trash button is clicked. Omit to hide delete. */
27
+ onDelete?: (id: string) => void
28
+ isCreating?: boolean
29
+ isDeleting?: boolean
30
+ /** Trigger button label. */
31
+ triggerLabel?: string
32
+ searchPlaceholder?: string
33
+ disabled?: boolean
34
+ align?: 'start' | 'center' | 'end'
35
+ /** Forwarded to the trigger button. */
36
+ className?: string
37
+ }
38
+
39
+ /**
40
+ * Multi-select tag picker rendered as a button-triggered dropdown (the
41
+ * multi-select sibling of the compact `AssigneeDropdown`): a trigger opens a
42
+ * popover with an internal search field, a checkable option list, and an
43
+ * inline "Create" row. Selected chips are rendered by the caller.
44
+ */
45
+ export function TagSelectDropdown({
46
+ options,
47
+ selectedIds,
48
+ onChange,
49
+ onCreate,
50
+ maxCreateLength,
51
+ onDelete,
52
+ isCreating = false,
53
+ isDeleting = false,
54
+ triggerLabel = 'Add Tags',
55
+ searchPlaceholder = 'Search or create tags...',
56
+ disabled = false,
57
+ align = 'start',
58
+ className,
59
+ }: TagSelectDropdownProps) {
60
+ const [isOpen, setIsOpen] = React.useState(false)
61
+ const [search, setSearch] = React.useState('')
62
+
63
+ React.useEffect(() => {
64
+ if (!isOpen) setSearch('')
65
+ }, [isOpen])
66
+
67
+ const query = search.trim()
68
+ const filtered = React.useMemo(() => {
69
+ const q = query.toLowerCase()
70
+ if (!q) return options
71
+ return options.filter(o => o.label.toLowerCase().includes(q))
72
+ }, [options, query])
73
+
74
+ // Selected tags float to the top of the list.
75
+ const ordered = React.useMemo(() => {
76
+ const selected = filtered.filter(o => selectedIds.includes(o.id))
77
+ const rest = filtered.filter(o => !selectedIds.includes(o.id))
78
+ return [...selected, ...rest]
79
+ }, [filtered, selectedIds])
80
+
81
+ const showCreate = !!onCreate && !!query && !options.some(o => o.label.toLowerCase() === query.toLowerCase())
82
+ const isCreateTooLong = maxCreateLength != null && query.length > maxCreateLength
83
+ const canCreate = showCreate && !isCreateTooLong
84
+
85
+ const toggle = (id: string) => {
86
+ onChange(selectedIds.includes(id) ? selectedIds.filter(i => i !== id) : [...selectedIds, id])
87
+ }
88
+
89
+ const handleCreate = () => {
90
+ if (!onCreate || !canCreate) return
91
+ onCreate(query)
92
+ setSearch('')
93
+ }
94
+
95
+ return (
96
+ <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen} modal={false}>
97
+ <PopoverPrimitive.Trigger asChild>
98
+ <Button
99
+ type="button"
100
+ variant="outline"
101
+ size="small"
102
+ leftIcon={<PlusIcon />}
103
+ disabled={disabled}
104
+ className={cn('w-fit focus-visible:ring-0', className)}
105
+ >
106
+ {triggerLabel}
107
+ </Button>
108
+ </PopoverPrimitive.Trigger>
109
+ <PopoverPrimitive.Portal>
110
+ <PopoverPrimitive.Content
111
+ align={align}
112
+ sideOffset={6}
113
+ className={cn(
114
+ 'z-50 w-72 bg-ods-card border border-ods-border rounded-[6px] shadow-lg overflow-hidden',
115
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
116
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
117
+ 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
118
+ 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
119
+ )}
120
+ >
121
+ <div className="border-b border-ods-border">
122
+ <Input
123
+ autoFocus
124
+ value={search}
125
+ onChange={e => setSearch(e.target.value)}
126
+ onKeyDown={e => {
127
+ if (e.key === 'Enter' && canCreate) {
128
+ e.preventDefault()
129
+ handleCreate()
130
+ }
131
+ }}
132
+ placeholder={searchPlaceholder}
133
+ startAdornment={<SearchIcon className="size-4 text-ods-text-secondary" />}
134
+ className="rounded-none border-0"
135
+ />
136
+ </div>
137
+ <div className="max-h-72 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
138
+ {ordered.map(opt => {
139
+ const isSelected = selectedIds.includes(opt.id)
140
+ return (
141
+ <div
142
+ key={opt.id}
143
+ role="option"
144
+ aria-selected={isSelected}
145
+ tabIndex={0}
146
+ onClick={() => toggle(opt.id)}
147
+ onKeyDown={e => {
148
+ if (e.key === 'Enter' || e.key === ' ') {
149
+ e.preventDefault()
150
+ toggle(opt.id)
151
+ }
152
+ }}
153
+ className={cn(
154
+ 'group/item flex items-center gap-[var(--spacing-system-xs)] w-full cursor-pointer text-left',
155
+ 'px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)]',
156
+ 'hover:bg-ods-bg-hover transition-colors',
157
+ isSelected && 'bg-ods-bg-hover',
158
+ )}
159
+ >
160
+ <span
161
+ className={cn('flex-1 truncate text-h4', isSelected ? 'text-ods-accent' : 'text-ods-text-primary')}
162
+ title={opt.label}
163
+ >
164
+ {opt.label}
165
+ </span>
166
+ {isSelected && <CheckIcon className="size-4 shrink-0 text-ods-accent" />}
167
+ {onDelete && !isSelected && (
168
+ <button
169
+ type="button"
170
+ aria-label={`Delete tag ${opt.label}`}
171
+ onClick={e => {
172
+ e.stopPropagation()
173
+ onDelete(opt.id)
174
+ }}
175
+ disabled={isDeleting}
176
+ className={cn(
177
+ 'shrink-0 opacity-0 transition-opacity group-hover/item:opacity-100 focus-visible:opacity-100',
178
+ 'disabled:opacity-50 disabled:pointer-events-none',
179
+ )}
180
+ >
181
+ <TrashIcon className="size-4 text-ods-error" />
182
+ </button>
183
+ )}
184
+ </div>
185
+ )
186
+ })}
187
+
188
+ {showCreate &&
189
+ (isCreateTooLong ? (
190
+ <div className="flex items-center gap-[var(--spacing-system-xs)] w-full px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-h5 text-ods-error">
191
+ Maximum {maxCreateLength} characters
192
+ </div>
193
+ ) : (
194
+ <button
195
+ type="button"
196
+ onClick={handleCreate}
197
+ disabled={isCreating}
198
+ className={cn(
199
+ 'flex items-center gap-[var(--spacing-system-xs)] w-full px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
200
+ 'hover:bg-ods-bg-hover transition-colors disabled:opacity-50 disabled:pointer-events-none',
201
+ )}
202
+ >
203
+ <PlusIcon className="size-4 shrink-0 text-ods-accent" />
204
+ <span className="flex-1 truncate text-h4 text-ods-accent" title={`Create "${query}"`}>
205
+ Create &ldquo;{query}&rdquo;
206
+ </span>
207
+ </button>
208
+ ))}
209
+
210
+ {filtered.length === 0 && !showCreate && (
211
+ <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
212
+ No tags found
213
+ </div>
214
+ )}
215
+ </div>
216
+ </PopoverPrimitive.Content>
217
+ </PopoverPrimitive.Portal>
218
+ </PopoverPrimitive.Root>
219
+ )
220
+ }