@recursica/mantine-adapter 0.48.1 → 0.49.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.
@@ -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 { renderRichOption } 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 floating/portal menu — inside a
197
+ // container sized to Dropdown's own max-width token. Spacing between rows, icon/supportingText
198
+ // presence-or-absence alignment, and long-text wrapping/truncation are all much easier to inspect
199
+ // this way than by opening the real (portal-rendered) combobox dropdown. 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
+ {renderRichOption(
212
+ { option: 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.",
@@ -3,7 +3,10 @@ import {
3
3
  Select as MantineSelect,
4
4
  type SelectProps as MantineSelectProps,
5
5
  } from "@mantine/core";
6
- import { type ReadOnlyControlProps } from "@recursica/adapter-common";
6
+ import {
7
+ type ReadOnlyControlProps,
8
+ normalizeComboboxData,
9
+ } from "@recursica/adapter-common";
7
10
  import {
8
11
  filterStylingProps,
9
12
  omitUnsupportedProps,
@@ -12,6 +15,7 @@ import {
12
15
  } from "../../utils/filterStylingProps";
13
16
  import { type RecursicaFormControlWrapperProps } from "../FormControlWrapper/FormControlWrapper";
14
17
  import { WithReadOnlyWrapper } from "../ReadOnlyField/WithReadOnlyWrapper";
18
+ import { renderRichOption } from "../../utils/renderRichOption";
15
19
  import styles from "./Dropdown.module.css";
16
20
 
17
21
  import { type RecursicaDropdownProps as BaseRecursicaDropdownProps } from "@recursica/adapter-common";
@@ -69,6 +73,8 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
69
73
  defaultValue,
70
74
  data,
71
75
  clearButtonProps,
76
+ renderOption,
77
+ wrapItemText = false,
72
78
  ...rest
73
79
  } = props;
74
80
  const sanitizedProps = omitUnsupportedProps(
@@ -77,6 +83,13 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
77
83
  );
78
84
  const restRecord = sanitizedProps as Record<string, unknown>;
79
85
 
86
+ // Mantine's own data parser only preserves extra fields (`leadingIcon`/`supportingText`) when
87
+ // an item already has both `value` and `label` — an item with `value` only is rebuilt into a
88
+ // bare `{value, label: value, disabled}` object, silently dropping them (see
89
+ // get-parsed-combobox-data.mjs, and AutoComplete.tsx's identical use of this). Matters here
90
+ // too now that `label` is optional (shared `RecursicaComboboxItem` — see adapter-common).
91
+ const normalizedData = normalizeComboboxData(data);
92
+
80
93
  // Mantine's own clear button (rendered when `clearable` + a value are both present) otherwise
81
94
  // renders unstyled — bare `CloseButton` defaults, no Recursica icon-button treatment. Merge in
82
95
  // our own class (see `.clearButton` in Dropdown.module.css) alongside anything the caller
@@ -104,6 +117,14 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
104
117
  restRecord.classNames as Partial<Record<string, string>> | undefined,
105
118
  );
106
119
 
120
+ const optionClassNames = {
121
+ optionContent: styles.optionContent,
122
+ optionIcon: styles.optionIcon,
123
+ optionText: styles.optionText,
124
+ optionTextWrap: styles.optionTextWrap,
125
+ optionSupportingText: styles.optionSupportingText,
126
+ };
127
+
107
128
  const injectedStyles = {
108
129
  ...((style as React.CSSProperties) || {}),
109
130
  width: containerWidth || "100%",
@@ -154,7 +175,14 @@ export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
154
175
  disabled={disabled}
155
176
  value={value}
156
177
  defaultValue={defaultValue}
157
- data={(data as unknown as MantineSelectProps["data"]) || []}
178
+ data={
179
+ (normalizedData as unknown as MantineSelectProps["data"]) || []
180
+ }
181
+ renderOption={
182
+ renderOption ??
183
+ ((input) =>
184
+ renderRichOption(input, optionClassNames, wrapItemText))
185
+ }
158
186
  clearButtonProps={mergedClearButtonProps}
159
187
  label={undefined}
160
188
  description={undefined}
@@ -46,3 +46,24 @@ All Recursica components in the `@recursica/mantine-adapter` package adhere stri
46
46
 
47
47
  - `Dropdown` is styled independently from `TextField`; even though they look similar, they are themed using separate design tokens.
48
48
  - Pass `leftSection` 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.
49
+ - `data` items can carry a `leadingIcon` and `supportingText`, rendered inside each option row:
50
+
51
+ ```tsx
52
+ <Dropdown
53
+ label="Assignee"
54
+ data={[
55
+ {
56
+ value: "jdoe",
57
+ label: "Jane Doe",
58
+ leadingIcon: <UserIcon />,
59
+ supportingText: "jane.doe@example.com",
60
+ },
61
+ { value: "asmith", label: "Alex Smith" },
62
+ ]}
63
+ />
64
+ ```
65
+
66
+ Pass your own `renderOption` to opt out of this default rendering for a given instance.
67
+
68
+ By default `label`/`supportingText` truncate to a single line with an ellipsis. Set
69
+ `wrapItemText` to wrap them onto additional lines instead: `<Dropdown data={data} wrapItemText />`.
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+ import {
3
+ type ComboboxStringItem,
4
+ type ComboboxLikeRenderOptionInput,
5
+ } from "@mantine/core";
6
+
7
+ // `ComboboxStringItem`/`ComboboxItem` only type `value`/`label`/`disabled` —
8
+ // `leadingIcon`/`supportingText` are extra fields Mantine's own `getParsedComboboxData` passes
9
+ // through untouched whenever an item already has both `value` and `label` (see
10
+ // get-parsed-combobox-data.mjs), so they reach `option` here at runtime even though the upstream
11
+ // type doesn't know about them. Generic over both Autocomplete's `ComboboxStringItem` (optional
12
+ // `label`) and Dropdown's `ComboboxItem` (required `label`) — the same renderer serves both.
13
+ type RichComboboxItem = ComboboxStringItem & {
14
+ label?: string;
15
+ leadingIcon?: React.ReactNode;
16
+ supportingText?: string;
17
+ };
18
+
19
+ export interface RichOptionClassNames {
20
+ optionContent: string;
21
+ optionIcon: string;
22
+ optionText: string;
23
+ /** Combined with `optionText` when `wrapItemText` is true — see `renderRichOption` below. */
24
+ optionTextWrap: string;
25
+ optionSupportingText: string;
26
+ }
27
+
28
+ /**
29
+ * `renderOption` for Autocomplete/Dropdown that renders an item's `leadingIcon`/`supportingText`
30
+ * (see MANTINE_ADAPTER_RICH_OPTION_DATA.md) inside the option row. Falls back to plain
31
+ * `option.label` — the same rendering Mantine uses when no `renderOption` is supplied at all — so
32
+ * items without the new fields are unaffected.
33
+ *
34
+ * `wrapItemText` (default `false`) controls whether `label`/`supportingText` wrap onto additional
35
+ * lines or truncate to a single line with an ellipsis.
36
+ */
37
+ export function renderRichOption<T extends ComboboxStringItem>(
38
+ { option }: ComboboxLikeRenderOptionInput<T>,
39
+ classNames: RichOptionClassNames,
40
+ wrapItemText = false,
41
+ ): React.ReactNode {
42
+ const { label, leadingIcon, supportingText } = option as RichComboboxItem;
43
+ const displayLabel = label ?? option.value;
44
+ if (!leadingIcon && !supportingText) {
45
+ return displayLabel;
46
+ }
47
+ const optionTextClassName = wrapItemText
48
+ ? `${classNames.optionText} ${classNames.optionTextWrap}`
49
+ : classNames.optionText;
50
+ return (
51
+ <span className={classNames.optionContent}>
52
+ {leadingIcon && (
53
+ <span className={classNames.optionIcon}>{leadingIcon}</span>
54
+ )}
55
+ <span className={optionTextClassName}>
56
+ <span>{displayLabel}</span>
57
+ {supportingText && (
58
+ <span className={classNames.optionSupportingText}>
59
+ {supportingText}
60
+ </span>
61
+ )}
62
+ </span>
63
+ </span>
64
+ );
65
+ }