@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.
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mui-adapter"
15
15
  },
16
- "version": "0.32.1",
16
+ "version": "0.34.0",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -102,7 +102,7 @@
102
102
  "vitest": "^3.2.4"
103
103
  },
104
104
  "dependencies": {
105
- "@recursica/adapter-common": "^0.23.0",
105
+ "@recursica/adapter-common": "^0.25.0",
106
106
  "@recursica/official-release": "^2.8.0",
107
107
  "dayjs": "^1.11.21"
108
108
  },
@@ -0,0 +1,17 @@
1
+ # Autocomplete Implementation Notes
2
+
3
+ ## Rich Option Content (`leadingIcon`/`supportingText`)
4
+
5
+ `data` items accept optional `leadingIcon`/`supportingText` fields, via the shared `RecursicaComboboxItem` type in `@recursica/adapter-common` (see `MANTINE_ADAPTER_RICH_OPTION_DATA.md` at the repo root — proposed against the mantine-adapter, but Forge needs both adapters to accept the same `data` shape, hence the type living in adapter-common rather than being redeclared per adapter). `Autocomplete.tsx` runs `data` through adapter-common's `normalizeComboboxData` first (backfills `label` from `value`, since `label` on the shared type is optional), then installs a default `renderOption` that renders each option's `<li>` via `renderRichOptionContent` (`../../utils/renderRichOption.tsx`, shared with `Dropdown`) — `leadingIcon`+`label`+`supportingText` when either new field is present, or just `label` otherwise. A caller-supplied `renderOption` always wins over the default. The icon is only rendered as a child when `leadingIcon` is set (not a hidden reserved slot), so label/supportingText shift left when there's no icon; the row's `align-items: center` keeps the label vertically centered when there's no `supportingText`.
6
+
7
+ Unlike Dropdown, this doesn't need an explicit `renderValue`-equivalent fix: MUI's `Autocomplete` already separates the closed field's text (driven by `getOptionLabel`, which defaults to `option.label`) from the open dropdown's row rendering (`renderOption`), so enriching `renderOption` alone doesn't touch what the closed field shows.
8
+
9
+ New CSS classes (`.optionContent`/`.optionIcon`/`.optionText`/`.optionSupportingText`) in `Autocomplete.module.css` reuse the menu-item component's icon/supporting-text tokens, matching the mantine-adapter's equivalent addition — no dedicated autocomplete-option tokens exist for either.
10
+
11
+ ## `wrapItemText`
12
+
13
+ `label`/`supportingText` default to single-line truncation with an ellipsis (`.optionText > *` — `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`). Passing `wrapItemText` adds `.optionTextWrap` alongside `.optionText`, re-enabling wrapping (`white-space: normal; overflow-wrap: anywhere`) for both children — later-cascade-wins, equal specificity. `renderRichOptionContent` takes `wrapItemText` as a third parameter and combines the two class names when it's true.
14
+
15
+ ## Selected Option Highlight (bug fix)
16
+
17
+ There was previously no rule at all for "this option matches the current value" — only the shared neutral hover tint, and only while actually hovered. MUI's `Autocomplete` already stamps `aria-selected="true"` onto the matching option (`useAutocomplete.js`'s `getOptionProps`), and `defaultRenderOption` spreads `liProps` (which includes it) straight onto the `<li>`, so the DOM signal was already there — `.option[aria-selected="true"]` now maps it to the same menu-item selected-state background/text-color tokens Dropdown uses. Needs `!important`: MUI's own `.MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected="true"]` rule (`Autocomplete.js`) is a two-class descendant selector — higher specificity than this single-class rule — and paints its own default blue tint otherwise, same reasoning as the padding `!important` above. Verified via Playwright: `aria-selected="true"` plus the token background color both land on the correct option after a plain click-select-reopen.
@@ -227,26 +227,33 @@
227
227
  color: var(
228
228
  --recursica_ui-kit_components_autocomplete_properties_colors_text-color
229
229
  );
230
- padding: calc(
231
- var(
232
- --recursica_ui-kit_components_autocomplete_properties_vertical-padding
233
- ) *
234
- 0.5
230
+ /* Padding/inter-item gap reuse the menu-item component's tokens (same reasoning as the icon/
231
+ supporting-text tokens below) instead of an arbitrary scaled autocomplete padding — matches
232
+ the mantine-adapter's equivalent Menu.module.css `.item` padding + gap exactly. `!important`
233
+ needed on padding: MUI's own `.MuiAutocomplete-listbox .MuiAutocomplete-option` rule
234
+ (hardcoded `6px 16px`) is a two-class descendant selector, higher specificity than this
235
+ single-class rule, and otherwise wins regardless of source order. */
236
+ padding: var(
237
+ --recursica_ui-kit_components_menu-item_properties_vertical-padding
235
238
  )
236
- var(
237
- --recursica_ui-kit_components_autocomplete_properties_horizontal-padding
238
- );
239
+ var(--recursica_ui-kit_components_menu-item_properties_horizontal-padding) !important;
240
+ margin-bottom: var(--recursica_ui-kit_components_menu_properties_item-gap);
239
241
  cursor: pointer;
240
242
  }
241
243
 
242
- /* No per-option selected/hovered tokens exist in the schema; use the generic overlay tint
243
- (same technique as Table/Dropdown) to highlight the active option. */
244
+ .option:last-of-type {
245
+ margin-bottom: 0;
246
+ }
247
+
248
+ /* No per-option hovered token exists in the schema; use the generic overlay tint (same technique
249
+ as Table/Dropdown) to highlight a hovered-but-not-selected option. `data-combobox-selected`/
250
+ `data-combobox-active`/`data-hovered` are Mantine-only attributes that never actually appear on
251
+ this MUI-driven tree — kept here only so this stays shape-matched with the mantine-adapter's
252
+ equivalent file. */
244
253
  .option[data-selected="true"],
245
254
  .option[data-selected],
246
255
  .option[data-combobox-selected="true"],
247
256
  .option[data-combobox-selected],
248
- .option[data-combobox-active="true"],
249
- .option[data-combobox-active],
250
257
  .option[data-hovered="true"],
251
258
  .option[data-hovered],
252
259
  .option:hover {
@@ -258,6 +265,108 @@
258
265
  );
259
266
  }
260
267
 
268
+ /* No dedicated autocomplete-option "selected" token exists in the schema either, but the
269
+ menu-item component's selected-state colors are the closest real token family for the same
270
+ concept (a selected row in a list) — same reuse Dropdown.module.css makes. MUI's Autocomplete
271
+ already stamps `aria-selected="true"` onto the option matching the current value (see
272
+ useAutocomplete.js's `getOptionProps`), and `defaultRenderOption` (Autocomplete.tsx) spreads
273
+ that straight onto the `<li>` — there was previously no rule keyed off it at all, so the
274
+ selected option never got a distinct highlight (only ever the same neutral hover tint above,
275
+ and only while actually hovered). `!important` needed: MUI's own
276
+ `.MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected="true"]` rule (Autocomplete.js)
277
+ is a two-class descendant selector, higher specificity than this single-class rule, and
278
+ otherwise wins with its default blue tint regardless of source order — same reasoning as the
279
+ padding `!important` above. */
280
+ .option[aria-selected="true"] {
281
+ background-color: var(
282
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_background-color
283
+ ) !important;
284
+ color: var(
285
+ --recursica_ui-kit_components_menu-item_variants_selection-states_selected_properties_colors_text-color
286
+ ) !important;
287
+ }
288
+
289
+ /* Rich option content (leadingIcon/supportingText — see MANTINE_ADAPTER_RICH_OPTION_DATA.md). No
290
+ dedicated autocomplete-option icon or supporting-text token exists in the schema either; reuse
291
+ the menu-item component's tokens, same reasoning as Dropdown.module.css. */
292
+ .optionContent {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: var(--recursica_ui-kit_components_menu-item_properties_icon-text-gap);
296
+ }
297
+
298
+ .optionIcon {
299
+ display: flex;
300
+ flex-shrink: 0;
301
+ width: var(
302
+ --recursica_ui-kit_components_menu-item_properties_icon-leading-size
303
+ );
304
+ height: var(
305
+ --recursica_ui-kit_components_menu-item_properties_icon-leading-size
306
+ );
307
+ color: var(
308
+ --recursica_ui-kit_components_menu-item_variants_selection-states_unselected_properties_colors_leading-icon-color
309
+ );
310
+ }
311
+
312
+ .optionIcon :global(svg) {
313
+ width: 100%;
314
+ height: 100%;
315
+ }
316
+
317
+ .optionText {
318
+ display: flex;
319
+ flex-direction: column;
320
+ min-width: 0;
321
+ gap: var(--recursica_ui-kit_components_menu-item_properties_text-gap);
322
+ }
323
+
324
+ /* Default: label/supportingText each truncate to a single line with an ellipsis. */
325
+ .optionText > * {
326
+ overflow: hidden;
327
+ text-overflow: ellipsis;
328
+ white-space: nowrap;
329
+ }
330
+
331
+ /* Applied alongside .optionText when `wrapItemText` is true — long values wrap onto additional
332
+ lines instead of overflowing the fixed-width dropdown. */
333
+ .optionTextWrap > * {
334
+ overflow: visible;
335
+ text-overflow: clip;
336
+ white-space: normal;
337
+ overflow-wrap: anywhere;
338
+ }
339
+
340
+ .optionSupportingText {
341
+ font-family: var(
342
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-family
343
+ );
344
+ font-size: var(
345
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-size
346
+ );
347
+ font-style: var(
348
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-style
349
+ );
350
+ font-weight: var(
351
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_font-weight
352
+ );
353
+ letter-spacing: var(
354
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_letter-spacing
355
+ );
356
+ line-height: var(
357
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_line-height
358
+ );
359
+ text-decoration: var(
360
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_text-decoration
361
+ );
362
+ text-transform: var(
363
+ --recursica_ui-kit_components_menu-item_properties_supporting-text_text-transform
364
+ );
365
+ color: var(
366
+ --recursica_ui-kit_components_menu-item_variants_selection-states_unselected_properties_colors_supporting-text-color
367
+ );
368
+ }
369
+
261
370
  /* -------------------------------------
262
371
  STATE CASCADE ARCHITECTURE
263
372
  -------------------------------------- */
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Autocomplete as AutoComplete } from "./Autocomplete";
3
3
  import { formControlArgTypes } from "../../../.storybook/commonArgTypes";
4
+ import { renderRichOptionContent } from "../../utils/renderRichOption";
5
+ import styles from "./Autocomplete.module.css";
4
6
 
5
7
  const meta: Meta<typeof AutoComplete> = {
6
8
  title: "UI-Kit/AutoComplete",
@@ -55,6 +57,11 @@ Always structure horizontal architectures via the generic \`formLayout\` paramet
55
57
  description:
56
58
  "Toggles structural read-only data presentation explicitly blocking standard component bindings.",
57
59
  },
60
+ wrapItemText: {
61
+ control: "boolean",
62
+ description:
63
+ "Wraps option label/supportingText onto additional lines instead of truncating with an ellipsis.",
64
+ },
58
65
  },
59
66
  };
60
67
 
@@ -144,6 +151,148 @@ export const WithTrailingIcon: Story = {
144
151
  },
145
152
  };
146
153
 
154
+ const UserIcon = (
155
+ <svg
156
+ width="16"
157
+ height="16"
158
+ viewBox="0 0 24 24"
159
+ fill="none"
160
+ stroke="currentColor"
161
+ strokeWidth="2"
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ >
165
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
166
+ <circle cx="12" cy="7" r="4"></circle>
167
+ </svg>
168
+ );
169
+
170
+ export const WithRichOptions: Story = {
171
+ args: {
172
+ label: "Assignee",
173
+ placeholder: "Search team members...",
174
+ data: [
175
+ {
176
+ value: "jdoe",
177
+ label: "Jane Doe",
178
+ leadingIcon: UserIcon,
179
+ supportingText: "jane.doe@example.com",
180
+ },
181
+ {
182
+ value: "asmith",
183
+ label: "Alex Smith",
184
+ leadingIcon: UserIcon,
185
+ supportingText: "alex.smith@example.com",
186
+ },
187
+ { value: "unassigned", label: "Unassigned" },
188
+ ],
189
+ assistiveText:
190
+ "Each option can show a leading icon and supporting text — see MANTINE_ADAPTER_RICH_OPTION_DATA.md.",
191
+ },
192
+ };
193
+
194
+ export const WithRichOptionsWrapped: Story = {
195
+ args: {
196
+ label: "Assignee",
197
+ placeholder: "Search team members...",
198
+ wrapItemText: true,
199
+ data: [
200
+ {
201
+ value: "jdoe",
202
+ label: "Jane Doe, Senior Staff Engineer, Platform Infrastructure",
203
+ leadingIcon: UserIcon,
204
+ supportingText:
205
+ "jane.doe@example.com — Platform Infrastructure team, on-call rotation lead",
206
+ },
207
+ { value: "unassigned", label: "Unassigned" },
208
+ ],
209
+ assistiveText:
210
+ "wrapItemText=true — long label/supportingText wrap instead of truncating.",
211
+ },
212
+ };
213
+
214
+ const optionRowPreviewClassNames = {
215
+ optionContent: styles.optionContent,
216
+ optionIcon: styles.optionIcon,
217
+ optionText: styles.optionText,
218
+ optionTextWrap: styles.optionTextWrap,
219
+ optionSupportingText: styles.optionSupportingText,
220
+ };
221
+
222
+ const OPTION_ROW_PREVIEW_ITEMS = [
223
+ {
224
+ value: "icon-and-supporting",
225
+ label: "Jane Doe",
226
+ leadingIcon: UserIcon,
227
+ supportingText: "jane.doe@example.com",
228
+ },
229
+ {
230
+ value: "no-icon",
231
+ label: "Alex Smith",
232
+ supportingText:
233
+ "No leadingIcon — label/supportingText shift left, no reserved icon space",
234
+ },
235
+ {
236
+ value: "no-supporting-text",
237
+ label: "Taylor Rivera",
238
+ leadingIcon: UserIcon,
239
+ },
240
+ {
241
+ value: "plain",
242
+ label: "Plain option — no leadingIcon, no supportingText",
243
+ },
244
+ {
245
+ value: "long-text",
246
+ label:
247
+ "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",
248
+ leadingIcon: UserIcon,
249
+ supportingText:
250
+ "A similarly long supporting text string, to confirm the same wrap-or-truncate behavior applies to it too",
251
+ },
252
+ ];
253
+
254
+ // Renders the option row content directly — outside the MUI Popper portal — inside a container
255
+ // sized to Autocomplete's own max-width token. Spacing between rows, icon/supportingText
256
+ // presence-or-absence alignment, and long-text wrapping/truncation are all much easier to inspect
257
+ // this way than by opening the real (portal-rendered) MUI Autocomplete listbox. See
258
+ // MANTINE_ADAPTER_RICH_OPTION_DATA.md.
259
+ const renderOptionRowPreview = (wrapItemText: boolean) => (
260
+ <div
261
+ className={styles.dropdown}
262
+ style={{
263
+ width:
264
+ "var(--recursica_ui-kit_components_autocomplete_variants_layouts_stacked_properties_max-width)",
265
+ }}
266
+ >
267
+ {OPTION_ROW_PREVIEW_ITEMS.map((item) => (
268
+ <div key={item.value} className={styles.option}>
269
+ {renderRichOptionContent(
270
+ item,
271
+ optionRowPreviewClassNames,
272
+ wrapItemText,
273
+ )}
274
+ </div>
275
+ ))}
276
+ </div>
277
+ );
278
+
279
+ // Default: `wrapItemText` is false — label/supportingText truncate to a single line with an
280
+ // ellipsis instead of wrapping.
281
+ export const RichOptionRowPreview: Story = {
282
+ parameters: {
283
+ controls: { disable: true },
284
+ },
285
+ render: () => renderOptionRowPreview(false),
286
+ };
287
+
288
+ // `wrapItemText: true` — label/supportingText wrap onto additional lines instead of truncating.
289
+ export const RichOptionRowPreviewWrapped: Story = {
290
+ parameters: {
291
+ controls: { disable: true },
292
+ },
293
+ render: () => renderOptionRowPreview(true),
294
+ };
295
+
147
296
  export const Disabled: Story = {
148
297
  args: {
149
298
  label: "Disabled Deployment Node",
@@ -5,7 +5,11 @@ import {
5
5
  TextField as MuiTextField,
6
6
  // removed InputWrapperProps
7
7
  } from "@mui/material";
8
- import { type ReadOnlyControlProps } from "@recursica/adapter-common";
8
+ import {
9
+ type ReadOnlyControlProps,
10
+ type RecursicaComboboxItemWithLabel,
11
+ normalizeComboboxData,
12
+ } from "@recursica/adapter-common";
9
13
  import {
10
14
  filterStylingProps,
11
15
  omitUnsupportedProps,
@@ -14,6 +18,7 @@ import {
14
18
  } from "../../utils/filterStylingProps";
15
19
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
16
20
  import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
21
+ import { renderRichOptionContent } from "../../utils/renderRichOption";
17
22
  import styles from "./Autocomplete.module.css";
18
23
 
19
24
  import { type RecursicaAutocompleteProps as BaseRecursicaAutocompleteProps } from "@recursica/adapter-common";
@@ -77,6 +82,8 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
77
82
  rightSection,
78
83
  placeholder,
79
84
  ListboxProps,
85
+ renderOption,
86
+ wrapItemText = false,
80
87
  ...rest
81
88
  } = props;
82
89
  // Props this component intentionally doesn't support — deleted at runtime so they can't leak
@@ -128,6 +135,43 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
128
135
  ? `${styles.layoutOverride} ${className}`
129
136
  : styles.layoutOverride;
130
137
 
138
+ const optionClassNames = {
139
+ optionContent: styles.optionContent,
140
+ optionIcon: styles.optionIcon,
141
+ optionText: styles.optionText,
142
+ optionTextWrap: styles.optionTextWrap,
143
+ optionSupportingText: styles.optionSupportingText,
144
+ };
145
+
146
+ // See Dropdown.tsx's identical use of `normalizeComboboxData` (adapter-common) — items always
147
+ // have a real `label` after this, so `defaultRenderOption` below doesn't need its own fallback.
148
+ const normalizedData = normalizeComboboxData(data);
149
+
150
+ // Default per-option rendering — kept as a fallback so a caller-supplied `renderOption` (an
151
+ // escape hatch, see MANTINE_ADAPTER_RICH_OPTION_DATA.md) still wins.
152
+ const defaultRenderOption = (
153
+ liProps: React.HTMLAttributes<HTMLLIElement> & { key?: React.Key },
154
+ option: unknown,
155
+ ) => {
156
+ const { key, ...otherProps } = liProps;
157
+ if (typeof option === "string") {
158
+ return (
159
+ <li key={key} {...otherProps}>
160
+ {option}
161
+ </li>
162
+ );
163
+ }
164
+ return (
165
+ <li key={key} {...otherProps}>
166
+ {renderRichOptionContent(
167
+ option as RecursicaComboboxItemWithLabel,
168
+ optionClassNames,
169
+ wrapItemText,
170
+ )}
171
+ </li>
172
+ );
173
+ };
174
+
131
175
  return (
132
176
  <WithReadOnlyWrapper
133
177
  className={wrapperClass}
@@ -175,7 +219,8 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
175
219
  ...ListboxProps,
176
220
  className: mergedClassNames.listbox,
177
221
  }}
178
- options={data || []}
222
+ options={normalizedData || []}
223
+ renderOption={renderOption ?? defaultRenderOption}
179
224
  renderInput={(params) => {
180
225
  const { InputProps, ...restParams } = params;
181
226
  return (
@@ -40,3 +40,29 @@ All Recursica components in the `@recursica/mui-adapter` package adhere strictly
40
40
  > - **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.
41
41
  > - **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.
42
42
  > - **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.
43
+
44
+ ---
45
+
46
+ ## 4. Rich option content: `leadingIcon` / `supportingText`
47
+
48
+ `data` items can carry an icon and a secondary line of text, rendered inside each option row:
49
+
50
+ ```tsx
51
+ <Autocomplete
52
+ label="Assignee"
53
+ data={[
54
+ {
55
+ value: "jdoe",
56
+ label: "Jane Doe",
57
+ leadingIcon: <UserIcon />,
58
+ supportingText: "jane.doe@example.com",
59
+ },
60
+ { value: "asmith", label: "Alex Smith" },
61
+ ]}
62
+ />
63
+ ```
64
+
65
+ Pass your own `renderOption` to opt out of this default rendering for a given instance.
66
+
67
+ By default `label`/`supportingText` truncate to a single line with an ellipsis. Set
68
+ `wrapItemText` to wrap them onto additional lines instead: `<Autocomplete data={data} wrapItemText />`.
@@ -1,14 +1,19 @@
1
- import React, { forwardRef } from "react";
1
+ import { forwardRef } from "react";
2
2
  import {
3
3
  Select as MuiSelect,
4
4
  type SelectProps as MuiSelectProps,
5
5
  MenuItem,
6
6
  } from "@mui/material";
7
+ import {
8
+ type RecursicaComboboxData,
9
+ normalizeComboboxData,
10
+ } from "@recursica/adapter-common";
7
11
  import {
8
12
  filterStylingProps,
9
13
  omitUnsupportedProps,
10
14
  type RecursicaOverStyled,
11
15
  } from "../../utils/filterStylingProps";
16
+ import { renderRichOptionContent } from "../../utils/renderRichOption";
12
17
  import styles from "./Dropdown.module.css";
13
18
 
14
19
  /**
@@ -26,10 +31,7 @@ export interface BareDropdownProps
26
31
  MuiSelectProps,
27
32
  "size" | "variant" | "classes" | "error" | "onChange"
28
33
  > {
29
- data: (
30
- | string
31
- | { value: string; label: React.ReactNode; disabled?: boolean }
32
- )[];
34
+ data: RecursicaComboboxData;
33
35
  /** Normalized to just the selected value, unlike MUI's raw (event, child) Select onChange. */
34
36
  onChange?: (value: string | null) => void;
35
37
  /** Applies the error visual state (via `data-error`) — no error message is rendered here. */
@@ -73,8 +75,19 @@ export const BareDropdown = forwardRef<
73
75
 
74
76
  const selectedValue = value ?? defaultValue;
75
77
 
78
+ const optionClassNames = {
79
+ optionContent: styles.optionContent,
80
+ optionIcon: styles.optionIcon,
81
+ optionText: styles.optionText,
82
+ optionTextWrap: styles.optionTextWrap,
83
+ optionSupportingText: styles.optionSupportingText,
84
+ };
85
+
86
+ // See Dropdown.tsx's identical use of `normalizeComboboxData` (adapter-common).
87
+ const normalizedData = normalizeComboboxData(data) ?? [];
88
+
76
89
  const renderOptions = () =>
77
- data.map((item, index) => {
90
+ normalizedData.map((item, index) => {
78
91
  if (typeof item === "string") {
79
92
  return (
80
93
  <MenuItem
@@ -98,11 +111,24 @@ export const BareDropdown = forwardRef<
98
111
  className={styles.option}
99
112
  data-selected={item.value === selectedValue ? "true" : undefined}
100
113
  >
101
- {item.label}
114
+ {renderRichOptionContent(item, optionClassNames)}
102
115
  </MenuItem>
103
116
  );
104
117
  });
105
118
 
119
+ // See Dropdown.tsx's identical `renderValue` — keeps the closed field showing just the plain
120
+ // label now that a MenuItem's children can be a rich icon+label+supportingText row.
121
+ const renderValue = (selected: unknown) => {
122
+ if (selected === "" || selected === undefined || selected === null) {
123
+ return "";
124
+ }
125
+ const match = normalizedData.find((item) =>
126
+ typeof item === "string" ? item === selected : item.value === selected,
127
+ );
128
+ if (!match) return "";
129
+ return typeof match === "string" ? match : match.label;
130
+ };
131
+
106
132
  return (
107
133
  <MuiSelect
108
134
  ref={ref}
@@ -112,6 +138,7 @@ export const BareDropdown = forwardRef<
112
138
  defaultValue={defaultValue}
113
139
  onChange={(event) => onChange?.((event.target.value as string) ?? null)}
114
140
  displayEmpty
141
+ renderValue={renderValue}
115
142
  error={!!error}
116
143
  className={mergedClassName}
117
144
  classes={{
@@ -0,0 +1,15 @@
1
+ # Dropdown Implementation Notes
2
+
3
+ ## Rich Option Content (`leadingIcon`/`supportingText`)
4
+
5
+ `data` items accept optional `leadingIcon`/`supportingText` fields, via the shared `RecursicaComboboxItem` type in `@recursica/adapter-common` (see `MANTINE_ADAPTER_RICH_OPTION_DATA.md` at the repo root — proposed against the mantine-adapter, but Forge needs both adapters to accept the same `data` shape, hence the type living in adapter-common rather than being redeclared per adapter). `Dropdown.tsx` and `BareDropdown.tsx` both build `MenuItem` children directly from `data` (there's no Mantine-style `renderOption` indirection here), so rendering the new fields is a straight change to that existing per-item mapping — `renderRichOptionContent` (`../../utils/renderRichOption.tsx`, shared with `Autocomplete`) renders `leadingIcon`+`label`+`supportingText` when either new field is present, or just `label` otherwise. The icon is only rendered as a child when `leadingIcon` is set (not a hidden reserved slot), so label/supportingText shift left when there's no icon; the row's `align-items: center` keeps the label vertically centered when there's no `supportingText` to stack under it.
6
+
7
+ `label` on the shared type is optional (falls back to `value`) — both components run `data` through adapter-common's `normalizeComboboxData` first (`const normalizedData = normalizeComboboxData(data)`), so every downstream read of `item.label` is a real string, with no per-call-site `?? value` fallback needed.
8
+
9
+ MUI's closed-field display has no separate slot analogous to Mantine's `option.label` — without a `renderValue`, MUI shows whichever `MenuItem`'s children matched the selected value, which would leak the rich icon/supporting-text row into the closed field. Both components now pass a `renderValue` that looks the selected item back up by value and returns its plain `label`, keeping the closed field a single line of text regardless of what the open dropdown renders.
10
+
11
+ New CSS classes (`.optionContent`/`.optionIcon`/`.optionText`/`.optionSupportingText`) in `Dropdown.module.css` reuse the menu-item component's icon/supporting-text tokens, matching the mantine-adapter's equivalent addition — no dedicated dropdown-option tokens exist for either.
12
+
13
+ ## `wrapItemText`
14
+
15
+ `label`/`supportingText` default to single-line truncation with an ellipsis (`.optionText > *` — `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`). Passing `wrapItemText` adds `.optionTextWrap` alongside `.optionText`, re-enabling wrapping (`white-space: normal; overflow-wrap: anywhere`) for both children — later-cascade-wins, equal specificity. `renderRichOptionContent` takes `wrapItemText` as a third parameter and combines the two class names when it's true. `Dropdown.tsx` exposes this as a public prop; `BareDropdown.tsx` (internal-only, not part of the public `Dropdown`/`AutoComplete` API this was requested for) doesn't take the prop and always truncates.