@recursica/mui-adapter 0.32.1 → 0.34.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.
@@ -366,12 +366,25 @@
366
366
  color: var(
367
367
  --recursica_ui-kit_components_dropdown_properties_colors_text-color
368
368
  );
369
- padding: calc(
370
- var(--recursica_ui-kit_components_dropdown_properties_vertical-padding) *
371
- 0.75
369
+ /* Padding/inter-item gap reuse the menu-item component's tokens (same reasoning as the icon/
370
+ supporting-text tokens below) instead of an arbitrary scaled dropdown padding — matches the
371
+ mantine-adapter's equivalent Menu.module.css `.item` padding + gap exactly. `!important` on
372
+ padding: MUI's `MenuItem` root sets its own paddingTop/Bottom (MenuItem.js) plus paddingLeft/
373
+ Right 16px via its `gutters` variant, both at equal selector specificity to this rule, so
374
+ without it the winner depends on emotion's injection order rather than this stylesheet. */
375
+ padding: var(
376
+ --recursica_ui-kit_components_menu-item_properties_vertical-padding
372
377
  )
373
- var(--recursica_ui-kit_components_dropdown_properties_horizontal-padding);
378
+ var(--recursica_ui-kit_components_menu-item_properties_horizontal-padding) !important;
379
+ margin-bottom: var(--recursica_ui-kit_components_menu_properties_item-gap);
374
380
  cursor: pointer;
381
+ /* MUI's `MenuItem` also forces `white-space: nowrap` by default; override it so long
382
+ labels/supportingText wrap instead of overflowing. Same equal-specificity reasoning. */
383
+ white-space: normal !important;
384
+ }
385
+
386
+ .option:last-of-type {
387
+ margin-bottom: 0;
375
388
  }
376
389
 
377
390
  /* No per-option hovered token exists in the schema; use the generic overlay tint (same technique
@@ -402,3 +415,99 @@
402
415
  --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_text-color
403
416
  ) !important;
404
417
  }
418
+
419
+ /* Rich option content (leadingIcon/supportingText — see MANTINE_ADAPTER_RICH_OPTION_DATA.md). No
420
+ dedicated dropdown-option icon or supporting-text token exists in the schema either; reuse the
421
+ menu-item component's tokens for the same reason the selected-state colors above do. Matches
422
+ the mantine-adapter's equivalent classes. */
423
+ .optionContent {
424
+ display: flex;
425
+ align-items: center;
426
+ gap: var(--recursica_ui-kit_components_menu-item_properties_icon-text-gap);
427
+ }
428
+
429
+ .optionIcon {
430
+ display: flex;
431
+ flex-shrink: 0;
432
+ width: var(
433
+ --recursica_ui-kit_components_menu-item_properties_icon-leading-size
434
+ );
435
+ height: var(
436
+ --recursica_ui-kit_components_menu-item_properties_icon-leading-size
437
+ );
438
+ color: var(
439
+ --recursica_ui-kit_components_menu-item_variants_selection-states_unselected_properties_colors_leading-icon-color
440
+ );
441
+ }
442
+
443
+ .optionIcon :global(svg) {
444
+ width: 100%;
445
+ height: 100%;
446
+ }
447
+
448
+ .optionText {
449
+ display: flex;
450
+ flex-direction: column;
451
+ min-width: 0;
452
+ gap: var(--recursica_ui-kit_components_menu-item_properties_text-gap);
453
+ }
454
+
455
+ /* Default: label/supportingText each truncate to a single line with an ellipsis. */
456
+ .optionText > * {
457
+ overflow: hidden;
458
+ text-overflow: ellipsis;
459
+ white-space: nowrap;
460
+ }
461
+
462
+ /* Applied alongside .optionText when `wrapItemText` is true — long values wrap onto additional
463
+ lines instead of overflowing the fixed-width dropdown. */
464
+ .optionTextWrap > * {
465
+ overflow: visible;
466
+ text-overflow: clip;
467
+ white-space: normal;
468
+ overflow-wrap: anywhere;
469
+ }
470
+
471
+ .optionSupportingText {
472
+ font-family: var(
473
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-family
474
+ );
475
+ font-size: var(
476
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-size
477
+ );
478
+ font-style: var(
479
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-style
480
+ );
481
+ font-weight: var(
482
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-weight
483
+ );
484
+ letter-spacing: var(
485
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_letter-spacing
486
+ );
487
+ line-height: var(
488
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_line-height
489
+ );
490
+ text-decoration: var(
491
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_text-decoration
492
+ );
493
+ text-transform: var(
494
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_text-transform
495
+ );
496
+ color: var(
497
+ --recursica_ui-kit_components_menu-item_variants_selection-states_unselected_properties_colors_supporting-text-color
498
+ );
499
+ }
500
+
501
+ .option[data-selected="true"] .optionIcon,
502
+ .option[data-combobox-selected="true"] .optionIcon {
503
+ color: var(
504
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_leading-icon-color
505
+ ) !important;
506
+ }
507
+
508
+ .option[data-selected="true"] .optionSupportingText,
509
+ .option[data-combobox-selected="true"] .optionSupportingText {
510
+ color: var(
511
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_supporting-text-color
512
+ ) !important;
513
+ }
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Dropdown } from "./Dropdown";
4
4
  import { formControlArgTypes } from "../../../.storybook/commonArgTypes";
5
+ import { renderRichOptionContent } from "../../utils/renderRichOption";
6
+ import styles from "./Dropdown.module.css";
5
7
 
6
8
  type DropdownStoryProps = React.ComponentProps<typeof Dropdown>;
7
9
 
@@ -42,6 +44,11 @@ const meta: Meta<DropdownStoryProps> = {
42
44
  clearable: {
43
45
  control: "boolean",
44
46
  },
47
+ wrapItemText: {
48
+ control: "boolean",
49
+ description:
50
+ "Wraps option label/supportingText onto additional lines instead of truncating with an ellipsis.",
51
+ },
45
52
  containerWidth: {
46
53
  table: { disable: true },
47
54
  },
@@ -86,6 +93,148 @@ export const WithLeadingIcon: Story = {
86
93
  },
87
94
  };
88
95
 
96
+ const UserIcon = (
97
+ <svg
98
+ width="16"
99
+ height="16"
100
+ viewBox="0 0 24 24"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ strokeWidth="2"
104
+ strokeLinecap="round"
105
+ strokeLinejoin="round"
106
+ >
107
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
108
+ <circle cx="12" cy="7" r="4"></circle>
109
+ </svg>
110
+ );
111
+
112
+ export const WithRichOptions: Story = {
113
+ args: {
114
+ label: "Assignee",
115
+ placeholder: "Pick a team member",
116
+ assistiveText:
117
+ "Each option can show a leading icon and supporting text — see MANTINE_ADAPTER_RICH_OPTION_DATA.md.",
118
+ data: [
119
+ {
120
+ value: "jdoe",
121
+ label: "Jane Doe",
122
+ leadingIcon: UserIcon,
123
+ supportingText: "jane.doe@example.com",
124
+ },
125
+ {
126
+ value: "asmith",
127
+ label: "Alex Smith",
128
+ leadingIcon: UserIcon,
129
+ supportingText: "alex.smith@example.com",
130
+ },
131
+ { value: "unassigned", label: "Unassigned" },
132
+ ],
133
+ },
134
+ };
135
+
136
+ export const WithRichOptionsWrapped: Story = {
137
+ args: {
138
+ label: "Assignee",
139
+ placeholder: "Pick a team member",
140
+ wrapItemText: true,
141
+ data: [
142
+ {
143
+ value: "jdoe",
144
+ label: "Jane Doe, Senior Staff Engineer, Platform Infrastructure",
145
+ leadingIcon: UserIcon,
146
+ supportingText:
147
+ "jane.doe@example.com — Platform Infrastructure team, on-call rotation lead",
148
+ },
149
+ { value: "unassigned", label: "Unassigned" },
150
+ ],
151
+ assistiveText:
152
+ "wrapItemText=true — long label/supportingText wrap instead of truncating.",
153
+ },
154
+ };
155
+
156
+ const optionRowPreviewClassNames = {
157
+ optionContent: styles.optionContent,
158
+ optionIcon: styles.optionIcon,
159
+ optionText: styles.optionText,
160
+ optionTextWrap: styles.optionTextWrap,
161
+ optionSupportingText: styles.optionSupportingText,
162
+ };
163
+
164
+ const OPTION_ROW_PREVIEW_ITEMS = [
165
+ {
166
+ value: "icon-and-supporting",
167
+ label: "Jane Doe",
168
+ leadingIcon: UserIcon,
169
+ supportingText: "jane.doe@example.com",
170
+ },
171
+ {
172
+ value: "no-icon",
173
+ label: "Alex Smith",
174
+ supportingText:
175
+ "No leadingIcon — label/supportingText shift left, no reserved icon space",
176
+ },
177
+ {
178
+ value: "no-supporting-text",
179
+ label: "Taylor Rivera",
180
+ leadingIcon: UserIcon,
181
+ },
182
+ {
183
+ value: "plain",
184
+ label: "Plain option — no leadingIcon, no supportingText",
185
+ },
186
+ {
187
+ value: "long-text",
188
+ label:
189
+ "A very long option label that, with wrapItemText, wraps onto a second line instead of overflowing the fixed-width dropdown — otherwise it truncates with an ellipsis",
190
+ leadingIcon: UserIcon,
191
+ supportingText:
192
+ "A similarly long supporting text string, to confirm the same wrap-or-truncate behavior applies to it too",
193
+ },
194
+ ];
195
+
196
+ // Renders the option row content directly — outside the MUI Menu portal — inside a container
197
+ // sized to Dropdown's own max-width token. Spacing between rows, icon/supportingText presence-
198
+ // or-absence alignment, and long-text wrapping/truncation are all much easier to inspect this way
199
+ // than by opening the real (portal-rendered) MUI Select menu. See
200
+ // MANTINE_ADAPTER_RICH_OPTION_DATA.md.
201
+ const renderOptionRowPreview = (wrapItemText: boolean) => (
202
+ <div
203
+ className={styles.dropdown}
204
+ style={{
205
+ width:
206
+ "var(--recursica_ui-kit_components_dropdown_variants_layouts_stacked_properties_max-width)",
207
+ }}
208
+ >
209
+ {OPTION_ROW_PREVIEW_ITEMS.map((item) => (
210
+ <div key={item.value} className={styles.option}>
211
+ {renderRichOptionContent(
212
+ item,
213
+ optionRowPreviewClassNames,
214
+ wrapItemText,
215
+ )}
216
+ </div>
217
+ ))}
218
+ </div>
219
+ );
220
+
221
+ // Default: `wrapItemText` is false — label/supportingText truncate to a single line with an
222
+ // ellipsis instead of wrapping.
223
+ export const RichOptionRowPreview: Story = {
224
+ parameters: {
225
+ controls: { disable: true },
226
+ },
227
+ render: () => renderOptionRowPreview(false),
228
+ };
229
+
230
+ // `wrapItemText: true` — label/supportingText wrap onto additional lines instead of truncating.
231
+ export const RichOptionRowPreviewWrapped: Story = {
232
+ parameters: {
233
+ controls: { disable: true },
234
+ },
235
+ render: () => renderOptionRowPreview(true),
236
+ };
237
+
89
238
  export const StaticError: Story = {
90
239
  args: {
91
240
  error: "You must choose a valid destination.",
@@ -5,7 +5,10 @@ import {
5
5
  SelectChangeEvent,
6
6
  MenuItem,
7
7
  } from "@mui/material";
8
- import { type ReadOnlyControlProps } from "@recursica/adapter-common";
8
+ import {
9
+ type ReadOnlyControlProps,
10
+ normalizeComboboxData,
11
+ } from "@recursica/adapter-common";
9
12
  import {
10
13
  filterStylingProps,
11
14
  omitUnsupportedProps,
@@ -13,6 +16,7 @@ import {
13
16
  } from "../../utils/filterStylingProps";
14
17
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
15
18
  import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
19
+ import { renderRichOptionContent } from "../../utils/renderRichOption";
16
20
  import { ChevronIcon, ClearIcon } from "./Dropdown.icons";
17
21
  import styles from "./Dropdown.module.css";
18
22
 
@@ -86,6 +90,7 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
86
90
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
87
91
  searchable, // Not natively supported by basic MUI Select, stubbed
88
92
  clearable,
93
+ wrapItemText = false,
89
94
  ...rest
90
95
  } = props;
91
96
  // Props this component intentionally doesn't support — deleted at runtime so they can't leak
@@ -151,9 +156,22 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
151
156
  ? `${styles.layoutOverride} ${className}`
152
157
  : styles.layoutOverride;
153
158
 
159
+ const optionClassNames = {
160
+ optionContent: styles.optionContent,
161
+ optionIcon: styles.optionIcon,
162
+ optionText: styles.optionText,
163
+ optionTextWrap: styles.optionTextWrap,
164
+ optionSupportingText: styles.optionSupportingText,
165
+ };
166
+
167
+ // See AutoComplete.tsx's identical use of `normalizeComboboxData` (adapter-common) — items
168
+ // always have a real `label` after this, so downstream code doesn't need its own `?? value`
169
+ // fallback at every read.
170
+ const normalizedData = normalizeComboboxData(data);
171
+
154
172
  const renderOptions = () => {
155
- if (!data) return null;
156
- return data.map((item, index) => {
173
+ if (!normalizedData) return null;
174
+ return normalizedData.map((item, index) => {
157
175
  if (typeof item === "string") {
158
176
  return (
159
177
  <MenuItem
@@ -179,12 +197,26 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
179
197
  disableRipple
180
198
  data-selected={item.value === internalValue ? "true" : undefined}
181
199
  >
182
- {item.label}
200
+ {renderRichOptionContent(item, optionClassNames, wrapItemText)}
183
201
  </MenuItem>
184
202
  );
185
203
  });
186
204
  };
187
205
 
206
+ // MUI's closed-field display otherwise mirrors the selected `MenuItem`'s children directly —
207
+ // now that those can be a rich icon+label+supportingText row, `renderValue` keeps the closed
208
+ // field showing just the plain label, matching the field's own single-line text control.
209
+ const renderValue = (selected: unknown) => {
210
+ if (selected === "" || selected === undefined || selected === null) {
211
+ return "";
212
+ }
213
+ const match = normalizedData?.find((item) =>
214
+ typeof item === "string" ? item === selected : item.value === selected,
215
+ );
216
+ if (!match) return "";
217
+ return typeof match === "string" ? match : match.label;
218
+ };
219
+
188
220
  const wrappedStartAdornment = startAdornment ? (
189
221
  <span className={styles.section} data-position="left">
190
222
  {startAdornment}
@@ -246,6 +278,7 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
246
278
  error={!!error}
247
279
  required={required}
248
280
  displayEmpty
281
+ renderValue={renderValue}
249
282
  className={styles.root}
250
283
  classes={{
251
284
  select: styles.input,
@@ -46,3 +46,23 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
46
46
 
47
47
  - Pass `startAdornment` for a leading icon, and `clearable` (with a value present) to show a clear button — both render using the dropdown's own icon-color tokens, matching the mantine-adapter's `leftSection`/`clearable` behavior.
48
48
  - `onChange` follows MUI's native `Select` signature: `(event: SelectChangeEvent, child: ReactNode) => void`.
49
+ - `data` items can carry a `leadingIcon` and `supportingText`, rendered inside each option row
50
+ (the closed field always shows the plain `label`, matching the mantine-adapter):
51
+
52
+ ```tsx
53
+ <Dropdown
54
+ label="Assignee"
55
+ data={[
56
+ {
57
+ value: "jdoe",
58
+ label: "Jane Doe",
59
+ leadingIcon: <UserIcon />,
60
+ supportingText: "jane.doe@example.com",
61
+ },
62
+ { value: "asmith", label: "Alex Smith" },
63
+ ]}
64
+ />
65
+ ```
66
+
67
+ By default `label`/`supportingText` truncate to a single line with an ellipsis. Set
68
+ `wrapItemText` to wrap them onto additional lines instead: `<Dropdown data={data} wrapItemText />`.
@@ -2,3 +2,4 @@
2
2
 
3
3
  - **Compositional API Dropped:** Mantine uses `<Menu.Target>`, `<Menu.Dropdown>`, `<Menu.Item>`, etc., and manages state natively via React context within `<Menu>`. MUI's API is fully monolithic.
4
4
  - **Monolithic API Adopted:** Following architectural review, we have abandoned the fabricated context wrappers for `mui-adapter`. We now natively export `Menu`, `MenuItem`, and `MenuDivider` wrapping their `@mui/material` counterparts. Developers are expected to manage `anchorEl` state themselves, just like native MUI. Storybook tests have been updated to simulate this open state so visual regressions still cover the dropdown menu visually.
5
+ - **`maxHeight` Override:** `<Menu maxHeight={...}>` overrides the token-driven dropdown max-height with an explicit pixel (or other CSS length) value — the one deliberate exception to "no inline design tokens in TSX" (see `COMPONENT_DEV_GUIDE.md`). Implemented by merging `maxHeight` into `slotProps.paper.style`, alongside any caller-supplied `slotProps`, rather than a CSS module change; the CSS module's token-driven `max-height` stays untouched when the prop isn't passed.
@@ -300,6 +300,43 @@ export const WithSubmenus: Story = {
300
300
  },
301
301
  };
302
302
 
303
+ export const WithMaxHeight: Story = {
304
+ render: (args) => (
305
+ <InteractiveMenu {...args}>
306
+ <MenuItem>
307
+ <SettingsIcon style={{ marginRight: 8 }} /> Settings
308
+ </MenuItem>
309
+ <MenuItem>
310
+ <MessageIcon style={{ marginRight: 8 }} /> Messages
311
+ </MenuItem>
312
+ <MenuItem>
313
+ <ImageIcon style={{ marginRight: 8 }} /> Gallery
314
+ </MenuItem>
315
+ <MenuItem>
316
+ <SearchIcon style={{ marginRight: 8 }} /> Search
317
+ </MenuItem>
318
+ <MenuItem>
319
+ <ArrowsIcon style={{ marginRight: 8 }} /> Transfer my data
320
+ </MenuItem>
321
+ <MenuItem>
322
+ <TrashIcon style={{ marginRight: 8 }} /> Delete my account
323
+ </MenuItem>
324
+ </InteractiveMenu>
325
+ ),
326
+ args: {
327
+ opened: true,
328
+ maxHeight: 160,
329
+ },
330
+ parameters: {
331
+ docs: {
332
+ description: {
333
+ story:
334
+ "`maxHeight` overrides the token-driven dropdown max-height with an explicit pixel value, scrolling the item list once it's exceeded.",
335
+ },
336
+ },
337
+ },
338
+ };
339
+
303
340
  // mui-adapter's Menu has no native hover-trigger support (unlike Mantine's `trigger` prop),
304
341
  // so this story implements open-on-hover itself: hovering the target opens the menu, and a
305
342
  // short close delay (mirroring Mantine's `closeDelay`) keeps it open while the pointer moves
@@ -1,4 +1,4 @@
1
- import { forwardRef } from "react";
1
+ import { forwardRef, type CSSProperties } from "react";
2
2
  import {
3
3
  Menu as MuiMenu,
4
4
  type MenuProps as MuiMenuProps,
@@ -19,27 +19,47 @@ import { type RecursicaMenuProps } from "@recursica/adapter-common";
19
19
  export type MenuProps = RecursicaOverStyled<MuiMenuProps & RecursicaMenuProps>;
20
20
 
21
21
  export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
22
- { overStyled = false, className, ...rest },
22
+ { overStyled = false, className, maxHeight, ...rest },
23
23
  ref,
24
24
  ) {
25
25
  const sanitizedProps = filterStylingProps(rest, overStyled);
26
+ const restRecord = sanitizedProps as Record<string, unknown>;
26
27
 
27
28
  const mergedClassNames = mergeClassNames(
28
29
  {
29
30
  paper: styles.dropdown,
30
31
  list: styles.dropdown,
31
32
  },
32
- (sanitizedProps as Record<string, unknown>).classes as
33
- | Partial<Record<string, string>>
34
- | undefined,
33
+ restRecord.classes as Partial<Record<string, string>> | undefined,
35
34
  );
36
35
 
36
+ // `maxHeight` is a caller-supplied override of the token-driven dropdown max-height, applied
37
+ // to the Paper slot's inline style — an explicit per-instance escape hatch, not a design token.
38
+ const callerSlotProps = restRecord.slotProps as
39
+ | { paper?: Record<string, unknown> }
40
+ | undefined;
41
+ const mergedSlotProps = maxHeight
42
+ ? {
43
+ ...callerSlotProps,
44
+ paper: {
45
+ ...callerSlotProps?.paper,
46
+ style: {
47
+ ...(callerSlotProps?.paper?.style as CSSProperties | undefined),
48
+ maxHeight,
49
+ },
50
+ },
51
+ }
52
+ : callerSlotProps;
53
+
37
54
  return (
38
55
  <MuiMenu
39
56
  ref={ref}
40
57
  {...(sanitizedProps as MuiMenuProps)}
41
58
  className={className}
42
59
  classes={mergedClassNames}
60
+ {...(mergedSlotProps
61
+ ? { slotProps: mergedSlotProps as MuiMenuProps["slotProps"] }
62
+ : {})}
43
63
  />
44
64
  );
45
65
  });
@@ -45,3 +45,9 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
45
45
  > - **Anti-override protection**: Rogues style injections (like inline `style` or arbitrary `className`) are automatically blocked by our prop layer unless `overStyled={true}` is explicitly provided.
46
46
  > - **No Direct Layers**: Do not pass a `layer` prop to this component. To place it on a specific visual layer, wrap it in a `<Layer layer={0|1|2|3}>` component natively.
47
47
  > - **Variables and Theming**: Styling is entirely determined by local CSS variables defined in `recursica_variables_scoped.css` and mapped in the component's CSS module.
48
+
49
+ ---
50
+
51
+ ## 4. Notes
52
+
53
+ - `maxHeight` on `<Menu>` overrides the dropdown's token-driven max-height with an explicit pixel (or other CSS length) value, e.g. `<Menu maxHeight={320}>`. It's a per-instance escape hatch, not a design token — leave it unset to use the token default.
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { type RecursicaComboboxItemWithLabel } from "@recursica/adapter-common";
3
+
4
+ export interface RichOptionClassNames {
5
+ optionContent: string;
6
+ optionIcon: string;
7
+ optionText: string;
8
+ /** Combined with `optionText` when `wrapItemText` is true — see `renderRichOptionContent` below. */
9
+ optionTextWrap: string;
10
+ optionSupportingText: string;
11
+ }
12
+
13
+ /**
14
+ * `MenuItem`/option children for Dropdown/Autocomplete that renders `leadingIcon`/`supportingText`
15
+ * (see MANTINE_ADAPTER_RICH_OPTION_DATA.md) inside the row. Falls back to plain `label` when
16
+ * neither is present, so items without the new fields render exactly as before.
17
+ *
18
+ * `wrapItemText` (default `false`) controls whether `label`/`supportingText` wrap onto additional
19
+ * lines or truncate to a single line with an ellipsis.
20
+ */
21
+ export function renderRichOptionContent(
22
+ item: Pick<
23
+ RecursicaComboboxItemWithLabel,
24
+ "label" | "leadingIcon" | "supportingText"
25
+ >,
26
+ classNames: RichOptionClassNames,
27
+ wrapItemText = false,
28
+ ): React.ReactNode {
29
+ const { label, leadingIcon, supportingText } = item;
30
+ if (!leadingIcon && !supportingText) {
31
+ return label;
32
+ }
33
+ const optionTextClassName = wrapItemText
34
+ ? `${classNames.optionText} ${classNames.optionTextWrap}`
35
+ : classNames.optionText;
36
+ return (
37
+ <span className={classNames.optionContent}>
38
+ {leadingIcon && (
39
+ <span className={classNames.optionIcon}>{leadingIcon}</span>
40
+ )}
41
+ <span className={optionTextClassName}>
42
+ <span>{label}</span>
43
+ {supportingText && (
44
+ <span className={classNames.optionSupportingText}>
45
+ {supportingText}
46
+ </span>
47
+ )}
48
+ </span>
49
+ </span>
50
+ );
51
+ }