@flamingo-stack/openframe-frontend-core 0.0.732 → 0.0.734

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 (78) hide show
  1. package/dist/{chunk-YGKVHSNQ.cjs → chunk-2OHBH6PS.cjs} +42 -42
  2. package/dist/{chunk-MEDDKM3A.cjs → chunk-3HPB4H4C.cjs} +2 -2
  3. package/dist/{chunk-BS4EZZCN.cjs → chunk-3NANWZO3.cjs} +15 -15
  4. package/dist/{chunk-5HMGK3N6.cjs → chunk-4Q34V7MD.cjs} +20 -20
  5. package/dist/{chunk-22ZK7BA3.cjs → chunk-4V672TEI.cjs} +1127 -1072
  6. package/dist/{chunk-OI7VCXYU.cjs → chunk-5DDFSGCE.cjs} +40 -40
  7. package/dist/{chunk-CUF3IYIB.cjs → chunk-5OGT7H2S.cjs} +28 -28
  8. package/dist/{chunk-C5NERNE6.cjs → chunk-73ZW3265.cjs} +93 -93
  9. package/dist/{chunk-GID62YEN.cjs → chunk-AAFW4STC.cjs} +3 -3
  10. package/dist/{chunk-MVZ6XFPK.cjs → chunk-AOTOOGNX.cjs} +8 -8
  11. package/dist/{chunk-SJRR745G.cjs → chunk-BP6R5WDW.cjs} +44 -44
  12. package/dist/{chunk-OJ3XU3YZ.cjs → chunk-CMUEFHM3.cjs} +4 -4
  13. package/dist/{chunk-3BLBWZ6A.cjs → chunk-D34BLEIR.cjs} +7 -7
  14. package/dist/{chunk-XOQ2UUVS.cjs → chunk-DHIISS4W.cjs} +50 -40
  15. package/dist/{chunk-RIXGLS5Y.cjs → chunk-DUVOQHQI.cjs} +4 -4
  16. package/dist/{chunk-E3N24573.cjs → chunk-DUXLZPMJ.cjs} +9 -9
  17. package/dist/{chunk-6KDGIOLP.cjs → chunk-EMBH2EJO.cjs} +89 -89
  18. package/dist/{chunk-N466AJGN.cjs → chunk-FA5SMA7L.cjs} +17 -17
  19. package/dist/{chunk-AEVGCB3Y.cjs → chunk-FCGLSWKR.cjs} +4 -4
  20. package/dist/{chunk-MANNIJ42.cjs → chunk-FICJS5OA.cjs} +6 -0
  21. package/dist/{chunk-KLXHCNDC.cjs → chunk-FTTLBMHU.cjs} +3 -3
  22. package/dist/{chunk-7DUXR4HL.cjs → chunk-GVTDVS5U.cjs} +3 -3
  23. package/dist/{chunk-ZWMPAFGB.cjs → chunk-HAWQA5FI.cjs} +236 -236
  24. package/dist/{chunk-BDTX2OEE.cjs → chunk-JRC3KXKV.cjs} +32 -32
  25. package/dist/{chunk-PRWBVIWW.cjs → chunk-KSXZABGH.cjs} +59 -59
  26. package/dist/{chunk-G37LT2ID.cjs → chunk-L4ZNR5V7.cjs} +38 -38
  27. package/dist/{chunk-I46MQ4R5.cjs → chunk-L7WJBJU7.cjs} +73 -73
  28. package/dist/{chunk-NYY25425.cjs → chunk-MYBTNOS6.cjs} +2 -2
  29. package/dist/{chunk-HELVKMRG.cjs → chunk-NTJE6SM6.cjs} +2 -2
  30. package/dist/{chunk-5HSMTSHU.cjs → chunk-OV7QQHLG.cjs} +24 -24
  31. package/dist/{chunk-6HHUU56Y.cjs → chunk-R65UD6BH.cjs} +14 -14
  32. package/dist/{chunk-CC3LINHF.cjs → chunk-SA7LDEVJ.cjs} +3 -3
  33. package/dist/{chunk-TLDCSTTQ.cjs → chunk-TLQQP2EL.cjs} +10 -10
  34. package/dist/{chunk-QMTC3BK3.cjs → chunk-ZJNYWNCB.cjs} +21 -21
  35. package/dist/{chunk-HH4BMYDX.cjs → chunk-ZRYKDN2J.cjs} +19 -19
  36. package/dist/{cloud-tenants-ILRE74JY.cjs → cloud-tenants-HU7YZD33.cjs} +21 -21
  37. package/dist/components/case-studies/index.cjs +34 -34
  38. package/dist/components/chat/embeddable-chat.d.ts +16 -0
  39. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  40. package/dist/components/chat/embeddable-chat.js +5 -2
  41. package/dist/components/chat/index.cjs +15 -15
  42. package/dist/components/chat/utils/icon-library.d.ts.map +1 -1
  43. package/dist/components/chat/utils/icon-library.js +6 -0
  44. package/dist/components/contact/index.cjs +22 -22
  45. package/dist/components/docs/index.cjs +25 -25
  46. package/dist/components/embeds/index.cjs +22 -22
  47. package/dist/components/faq/index.cjs +22 -22
  48. package/dist/components/features/index.cjs +21 -21
  49. package/dist/components/help-center-pages/index.cjs +137 -137
  50. package/dist/components/index.cjs +76 -76
  51. package/dist/components/layout/page-heading.d.ts +9 -1
  52. package/dist/components/layout/page-heading.d.ts.map +1 -1
  53. package/dist/components/layout/page-heading.js +15 -5
  54. package/dist/components/meeting-scheduler/index.cjs +63 -63
  55. package/dist/components/navigation/index.cjs +24 -24
  56. package/dist/components/onboarding-guides/index.cjs +24 -24
  57. package/dist/components/product-demo/index.cjs +8 -8
  58. package/dist/components/related-content/index.cjs +9 -9
  59. package/dist/components/tickets/index.cjs +25 -25
  60. package/dist/components/ui/index.cjs +21 -21
  61. package/dist/components/ui/searchable-select.d.ts +29 -3
  62. package/dist/components/ui/searchable-select.d.ts.map +1 -1
  63. package/dist/components/ui/searchable-select.js +101 -45
  64. package/dist/{devices-A6SFNGE4.cjs → devices-CKLN7BHY.cjs} +22 -22
  65. package/dist/{embeddable-chat-BKPYNOIM.cjs → embeddable-chat-FTAX4SVV.cjs} +95 -92
  66. package/dist/hooks/index.cjs +8 -8
  67. package/dist/index.cjs +27 -27
  68. package/dist/{knowledge-5ZCVKUYV.cjs → knowledge-NRLHSCC5.cjs} +22 -22
  69. package/dist/{logs-CL4OJASB.cjs → logs-BR6SLYAF.cjs} +29 -29
  70. package/dist/{policies-7D44ZVS5.cjs → policies-MQRXBYG2.cjs} +25 -25
  71. package/dist/{remote-session-QB3CIZRM.cjs → remote-session-Z4YZFG5E.cjs} +9 -9
  72. package/dist/{software-update-AX6TZL26.cjs → software-update-RUGIQP7N.cjs} +25 -25
  73. package/dist/{tickets-board-7FAV2YWF.cjs → tickets-board-JOSYLOS7.cjs} +12 -12
  74. package/package.json +1 -1
  75. package/src/components/chat/embeddable-chat.tsx +29 -2
  76. package/src/components/chat/utils/icon-library.tsx +7 -0
  77. package/src/components/layout/page-heading.tsx +37 -7
  78. package/src/components/ui/searchable-select.tsx +124 -32
@@ -481,6 +481,22 @@ export interface EmbeddableChatProps {
481
481
  items: ChatContextItem[];
482
482
  onRemove?: (item: ChatContextItem) => void;
483
483
  };
484
+ /**
485
+ * Host content pinned between the thread and the composer: the thread
486
+ * scrolls above it, the composer stays below, and it keeps its place while
487
+ * the thread grows (e.g. a guided tour the host attaches to one
488
+ * conversation, deciding by `mingoState.activeDialogId`). Rendered wherever
489
+ * the composer is - a conversation or the new-chat surface - and never on
490
+ * the chat list. Sized by its content: a tall block is the host's to cap or
491
+ * scroll. `null`/unset leaves the layout as it was.
492
+ */
493
+ aboveComposer?: React.ReactNode;
494
+ /**
495
+ * v2: what the list's "What to ask Mingo?" opens. Default: a new chat, the
496
+ * same as "Start New Chat"; a host with its own suggestions surface (a
497
+ * guided tour in a conversation of its own) points it there instead.
498
+ */
499
+ onWhatToAsk?: () => void;
484
500
  }
485
501
 
486
502
  // =============================================================================
@@ -1045,6 +1061,8 @@ function EmbeddableChatInner({
1045
1061
  guidePendingPrompt,
1046
1062
  onGuidePromptConsumed,
1047
1063
  contextMemory,
1064
+ aboveComposer,
1065
+ onWhatToAsk,
1048
1066
  handleRef,
1049
1067
  }: EmbeddableChatProps & { handleRef?: React.Ref<EmbeddableChatHandle> }) {
1050
1068
  // `shell` 'none' / 'inline' means the consumer hosts us inside their own panel
@@ -2397,8 +2415,8 @@ function EmbeddableChatInner({
2397
2415
  onNewChat: leaveArchiveThen(handleNewChat),
2398
2416
  onOpenArchive: headerOnOpenArchive && (archiveOpen ? closeArchive : headerOnOpenArchive),
2399
2417
  archiveActive: archiveOpen,
2400
- // The suggestions a new chat opens on.
2401
- onWhatToAsk: leaveArchiveThen(handleNewChat),
2418
+ // The suggestions a new chat opens on, unless the host has a surface of its own.
2419
+ onWhatToAsk: leaveArchiveThen(onWhatToAsk ?? handleNewChat),
2402
2420
  statusOf: dialogStatusOf,
2403
2421
  onRequestRename: mingoCaps.canRename ? setRenameTarget : undefined,
2404
2422
  onRequestArchive: mingoCaps.canArchive ? setArchiveTarget : undefined,
@@ -2987,6 +3005,15 @@ function EmbeddableChatInner({
2987
3005
  </div>
2988
3006
  </div>
2989
3007
 
3008
+ {/* Host block pinned above the composer (`aboveComposer`):
3009
+ the thread's own bottom padding separates them, the
3010
+ composer's horizontal inset is repeated so the two line up. */}
3011
+ {aboveComposer != null && aboveComposer !== false && (
3012
+ <div className="shrink-0 px-[var(--spacing-system-m)] pb-[var(--spacing-system-m)]">
3013
+ {aboveComposer}
3014
+ </div>
3015
+ )}
3016
+
2990
3017
  <ChatAttachmentChipStrip
2991
3018
  attachments={stagedAttachments}
2992
3019
  onRemove={removeAttachment}
@@ -90,6 +90,13 @@ export const ICON_ALIASES: Readonly<Record<string, IconRef>> = {
90
90
  video: { set: 'VideoRecorderIcon' },
91
91
  headphones: { set: 'HeadphoneIcon' },
92
92
 
93
+ // The two LLM brands offered by `ICON_OPTIONS`. Their brand-colored glyphs
94
+ // are the only picker entries that hardcode a fill (#000 / #D97757), so they
95
+ // ignored the chip accent and sat black and orange in a cyan grid; the grey
96
+ // variants fill with `currentColor` like every other brand logo here.
97
+ 'openai-logo': { set: 'OpenaiLogoGreyIcon' },
98
+ 'anthropic-logo': { set: 'AnthropicLogoGreyIcon' },
99
+
93
100
  // Figma-canonical aliases (node 7363:205938) — kept for Storybook
94
101
  // fixtures and any older feed entries still using the long names.
95
102
  'clickup-logo': CLICKUP_GREY,
@@ -154,7 +154,15 @@ const SECTION_HEADING_LAYOUT = {
154
154
  } as const;
155
155
 
156
156
  export interface SectionHeadingProps {
157
- /** The small label above the heading (Azeret Mono, uppercase, accent colour). */
157
+ /**
158
+ * The small label above the heading (Azeret Mono, uppercase, accent colour).
159
+ * On a page heading (`level="page"`) it is part of the `<h1>`: a hero's
160
+ * eyebrow names what the page is about ("Endpoint management software") above
161
+ * its positioning line, so the page's heading, and its accessible name, say
162
+ * both. The element takes no box of its own, so the eyebrow and the title keep
163
+ * their two places in the stack. On a section heading it stays a label beside
164
+ * the `<h2>`, whose accessible name is its title alone.
165
+ */
158
166
  eyebrow?: ReactNode;
159
167
  /** The heading text; a node when part of it is highlighted. Every sentence mark in it is drawn in the accent colour. */
160
168
  title: ReactNode;
@@ -204,14 +212,36 @@ export function SectionHeading({
204
212
  const accentClassName = headingAccentClass(platform, tone);
205
213
  const layout = SECTION_HEADING_LAYOUT[level];
206
214
  const Tag = presentational ? 'div' : level === 'page' ? 'h1' : 'h2';
215
+ const eyebrowNode = eyebrow ? <span className={`text-h5 ${accentClassName}`}>{eyebrow}</span> : null;
216
+ const headingText = (
217
+ <>
218
+ {accentSentenceMarks(title, platform, tone)}
219
+ {punctuation ? <span className={accentClassName}>{punctuation}</span> : null}
220
+ </>
221
+ );
207
222
  const stack = (
208
223
  <div className={layout.stack}>
209
- {eyebrow ? <span className={`text-h5 ${accentClassName}`}>{eyebrow}</span> : null}
210
- <Tag className={layout.heading} data-section-heading>
211
- {accentSentenceMarks(title, platform, tone)}
212
- {punctuation ? <span className={accentClassName}>{punctuation}</span> : null}
213
- </Tag>
214
- {intro ? <div className={layout.intro}>{intro}</div> : null}
224
+ {eyebrowNode && level === 'page' ? (
225
+ // `contents`: the element names the heading and draws no box, so the stack lays its two children out as before.
226
+ <Tag className="contents">
227
+ {eyebrowNode}
228
+ <span className={`block ${layout.heading}`} data-section-heading>
229
+ {headingText}
230
+ </span>
231
+ </Tag>
232
+ ) : (
233
+ <>
234
+ {eyebrowNode}
235
+ <Tag className={layout.heading} data-section-heading>
236
+ {headingText}
237
+ </Tag>
238
+ </>
239
+ )}
240
+ {intro ? (
241
+ <div className={layout.intro} data-section-intro>
242
+ {intro}
243
+ </div>
244
+ ) : null}
215
245
  </div>
216
246
  );
217
247
  if (!action) return className ? <div className={className}>{stack}</div> : stack;
@@ -1,20 +1,28 @@
1
1
  'use client';
2
2
 
3
3
  import * as PopoverPrimitive from '@radix-ui/react-popover';
4
- import { type ReactNode, useMemo, useState } from 'react';
4
+ import { type ReactNode, useId, useMemo, useState } from 'react';
5
5
  import { useKeyboardCollisionPadding } from '../../hooks/ui/use-keyboard-collision-padding';
6
6
  import { cn } from '../../utils/cn';
7
7
  import { CheckIcon, SearchIcon } from '../icons-v2-generated';
8
8
  import { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon';
9
+ import { FieldWrapper } from './field-wrapper';
9
10
  import { Input } from './input';
10
11
 
11
12
  export interface SearchableSelectOption {
12
13
  value: string;
13
14
  label: string;
14
- /** Optional leading visual for the option row (avatar, icon, badge). */
15
+ /** Optional leading visual for the option row (avatar, icon, badge). The
16
+ * default trigger leads with the selected option's icon too. */
15
17
  icon?: ReactNode;
18
+ /** Extra terms the search matches besides `label` — e.g. the stored key of a
19
+ * glyph (`chart-pie`) whose label reads "Pie chart". */
20
+ keywords?: string[];
16
21
  }
17
22
 
23
+ /** How the dropdown lays its options out — see `SearchableSelectProps.layout`. */
24
+ export type SearchableSelectLayout = 'list' | 'grid';
25
+
18
26
  export interface SearchableSelectProps {
19
27
  options: SearchableSelectOption[];
20
28
  /** Currently selected option value; null/undefined when nothing is selected. */
@@ -34,7 +42,27 @@ export interface SearchableSelectProps {
34
42
  * is rendered.
35
43
  */
36
44
  trigger?: ReactNode;
37
- /** Custom option-row renderer; default is icon + label + selected check. */
45
+ /** `id` of the default trigger button, which `label` names through `htmlFor`; generated when omitted. */
46
+ id?: string;
47
+ /**
48
+ * Label above the default trigger — the same `FieldWrapper` treatment
49
+ * `SelectTrigger` gives its `label`, so a searchable field lines up with the
50
+ * plain selects beside it. A custom `trigger` owns its own label.
51
+ */
52
+ label?: string;
53
+ /** Label scale forwarded to FieldWrapper ('large' = text-h4 for designs with body-scale field titles). */
54
+ labelVariant?: 'default' | 'large';
55
+ /** Validation message under the default trigger; also paints its border red. */
56
+ error?: string;
57
+ /**
58
+ * `'list'` (default) stacks full-width rows. `'grid'` tiles the options —
59
+ * icon above a centered caption, `minmax(7rem, 1fr)` columns — the
60
+ * icon-picker pattern. The grid fills the popover, so size it through
61
+ * `contentClassName` (360px fits three tiles).
62
+ */
63
+ layout?: SearchableSelectLayout;
64
+ /** Custom option-row renderer; default is icon + label + selected check
65
+ * (icon above the caption in the grid layout). */
38
66
  renderOption?: (option: SearchableSelectOption, isSelected: boolean) => ReactNode;
39
67
  align?: 'start' | 'center' | 'end';
40
68
  /** Classes for the default trigger button. */
@@ -50,22 +78,37 @@ export interface SearchableSelectProps {
50
78
  * custom `trigger` for icon-button use cases; the default trigger looks and
51
79
  * behaves like `SelectTrigger`.
52
80
  */
53
- export function SearchableSelect({
54
- options,
55
- value = null,
56
- onValueChange,
57
- placeholder = 'Select...',
58
- searchPlaceholder = 'Search...',
59
- emptyText = 'No results found',
60
- loadingText = 'Loading…',
61
- isLoading = false,
62
- disabled = false,
63
- trigger,
64
- renderOption,
65
- align = 'start',
66
- className,
67
- contentClassName,
68
- }: SearchableSelectProps) {
81
+ export function SearchableSelect(allProps: SearchableSelectProps) {
82
+ const {
83
+ options,
84
+ value = null,
85
+ onValueChange,
86
+ placeholder = 'Select...',
87
+ searchPlaceholder = 'Search...',
88
+ emptyText = 'No results found',
89
+ loadingText = 'Loading…',
90
+ isLoading = false,
91
+ disabled = false,
92
+ trigger,
93
+ id,
94
+ label,
95
+ labelVariant,
96
+ error,
97
+ layout = 'list',
98
+ renderOption,
99
+ align = 'start',
100
+ className,
101
+ contentClassName,
102
+ } = allProps;
103
+ // Wired for a message even while `error` is undefined — see `FieldWrapper.errorSlot`.
104
+ const errorSlot = 'error' in allProps;
105
+ const isInvalid = !!error;
106
+ const isGrid = layout === 'grid';
107
+ // The label names the trigger through `htmlFor`: clicking it focuses the
108
+ // button and assistive tech reads the field by it. A caller's `id` wins.
109
+ const generatedId = useId();
110
+ const triggerId = id ?? generatedId;
111
+
69
112
  const [isOpen, setIsOpen] = useState(false);
70
113
  const [search, setSearch] = useState('');
71
114
  const keyboardPadding = useKeyboardCollisionPadding();
@@ -79,7 +122,9 @@ export function SearchableSelect({
79
122
  const filtered = useMemo(() => {
80
123
  const q = search.trim().toLowerCase();
81
124
  if (!q) return options;
82
- return options.filter(o => o.label.toLowerCase().includes(q));
125
+ return options.filter(
126
+ o => o.label.toLowerCase().includes(q) || (o.keywords ?? []).some(k => k.toLowerCase().includes(q)),
127
+ );
83
128
  }, [options, search]);
84
129
 
85
130
  const selectedOption = useMemo(
@@ -94,8 +139,10 @@ export function SearchableSelect({
94
139
 
95
140
  const defaultTrigger = (
96
141
  <button
142
+ id={triggerId}
97
143
  type="button"
98
144
  disabled={disabled}
145
+ data-invalid={isInvalid || undefined}
99
146
  className={cn(
100
147
  // Mirrors SelectTrigger so the closed state is indistinguishable from
101
148
  // the regular Select.
@@ -103,22 +150,45 @@ export function SearchableSelect({
103
150
  'border-ods-border bg-ods-card text-h4',
104
151
  selectedOption ? 'text-ods-text-primary' : 'text-ods-text-secondary',
105
152
  'enabled:hover:border-ods-border-hover enabled:hover:bg-ods-bg-hover enabled:active:border-ods-border-active enabled:active:bg-ods-bg-active',
106
- 'data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent',
153
+ !isInvalid && 'data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent',
107
154
  'group cursor-pointer transition-colors duration-200 disabled:!cursor-not-allowed disabled:bg-ods-bg',
155
+ isInvalid &&
156
+ 'border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error',
108
157
  className,
109
158
  )}
110
159
  >
111
- <span className="line-clamp-1">{selectedOption ? selectedOption.label : placeholder}</span>
160
+ <span className="flex min-w-0 flex-1 items-center gap-2">
161
+ {selectedOption?.icon}
162
+ <span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span>
163
+ </span>
112
164
  <Chevron02DownIcon
113
- className="shrink-0 text-ods-text-secondary transition-all duration-200 group-data-[state=open]:rotate-180 group-data-[state=open]:text-ods-accent"
165
+ className={cn(
166
+ 'shrink-0 text-ods-text-secondary transition-all duration-200 group-data-[state=open]:rotate-180',
167
+ isInvalid ? 'text-ods-error' : 'group-data-[state=open]:text-ods-accent',
168
+ )}
114
169
  size={24}
115
170
  />
116
171
  </button>
117
172
  );
118
173
 
174
+ const triggerElement = <PopoverPrimitive.Trigger asChild>{trigger ?? defaultTrigger}</PopoverPrimitive.Trigger>;
175
+
176
+ // A `role="listbox"` child that is a status line, not an option: in the grid
177
+ // it has to span the row or it would sit in one tile-sized cell.
178
+ const statusClass = cn(
179
+ 'px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5',
180
+ isGrid && 'col-span-full',
181
+ );
182
+
119
183
  return (
120
184
  <PopoverPrimitive.Root open={isOpen} onOpenChange={disabled ? undefined : setIsOpen} modal={false}>
121
- <PopoverPrimitive.Trigger asChild>{trigger ?? defaultTrigger}</PopoverPrimitive.Trigger>
185
+ {!trigger && (label !== undefined || errorSlot) ? (
186
+ <FieldWrapper label={label} htmlFor={triggerId} labelVariant={labelVariant} error={error} errorSlot={errorSlot}>
187
+ {triggerElement}
188
+ </FieldWrapper>
189
+ ) : (
190
+ triggerElement
191
+ )}
122
192
  <PopoverPrimitive.Portal>
123
193
  <PopoverPrimitive.Content
124
194
  align={align}
@@ -147,15 +217,22 @@ export function SearchableSelect({
147
217
  className="rounded-none border-0"
148
218
  />
149
219
  </div>
150
- <div className="max-h-80 min-h-0 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
220
+ <div
221
+ className={cn(
222
+ 'max-h-80 min-h-0 overflow-y-auto',
223
+ // Tiles grow from 7rem: the popover's own border and a classic
224
+ // always-on scrollbar both eat into the track, so 7.5rem (the
225
+ // Figma cell) dropped a 360px popover to two columns. The column
226
+ // count follows the popover width. Each tile draws its own
227
+ // bottom/right rule, so the grid needs no gap.
228
+ isGrid ? 'grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))]' : 'py-[var(--spacing-system-xs)]',
229
+ )}
230
+ role="listbox"
231
+ >
151
232
  {isLoading ? (
152
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5">
153
- {loadingText}
154
- </div>
233
+ <div className={statusClass}>{loadingText}</div>
155
234
  ) : filtered.length === 0 ? (
156
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5">
157
- {emptyText}
158
- </div>
235
+ <div className={statusClass}>{emptyText}</div>
159
236
  ) : (
160
237
  filtered.map(opt => {
161
238
  const isSelected = value === opt.value;
@@ -167,13 +244,28 @@ export function SearchableSelect({
167
244
  aria-selected={isSelected}
168
245
  onClick={() => handleSelect(opt.value)}
169
246
  className={cn(
170
- 'flex w-full items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
171
247
  'transition-colors hover:bg-ods-bg-hover',
248
+ isGrid
249
+ ? 'flex min-w-0 flex-col items-center justify-center gap-[var(--spacing-system-xxs)] border-b border-r border-ods-border p-[var(--spacing-system-xs)]'
250
+ : 'flex w-full items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
172
251
  isSelected && 'bg-ods-bg-hover',
173
252
  )}
174
253
  >
175
254
  {renderOption ? (
176
255
  renderOption(opt, isSelected)
256
+ ) : isGrid ? (
257
+ <>
258
+ {opt.icon}
259
+ <span
260
+ className={cn(
261
+ 'w-full truncate text-center text-h6',
262
+ isSelected ? 'text-ods-text-primary' : 'text-ods-text-secondary',
263
+ )}
264
+ title={opt.label}
265
+ >
266
+ {opt.label}
267
+ </span>
268
+ </>
177
269
  ) : (
178
270
  <>
179
271
  {opt.icon}