@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/CHANGELOG.md +23 -0
- package/dist/index.d.ts +48 -7
- package/dist/mui-adapter.cjs +66 -66
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +6841 -6756
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Autocomplete/AUTOCOMPLETE_IMPLEMENTATION_NOTES.md +17 -0
- package/src/components/Autocomplete/Autocomplete.module.css +121 -12
- package/src/components/Autocomplete/Autocomplete.stories.tsx +149 -0
- package/src/components/Autocomplete/Autocomplete.tsx +47 -2
- package/src/components/Autocomplete/USAGE.md +26 -0
- package/src/components/Dropdown/BareDropdown.tsx +34 -7
- package/src/components/Dropdown/DROPDOWN_IMPLEMENTATION_NOTES.md +15 -0
- package/src/components/Dropdown/Dropdown.module.css +113 -4
- package/src/components/Dropdown/Dropdown.stories.tsx +149 -0
- package/src/components/Dropdown/Dropdown.tsx +37 -4
- package/src/components/Dropdown/USAGE.md +20 -0
- package/src/components/Menu/IMPLEMENTATION_NOTES.md +1 -0
- package/src/components/Menu/Menu.stories.tsx +37 -0
- package/src/components/Menu/Menu.tsx +25 -5
- package/src/components/Menu/USAGE.md +6 -0
- package/src/utils/renderRichOption.tsx +51 -0
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.
|
|
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.
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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 {
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|