Skip to content

Commit a7d2799

Browse files
Add customContent prop to ComboboxOption to render a custom react component (#3505)
1 parent a048363 commit a7d2799

File tree

9 files changed

+191
-34
lines changed

9 files changed

+191
-34
lines changed

.changeset/dark-boxes-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@leafygreen-ui/combobox': minor
3+
---
4+
5+
Added a customContent prop to render a custom component in combobox option

packages/combobox/README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,15 @@ import { Combobox, ComboboxOption } from '@leafygreen-ui/combobox';
8787

8888
## Props
8989

90-
| Prop | Type | Description | Default |
91-
| ------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
92-
| `value` | `string` | The internal value of the option. Used as the identifier in Combobox `initialValue`, `value` and `filteredOptions`. When undefined, this is set to `_.kebabCase(displayName)` | |
93-
| `displayName` | `string` | The display value of the option. Used as the rendered string within the menu and chips. When undefined, this is set to `value` | |
94-
| `glyph` | `<Icon/>` | The icon to display to the left of the option in the menu. | |
95-
| `className` | `string` | The className passed to the root element of the component. | |
96-
| `description` | `string` | Optional descriptive text under the displayName. | |
97-
| `onClick` | `(event: React.SyntheticEvent<HTMLLIElement, Event>, value: string) => void` | Callback fired when an option is clicked. Returns the event and the option value. | |
90+
| Prop | Type | Description | Default |
91+
| --------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
92+
| `value` | `string` | The internal value of the option. Used as the identifier in Combobox `initialValue`, `value` and `filteredOptions`. When undefined, this is set to `_.kebabCase(displayName)` | |
93+
| `displayName` | `string` | The display value of the option. Used as the rendered string within the menu and chips. When undefined, this is set to `value` | |
94+
| `customContent` | `ReactNode` | Optional custom content to render for the option. When provided, this ReactNode will be rendered in the option menu | |
95+
| `glyph` | `<Icon/>` | The icon to display to the left of the option in the menu. | |
96+
| `className` | `string` | The className passed to the root element of the component. | |
97+
| `description` | `string` | Optional descriptive text under the displayName. | |
98+
| `onClick` | `(event: React.SyntheticEvent<HTMLLIElement, Event>, value: string) => void` | Callback fired when an option is clicked. Returns the event and the option value. | |
9899

99100
# ComboboxGroup
100101

packages/combobox/src/Combobox.stories.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { StoryContext, StoryFn } from '@storybook/react';
99
import { userEvent, within } from '@storybook/test';
1010

11+
import { Badge } from '@leafygreen-ui/badge';
1112
import { Button } from '@leafygreen-ui/button';
1213
import { css } from '@leafygreen-ui/emotion';
1314

@@ -237,6 +238,57 @@ ExternalFilter.parameters = {
237238
chromatic: { disableSnapshot: true },
238239
};
239240

241+
/**
242+
* Example showing the `customContent` prop for rendering custom components in dropdown options.
243+
* The `customContent` prop accepts any ReactNode, allowing you to add badges, icons, or other
244+
* custom components to your options. The `displayName` is still used for filtering and chips.
245+
*/
246+
export const WithCustomContent = () => {
247+
return (
248+
<Combobox
249+
label="Choose a feature"
250+
description="Some features are new!"
251+
placeholder="Select a feature"
252+
multiselect={false}
253+
>
254+
<ComboboxOption
255+
value="feature-a"
256+
displayName="Feature A"
257+
customContent={
258+
<>
259+
<span>Feature A</span>
260+
<Badge variant="blue">New</Badge>
261+
</>
262+
}
263+
/>
264+
<ComboboxOption
265+
value="feature-b"
266+
displayName="Feature B"
267+
customContent={
268+
<>
269+
<span>Feature B</span>
270+
<Badge variant="green">Beta</Badge>
271+
</>
272+
}
273+
/>
274+
<ComboboxOption value="feature-c" displayName="Feature C" />
275+
<ComboboxOption
276+
value="feature-d"
277+
displayName="Feature D"
278+
customContent={
279+
<>
280+
<span>Feature D</span>
281+
<Badge variant="red">Deprecated</Badge>
282+
</>
283+
}
284+
/>
285+
</Combobox>
286+
);
287+
};
288+
WithCustomContent.parameters = {
289+
chromatic: { disableSnapshot: true },
290+
};
291+
240292
export const SingleSelect: StoryType<typeof Combobox> = () => <></>;
241293
SingleSelect.args = {
242294
multiselect: false,

packages/combobox/src/Combobox/Combobox.spec.tsx

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { axe } from 'jest-axe';
1313
import flatten from 'lodash/flatten';
1414
import isUndefined from 'lodash/isUndefined';
1515

16+
import { Badge } from '@leafygreen-ui/badge';
1617
import { RenderMode } from '@leafygreen-ui/popover';
1718
import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib';
1819

@@ -279,15 +280,11 @@ describe('packages/combobox', () => {
279280
expect(optionEl).toHaveTextContent('abc-def');
280281
});
281282

282-
test('Option aria-label falls back to displayName text content', () => {
283+
test('Option aria-label falls back to displayName', () => {
283284
const options: Array<OptionObject> = [
284285
{
285286
value: 'react-node-option',
286-
displayName: (
287-
<span>
288-
<strong>Bold</strong> and <em>italic</em> text
289-
</span>
290-
),
287+
displayName: 'Bold and italic text',
291288
isDisabled: false,
292289
},
293290
];
@@ -297,6 +294,100 @@ describe('packages/combobox', () => {
297294
expect(optionEl).toHaveAttribute('aria-label', 'Bold and italic text');
298295
});
299296

297+
test('displayName renders correctly', () => {
298+
const options: Array<OptionObject> = [
299+
{
300+
value: 'legacy-option',
301+
displayName: 'Legacy String Display Name',
302+
isDisabled: false,
303+
},
304+
];
305+
const { openMenu } = renderCombobox(select, { options });
306+
const { optionElements } = openMenu();
307+
const [optionEl] = Array.from(optionElements!);
308+
309+
// Should render the string displayName
310+
expect(optionEl).toHaveTextContent('Legacy String Display Name');
311+
expect(optionEl).toHaveAttribute(
312+
'aria-label',
313+
'Legacy String Display Name',
314+
);
315+
});
316+
317+
test('customContent with Badge component renders correctly', () => {
318+
const { openMenu } = renderCombobox(select, {
319+
children: (
320+
<ComboboxOption
321+
value="new-feature"
322+
displayName="New Feature"
323+
customContent={
324+
<>
325+
Custom Content Component
326+
<Badge variant="blue" data-testid="custom-badge">
327+
New
328+
</Badge>
329+
</>
330+
}
331+
/>
332+
),
333+
});
334+
const { optionElements } = openMenu();
335+
const [optionEl] = Array.from(optionElements!) as Array<Element>;
336+
337+
// Should render the custom content with Badge (not the displayName)
338+
expect(optionEl).toHaveTextContent('Custom Content Component');
339+
expect(optionEl).not.toHaveTextContent('New Feature');
340+
341+
// Should render the Badge component
342+
const badgeEl = optionEl.querySelector('[data-testid="custom-badge"]');
343+
expect(badgeEl).toBeInTheDocument();
344+
345+
// aria-label should still use the string displayName
346+
expect(optionEl).toHaveAttribute('aria-label', 'New Feature');
347+
});
348+
349+
test('option with customContent can be selected', async () => {
350+
const onChange = jest.fn();
351+
const { openMenu, inputEl, queryByTestId } = renderCombobox(select, {
352+
onChange,
353+
children: (
354+
<ComboboxOption
355+
value="new-feature"
356+
displayName="New Feature"
357+
customContent={
358+
<>
359+
Custom Content
360+
<Badge variant="blue">New</Badge>
361+
</>
362+
}
363+
/>
364+
),
365+
});
366+
const { optionElements } = openMenu();
367+
const [optionEl] = Array.from(optionElements!) as Array<Element>;
368+
369+
userEvent.click(optionEl as Element);
370+
371+
if (select === 'single') {
372+
expect(onChange).toHaveBeenCalledWith('new-feature');
373+
// Only displayName should be rendered in the input, not customContent
374+
expect(inputEl).toHaveValue('New Feature');
375+
expect(inputEl).not.toHaveValue('Custom Content');
376+
} else {
377+
expect(onChange).toHaveBeenCalledWith(
378+
['new-feature'],
379+
expect.anything(),
380+
);
381+
// Only displayName should be rendered in the chip, not customContent
382+
await waitFor(() => {
383+
const chip = queryByTestId('lg-combobox-chip');
384+
expect(chip).toBeInTheDocument();
385+
expect(chip).toHaveTextContent('New Feature');
386+
expect(chip).not.toHaveTextContent('Custom Content');
387+
});
388+
}
389+
});
390+
300391
test('Option aria-label falls back to value when displayName is not provided', () => {
301392
const options = [{ value: 'fallback-value' }];
302393
/// @ts-expect-error `options` will not match the expected type

packages/combobox/src/ComboboxOption/ComboboxOption.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export const WithIconsAndCustomDisplayName: StoryType<
8181
WithIconsAndCustomDisplayName.parameters = {
8282
generate: {
8383
args: {
84-
displayName: (
84+
displayName: 'Option',
85+
customContent: (
8586
<div
8687
className={css`
8788
display: flex;

packages/combobox/src/ComboboxOption/ComboboxOption.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const InternalComboboxOption = React.forwardRef<
3030
glyph,
3131
isSelected,
3232
displayName,
33+
customContent,
3334
isFocused,
3435
setSelected,
3536
className,
@@ -94,14 +95,17 @@ export const InternalComboboxOption = React.forwardRef<
9495
// When multiselect and withoutIcons the Checkbox is aligned to the top instead of centered.
9596
const multiSelectWithoutIcons = multiselect && !withIcons;
9697

98+
// Convert displayName ReactNode to string for aria-label and wrapJSX
99+
const displayNameStr = getNodeTextContent(displayName);
100+
97101
return (
98102
<InputOption
99103
{...rest}
100104
as="li"
101105
ref={optionRef}
102106
highlighted={isFocused}
103107
disabled={disabled}
104-
aria-label={ariaLabel || getNodeTextContent(displayName) || value}
108+
aria-label={ariaLabel || displayNameStr || value}
105109
darkMode={darkMode}
106110
className={getInputOptionStyles({
107111
size,
@@ -117,12 +121,12 @@ export const InternalComboboxOption = React.forwardRef<
117121
rightGlyph={rightGlyph}
118122
description={description}
119123
>
120-
{typeof displayName === 'string' ? (
124+
{customContent ? (
125+
customContent
126+
) : (
121127
<span id={optionTextId}>
122-
{wrapJSX(displayName, inputValue, 'strong')}
128+
{wrapJSX(displayNameStr, inputValue, 'strong')}
123129
</span>
124-
) : (
125-
displayName
126130
)}
127131
</InputOptionContent>
128132
</InputOption>

packages/combobox/src/ComboboxOption/ComboboxOption.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ interface SharedComboboxOptionProps {
2121
*/
2222
displayName?: ReactNode;
2323

24+
/**
25+
* Optional custom content to render for the option.
26+
* When provided, this ReactNode will be rendered in the option menu
27+
*/
28+
customContent?: ReactNode;
29+
2430
/**
2531
* The icon to display to the left of the option in the menu.
2632
*/

packages/combobox/src/utils/ComboboxTestUtils.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export const getComboboxJSX = (props?: renderComboboxProps) => {
121121

122122
const label = props?.label ?? 'Some label';
123123
const options = props?.options ?? defaultOptions;
124+
const children = props?.children;
124125
return (
125126
<LeafyGreenProvider>
126127
<Combobox
@@ -129,7 +130,7 @@ export const getComboboxJSX = (props?: renderComboboxProps) => {
129130
multiselect={props?.multiselect ?? false}
130131
{...props}
131132
>
132-
{options.map(renderOption)}
133+
{children ?? options.map(renderOption)}
133134
</Combobox>
134135
</LeafyGreenProvider>
135136
);

packages/combobox/src/utils/ComboboxUtils.spec.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -202,21 +202,16 @@ describe('packages/combobox/utils', () => {
202202
expect(result).toBe('test');
203203
});
204204

205-
test('Returns React node displayName when option has node displayName', () => {
206-
const nodeDisplayName = (
207-
<span>
208-
<strong>Bold</strong> text
209-
</span>
210-
);
211-
const optionsWithNode = [
205+
test('Returns string displayName when option has string displayName', () => {
206+
const optionsWithString = [
212207
{
213-
value: 'node-option',
214-
displayName: nodeDisplayName,
208+
value: 'string-option',
209+
displayName: 'Bold text',
215210
isDisabled: false,
216211
},
217212
];
218-
const result = getDisplayNameForValue('node-option', optionsWithNode);
219-
expect(result).toBe(nodeDisplayName);
213+
const result = getDisplayNameForValue('string-option', optionsWithString);
214+
expect(result).toBe('Bold text');
220215
});
221216
});
222217

@@ -280,12 +275,13 @@ describe('packages/combobox/utils', () => {
280275
]);
281276
});
282277

283-
test('flattens options with node displayName', () => {
278+
test('flattens options with customContent', () => {
284279
const children = [
285280
<ComboboxOption
286281
key="test"
287282
value="test"
288-
displayName={
283+
displayName="Testing New"
284+
customContent={
289285
<div>
290286
<span>Testing</span>
291287
<span>New</span>

0 commit comments

Comments
 (0)