Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-worlds-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@leafygreen-ui/combobox': minor
---

`ComboboxOption` now accepts a `ReactNode` for the `displayName` prop, enabling custom content like badges. Also refactored internal styles for better organization.
1 change: 1 addition & 0 deletions packages/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@leafygreen-ui/leafygreen-provider": "workspace:^5.0.0 || ^4.0.0 || ^3.2.0"
},
"devDependencies": {
"@leafygreen-ui/badge": "workspace:^",
"@leafygreen-ui/button": "workspace:^",
"@lg-tools/build": "workspace:^"
},
Expand Down
15 changes: 8 additions & 7 deletions packages/combobox/src/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type StoryMetaType,
StoryType,
} from '@lg-tools/storybook-utils';
import { StoryFn } from '@storybook/react';
import { StoryContext, StoryFn } from '@storybook/react';
import { userEvent, within } from '@storybook/test';

import { Button } from '@leafygreen-ui/button';
Expand Down Expand Up @@ -150,15 +150,15 @@ const meta: StoryMetaType<typeof Combobox> = {

export default meta;

export const LiveExample: StoryFn<ComboboxProps<boolean>> = (
args: ComboboxProps<boolean>,
) => {
export const LiveExample: StoryFn<ComboboxProps<boolean>> = args => {
return (
<>
{/* Since Combobox doesn't fully refresh when `multiselect` changes, we need to explicitly render a different instance */}
{args.multiselect ? (
// @ts-ignore - multiselect check ensures props match ComboboxProps<true>
<Combobox key="multi" {...args} multiselect={true} />
) : (
// @ts-ignore - multiselect check ensures props match ComboboxProps<false>
<Combobox key="single" {...args} multiselect={false} />
)}
</>
Expand Down Expand Up @@ -265,6 +265,7 @@ export const MultiSelectNoIcons: StoryFn<ComboboxProps<boolean>> = (
args: ComboboxProps<boolean>,
) => {
return (
// @ts-expect-error - args will have multiselect=true from storybook controls
<Combobox {...args} multiselect={true}>
{getComboboxOptions(false)}
</Combobox>
Expand Down Expand Up @@ -299,20 +300,20 @@ export const InitialLongComboboxOpen = {
</Combobox>
);
},
play: async ctx => {
play: async (ctx: StoryContext) => {
const { findByRole } = within(ctx.canvasElement.parentElement!);
const trigger = await findByRole('combobox');
userEvent.click(trigger);
},
decorators: [
(StoryFn, _ctx) => (
(Story: StoryFn, _ctx: StoryContext) => (
<div
className={css`
height: 100vh;
padding: 0;
`}
>
<StoryFn />
<Story />
</div>
),
],
Expand Down
56 changes: 53 additions & 3 deletions packages/combobox/src/Combobox/Combobox.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/* eslint-disable jest/no-standalone-expect */
/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectSelection"] }] */
import { createRef } from 'react';
import React, { createRef } from 'react';
import {
act,
queryByText,
render,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
Expand All @@ -12,6 +13,7 @@
import flatten from 'lodash/flatten';
import isUndefined from 'lodash/isUndefined';

import { Badge } from '@leafygreen-ui/badge';

Check warning on line 16 in packages/combobox/src/Combobox/Combobox.spec.tsx

View workflow job for this annotation

GitHub Actions / Check lints

'Badge' is defined but never used. Allowed unused vars must match /^_/u
import { RenderMode } from '@leafygreen-ui/popover';
import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib';

Expand All @@ -25,6 +27,7 @@
Select,
testif,
} from '../utils/ComboboxTestUtils';
import { Combobox, ComboboxOption } from '..';

/**
* Tests
Expand Down Expand Up @@ -262,7 +265,9 @@
const { optionElements } = openMenu();
// Note on `foo!` operator https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#non-null-assertion-operator
Array.from(optionElements!).forEach((optionEl, index) => {
expect(optionEl).toHaveTextContent(defaultOptions[index].displayName);
expect(optionEl).toHaveTextContent(
defaultOptions[index].displayName as string,
);
});
});

Expand All @@ -275,6 +280,49 @@
expect(optionEl).toHaveTextContent('abc-def');
});

test('Option aria-label falls back to displayName text content', () => {
const options: Array<OptionObject> = [
{
value: 'react-node-option',
displayName: (
<span>
<strong>Bold</strong> and <em>italic</em> text
</span>
),
isDisabled: false,
},
];
const { openMenu } = renderCombobox(select, { options });
const { optionElements } = openMenu();
const [optionEl] = Array.from(optionElements!);
expect(optionEl).toHaveAttribute('aria-label', 'Bold and italic text');
});

test('Option aria-label falls back to value when displayName is not provided', () => {
const options = [{ value: 'fallback-value' }];
/// @ts-expect-error `options` will not match the expected type
const { openMenu } = renderCombobox(select, { options });
const { optionElements } = openMenu();
const [optionEl] = Array.from(optionElements!);
expect(optionEl).toHaveAttribute('aria-label', 'fallback-value');
});

test('Option uses explicit aria-label prop when provided', () => {
const { getByRole, queryByRole } = render(
<Combobox label="Test" multiselect={select === 'multiple'}>
<ComboboxOption
value="test-value"
displayName="Display Name"
aria-label="Custom aria label"
/>
</Combobox>,
);
userEvent.click(getByRole('combobox'));
const listbox = queryByRole('listbox');
const optionEl = listbox?.getElementsByTagName('li')[0];
expect(optionEl).toHaveAttribute('aria-label', 'Custom aria label');
});

test('Options with long names are rendered with the full text', () => {
const displayName = `Donec id elit non mi porta gravida at eget metus. Aenean lacinia bibendum nulla sed consectetur.`;
const options: Array<OptionObject> = [
Expand Down Expand Up @@ -367,7 +415,9 @@
groupedOptions.map(({ children }: NestedObject) => children),
).forEach((option: OptionObject | string) => {
const displayName =
typeof option === 'string' ? option : option.displayName;
typeof option === 'string'
? option
: (option.displayName as string);
const optionEl = queryByText(menuContainerEl!, displayName);
expect(optionEl).toBeInTheDocument();
});
Expand Down
16 changes: 11 additions & 5 deletions packages/combobox/src/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ import LeafyGreenProvider, {
PopoverPropsProvider,
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
import { consoleOnce, isComponentType, keyMap } from '@leafygreen-ui/lib';
import {
consoleOnce,
getNodeTextContent,
isComponentType,
keyMap,
} from '@leafygreen-ui/lib';
import {
DismissMode,
getPopoverRenderModeProps,
Expand Down Expand Up @@ -324,7 +329,7 @@ export function Combobox<M extends boolean>({
? getDisplayNameForValue(value, allOptions)
: option.displayName;

const isValueInDisplayName = displayName
const isValueInDisplayName = getNodeTextContent(displayName)
.toLowerCase()
.includes(inputValue.toLowerCase());

Expand Down Expand Up @@ -718,6 +723,7 @@ export function Combobox<M extends boolean>({
if (isMultiselect(selection)) {
return selection.filter(isValueValid).map((value, index) => {
const displayName = getDisplayNameForValue(value, allOptions);
const displayNameContent = getNodeTextContent(displayName);
const isFocused = focusedChip === value;
const chipRef = getChipRef(value);
const isLastChip = index >= selection.length - 1;
Expand All @@ -740,7 +746,7 @@ export function Combobox<M extends boolean>({
return (
<ComboboxChip
key={value}
displayName={displayName}
displayName={displayNameContent}
isFocused={isFocused}
onRemove={onRemove}
onFocus={onFocus}
Expand Down Expand Up @@ -793,7 +799,7 @@ export function Combobox<M extends boolean>({
selection as SelectValueType<false>,
allOptions,
) ?? prevSelection;
updateInputValue(displayName);
updateInputValue(getNodeTextContent(displayName));
}
}
}, [
Expand Down Expand Up @@ -821,7 +827,7 @@ export function Combobox<M extends boolean>({
selection as SelectValueType<false>,
allOptions,
) ?? '';
updateInputValue(displayName);
updateInputValue(getNodeTextContent(displayName));
closeMenu();
}
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React from 'react';
import { StoryMetaType, StoryType } from '@lg-tools/storybook-utils';

import { Badge } from '@leafygreen-ui/badge';
import { css } from '@leafygreen-ui/emotion';
import { Icon } from '@leafygreen-ui/icon';
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
import { spacing } from '@leafygreen-ui/tokens';

import { ComboboxContext, defaultContext } from '../ComboboxContext';

Expand All @@ -14,7 +17,12 @@ const meta: StoryMetaType<typeof InternalComboboxOption> = {
parameters: {
default: null,
generate: {
storyNames: ['WithIcons', 'WithoutIcons', 'WithoutIconsAndMultiStep'],
storyNames: [
'WithIcons',
'WithoutIcons',
'WithoutIconsAndMultiStep',
'WithIconsAndCustomDisplayName',
],
combineArgs: {
darkMode: [false, true],
description: [undefined, 'This is a description'],
Expand Down Expand Up @@ -67,6 +75,32 @@ WithIcons.parameters = {
},
};

export const WithIconsAndCustomDisplayName: StoryType<
typeof InternalComboboxOption
> = () => <></>;
WithIconsAndCustomDisplayName.parameters = {
generate: {
args: {
displayName: (
<div
className={css`
display: flex;
align-items: center;
gap: ${spacing[100]}px;
margin-bottom: ${spacing[100]}px;
`}
>
<span>Option</span>
<Badge variant="green">New</Badge>
</div>
),
/// @ts-expect-error - withIcons is not a component prop
withIcons: true,
glyph: <Icon glyph="Cloud" />,
},
},
};

export const WithoutIconsAndMultiStep: StoryType<
typeof InternalComboboxOption
> = () => <></>;
Expand Down
37 changes: 34 additions & 3 deletions packages/combobox/src/ComboboxOption/ComboboxOption.styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css } from '@leafygreen-ui/emotion';
import { css, cx } from '@leafygreen-ui/emotion';
import { leftGlyphClassName } from '@leafygreen-ui/input-option';
import {
descriptionClassName,
Expand Down Expand Up @@ -50,8 +50,16 @@ export const disallowPointer = css`
pointer-events: none;
`;

export const displayNameStyle = (isSelected: boolean) => css`
font-weight: ${isSelected ? fontWeights.semiBold : fontWeights.regular};
const inputOptionBaseStyles = css`
.${titleClassName} {
font-weight: ${fontWeights.regular};
}
`;

const selectedInputOptionStyles = css`
.${titleClassName} {
font-weight: ${fontWeights.semiBold};
}
`;

export const iconThemeStyles: Record<Theme, string> = {
Expand Down Expand Up @@ -113,3 +121,26 @@ export const multiselectIconLargePosition = css`
top: 3px;
}
`;

export const getInputOptionStyles = ({
size,
isMultiselectWithoutIcons,
isSelected,
className,
}: {
size: ComboboxSize;
isMultiselectWithoutIcons: boolean;
isSelected: boolean;
className?: string;
}) =>
cx(
inputOptionBaseStyles,
{
[selectedInputOptionStyles]: isSelected,
[largeStyles]: size === ComboboxSize.Large,
[multiselectIconPosition]: isMultiselectWithoutIcons,
[multiselectIconLargePosition]:
isMultiselectWithoutIcons && size === ComboboxSize.Large,
},
className,
);
Loading
Loading