diff --git a/.changeset/eighty-worlds-worry.md b/.changeset/eighty-worlds-worry.md new file mode 100644 index 0000000000..f755cf73b8 --- /dev/null +++ b/.changeset/eighty-worlds-worry.md @@ -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. diff --git a/packages/combobox/package.json b/packages/combobox/package.json index eb5db255b9..0f1975e245 100644 --- a/packages/combobox/package.json +++ b/packages/combobox/package.json @@ -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:^" }, diff --git a/packages/combobox/src/Combobox.stories.tsx b/packages/combobox/src/Combobox.stories.tsx index f666f71708..8a52ded021 100644 --- a/packages/combobox/src/Combobox.stories.tsx +++ b/packages/combobox/src/Combobox.stories.tsx @@ -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'; @@ -150,15 +150,15 @@ const meta: StoryMetaType = { export default meta; -export const LiveExample: StoryFn> = ( - args: ComboboxProps, -) => { +export const LiveExample: StoryFn> = 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 ) : ( + // @ts-ignore - multiselect check ensures props match ComboboxProps )} @@ -265,6 +265,7 @@ export const MultiSelectNoIcons: StoryFn> = ( args: ComboboxProps, ) => { return ( + // @ts-expect-error - args will have multiselect=true from storybook controls {getComboboxOptions(false)} @@ -299,20 +300,20 @@ export const InitialLongComboboxOpen = { ); }, - 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) => (
- +
), ], diff --git a/packages/combobox/src/Combobox/Combobox.spec.tsx b/packages/combobox/src/Combobox/Combobox.spec.tsx index c59e54837a..bf3bb99340 100644 --- a/packages/combobox/src/Combobox/Combobox.spec.tsx +++ b/packages/combobox/src/Combobox/Combobox.spec.tsx @@ -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'; @@ -12,6 +13,7 @@ import { axe } from 'jest-axe'; import flatten from 'lodash/flatten'; import isUndefined from 'lodash/isUndefined'; +import { Badge } from '@leafygreen-ui/badge'; import { RenderMode } from '@leafygreen-ui/popover'; import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib'; @@ -25,6 +27,7 @@ import { Select, testif, } from '../utils/ComboboxTestUtils'; +import { Combobox, ComboboxOption } from '..'; /** * Tests @@ -262,7 +265,9 @@ describe('packages/combobox', () => { 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, + ); }); }); @@ -275,6 +280,49 @@ describe('packages/combobox', () => { expect(optionEl).toHaveTextContent('abc-def'); }); + test('Option aria-label falls back to displayName text content', () => { + const options: Array = [ + { + value: 'react-node-option', + displayName: ( + + Bold and italic text + + ), + 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( + + + , + ); + 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 = [ @@ -367,7 +415,9 @@ describe('packages/combobox', () => { 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(); }); diff --git a/packages/combobox/src/Combobox/Combobox.tsx b/packages/combobox/src/Combobox/Combobox.tsx index 84c2d64a1f..1080bff550 100644 --- a/packages/combobox/src/Combobox/Combobox.tsx +++ b/packages/combobox/src/Combobox/Combobox.tsx @@ -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, @@ -324,7 +329,7 @@ export function Combobox({ ? getDisplayNameForValue(value, allOptions) : option.displayName; - const isValueInDisplayName = displayName + const isValueInDisplayName = getNodeTextContent(displayName) .toLowerCase() .includes(inputValue.toLowerCase()); @@ -718,6 +723,7 @@ export function Combobox({ 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; @@ -740,7 +746,7 @@ export function Combobox({ return ( ({ selection as SelectValueType, allOptions, ) ?? prevSelection; - updateInputValue(displayName); + updateInputValue(getNodeTextContent(displayName)); } } }, [ @@ -821,7 +827,7 @@ export function Combobox({ selection as SelectValueType, allOptions, ) ?? ''; - updateInputValue(displayName); + updateInputValue(getNodeTextContent(displayName)); closeMenu(); } } else { diff --git a/packages/combobox/src/ComboboxOption/ComboboxOption.stories.tsx b/packages/combobox/src/ComboboxOption/ComboboxOption.stories.tsx index be3c09a3dc..02fb9864a9 100644 --- a/packages/combobox/src/ComboboxOption/ComboboxOption.stories.tsx +++ b/packages/combobox/src/ComboboxOption/ComboboxOption.stories.tsx @@ -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'; @@ -14,7 +17,12 @@ const meta: StoryMetaType = { parameters: { default: null, generate: { - storyNames: ['WithIcons', 'WithoutIcons', 'WithoutIconsAndMultiStep'], + storyNames: [ + 'WithIcons', + 'WithoutIcons', + 'WithoutIconsAndMultiStep', + 'WithIconsAndCustomDisplayName', + ], combineArgs: { darkMode: [false, true], description: [undefined, 'This is a description'], @@ -67,6 +75,32 @@ WithIcons.parameters = { }, }; +export const WithIconsAndCustomDisplayName: StoryType< + typeof InternalComboboxOption +> = () => <>; +WithIconsAndCustomDisplayName.parameters = { + generate: { + args: { + displayName: ( +
+ Option + New +
+ ), + /// @ts-expect-error - withIcons is not a component prop + withIcons: true, + glyph: , + }, + }, +}; + export const WithoutIconsAndMultiStep: StoryType< typeof InternalComboboxOption > = () => <>; diff --git a/packages/combobox/src/ComboboxOption/ComboboxOption.styles.ts b/packages/combobox/src/ComboboxOption/ComboboxOption.styles.ts index 990a5d38c0..c9bfe9b044 100644 --- a/packages/combobox/src/ComboboxOption/ComboboxOption.styles.ts +++ b/packages/combobox/src/ComboboxOption/ComboboxOption.styles.ts @@ -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, @@ -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 = { @@ -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, + ); diff --git a/packages/combobox/src/ComboboxOption/ComboboxOption.tsx b/packages/combobox/src/ComboboxOption/ComboboxOption.tsx index a7d491f3e3..0218da3eb5 100644 --- a/packages/combobox/src/ComboboxOption/ComboboxOption.tsx +++ b/packages/combobox/src/ComboboxOption/ComboboxOption.tsx @@ -1,20 +1,14 @@ import React, { useCallback, useContext, useMemo } from 'react'; -import { cx } from '@leafygreen-ui/emotion'; import { useForwardedRef, useIdAllocator } from '@leafygreen-ui/hooks'; import { InputOption, InputOptionContent } from '@leafygreen-ui/input-option'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { getNodeTextContent } from '@leafygreen-ui/lib'; import { ComboboxContext } from '../ComboboxContext'; -import { ComboboxSize } from '../types'; import { wrapJSX } from '../utils'; -import { - displayNameStyle, - largeStyles, - multiselectIconLargePosition, - multiselectIconPosition, -} from './ComboboxOption.styles'; +import { getInputOptionStyles } from './ComboboxOption.styles'; import { ComboboxOptionProps, InternalComboboxOptionProps, @@ -43,6 +37,7 @@ export const InternalComboboxOption = React.forwardRef< value, onClick, disabled = false, + 'aria-label': ariaLabel, ...rest }: InternalComboboxOptionProps, forwardedRef, @@ -106,17 +101,14 @@ export const InternalComboboxOption = React.forwardRef< ref={optionRef} highlighted={isFocused} disabled={disabled} - aria-label={displayName} + aria-label={ariaLabel || getNodeTextContent(displayName) || value} darkMode={darkMode} - className={cx( - { - [largeStyles]: size === ComboboxSize.Large, - [multiselectIconPosition]: multiSelectWithoutIcons, - [multiselectIconLargePosition]: - multiSelectWithoutIcons && size === ComboboxSize.Large, - }, + className={getInputOptionStyles({ + size, + isSelected, + isMultiselectWithoutIcons: multiSelectWithoutIcons, className, - )} + })} onClick={handleOptionClick} onKeyDown={handleOptionClick} > @@ -125,9 +117,13 @@ export const InternalComboboxOption = React.forwardRef< rightGlyph={rightGlyph} description={description} > - - {wrapJSX(displayName, inputValue, 'strong')} - + {typeof displayName === 'string' ? ( + + {wrapJSX(displayName, inputValue, 'strong')} + + ) : ( + displayName + )} ); diff --git a/packages/combobox/src/ComboboxOption/ComboboxOption.types.ts b/packages/combobox/src/ComboboxOption/ComboboxOption.types.ts index dce2c02dc3..2707aae976 100644 --- a/packages/combobox/src/ComboboxOption/ComboboxOption.types.ts +++ b/packages/combobox/src/ComboboxOption/ComboboxOption.types.ts @@ -1,4 +1,4 @@ -import { ComponentPropsWithoutRef, ReactElement } from 'react'; +import { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'; import { Either } from '@leafygreen-ui/lib'; @@ -19,7 +19,7 @@ interface SharedComboboxOptionProps { * The display value of the option. Used as the rendered string within the menu and chips. * When undefined, this is set to `value` */ - displayName?: string; + displayName?: ReactNode; /** * The icon to display to the left of the option in the menu. diff --git a/packages/combobox/src/utils/ComboboxUtils.spec.tsx b/packages/combobox/src/utils/ComboboxUtils.spec.tsx index abf49eed1d..e6a5b1cbef 100644 --- a/packages/combobox/src/utils/ComboboxUtils.spec.tsx +++ b/packages/combobox/src/utils/ComboboxUtils.spec.tsx @@ -5,7 +5,12 @@ import { Icon } from '@leafygreen-ui/icon'; import { ComboboxGroup, ComboboxOption } from '..'; -import { flattenChildren, getNameAndValue, wrapJSX } from '.'; +import { + flattenChildren, + getDisplayNameForValue, + getNameAndValue, + wrapJSX, +} from '.'; describe('packages/combobox/utils', () => { describe('wrapJSX', () => { @@ -155,6 +160,66 @@ describe('packages/combobox/utils', () => { }); }); + describe('getDisplayNameForValue', () => { + const options = [ + { value: 'apple', displayName: 'Apple', isDisabled: false }, + { value: 'banana', displayName: 'Banana', isDisabled: false }, + { value: 'carrot', displayName: 'Carrot', isDisabled: true }, + ]; + + test('Returns the displayName when a matching option is found', () => { + const result = getDisplayNameForValue('apple', options); + expect(result).toBe('Apple'); + }); + + test('Returns the value when no matching option is found', () => { + const result = getDisplayNameForValue('unknown', options); + expect(result).toBe('unknown'); + }); + + test('Returns empty string when value is null', () => { + const result = getDisplayNameForValue(null, options); + expect(result).toBe(''); + }); + + test('Returns empty string when value is empty string', () => { + const result = getDisplayNameForValue('', options); + expect(result).toBe(''); + }); + + test('Returns displayName for disabled option', () => { + const result = getDisplayNameForValue('carrot', options); + expect(result).toBe('Carrot'); + }); + + test('Returns empty string when options array is empty and value is null', () => { + const result = getDisplayNameForValue(null, []); + expect(result).toBe(''); + }); + + test('Returns value when options array is empty but value is provided', () => { + const result = getDisplayNameForValue('test', []); + expect(result).toBe('test'); + }); + + test('Returns React node displayName when option has node displayName', () => { + const nodeDisplayName = ( + + Bold text + + ); + const optionsWithNode = [ + { + value: 'node-option', + displayName: nodeDisplayName, + isDisabled: false, + }, + ]; + const result = getDisplayNameForValue('node-option', optionsWithNode); + expect(result).toBe(nodeDisplayName); + }); + }); + describe('flattenChildren', () => { test('returns a single option', () => { const children = ; @@ -165,6 +230,7 @@ describe('packages/combobox/utils', () => { displayName: 'Test', hasGlyph: false, isDisabled: false, + badge: undefined, }, ]); }); @@ -181,12 +247,14 @@ describe('packages/combobox/utils', () => { displayName: 'Apple', hasGlyph: false, isDisabled: false, + badge: undefined, }, { value: 'banana', displayName: 'Banana', hasGlyph: false, isDisabled: false, + badge: undefined, }, ]); }); @@ -202,6 +270,44 @@ describe('packages/combobox/utils', () => { ); const flat = flattenChildren(children); expect(flat).toEqual([ + { + value: 'test', + displayName: 'Test', + hasGlyph: true, + isDisabled: true, + badge: undefined, + }, + ]); + }); + + test('flattens options with node displayName', () => { + const children = [ + + Testing + New + + } + />, + } + disabled + />, + ]; + const flat = flattenChildren(children); + expect(flat).toEqual([ + { + value: 'test', + displayName: 'Testing New', + hasGlyph: false, + isDisabled: false, + }, { value: 'test', displayName: 'Test', diff --git a/packages/combobox/src/utils/OptionObjectUtils.ts b/packages/combobox/src/utils/OptionObjectUtils.ts index 0178622245..c28beb39c0 100644 --- a/packages/combobox/src/utils/OptionObjectUtils.ts +++ b/packages/combobox/src/utils/OptionObjectUtils.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import { OptionObject } from '../ComboboxOption'; /** * @@ -21,7 +23,7 @@ export const getOptionObjectFromValue = ( export const getDisplayNameForValue = ( value: string | null, options: Array, -): string => { +): ReactNode => { return value ? getOptionObjectFromValue(value, options)?.displayName ?? value : ''; diff --git a/packages/combobox/src/utils/getNameAndValue.ts b/packages/combobox/src/utils/getNameAndValue.ts index 7e239fbbf5..7f095a4e5e 100644 --- a/packages/combobox/src/utils/getNameAndValue.ts +++ b/packages/combobox/src/utils/getNameAndValue.ts @@ -1,5 +1,7 @@ import kebabCase from 'lodash/kebabCase'; +import { getNodeTextContent } from '@leafygreen-ui/lib'; + import { ComboboxOptionProps } from '../ComboboxOption'; /** @@ -18,8 +20,10 @@ export const getNameAndValue = ({ value: string; displayName: string; } => { + const displayNameProps = getNodeTextContent(nameProp); + return { - value: valProp ?? kebabCase(nameProp), - displayName: nameProp ?? valProp ?? '', // TODO consider adding a prop to customize displayName => startCase(valProp), + value: valProp ?? kebabCase(displayNameProps), + displayName: displayNameProps || valProp || '', // TODO consider adding a prop to customize displayName => startCase(valProp), }; }; diff --git a/packages/combobox/tsconfig.json b/packages/combobox/tsconfig.json index da0c082c03..a966cde1ef 100644 --- a/packages/combobox/tsconfig.json +++ b/packages/combobox/tsconfig.json @@ -17,6 +17,12 @@ "**/*.stories.*" ], "references": [ + { + "path": "../badge" + }, + { + "path": "../button" + }, { "path": "../checkbox" }, diff --git a/packages/input-option/src/InputOption/InputOption.stories.tsx b/packages/input-option/src/InputOption/InputOption.stories.tsx index cb164ea649..f791c7c3b3 100644 --- a/packages/input-option/src/InputOption/InputOption.stories.tsx +++ b/packages/input-option/src/InputOption/InputOption.stories.tsx @@ -52,6 +52,8 @@ export default { control: 'boolean', }, showWedge: { + description: + 'Whether a wedge displays on the left side of the item when the element is highlighted or selected', control: 'boolean', }, leftGlyph: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5327ab642f..fd14a209e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1480,6 +1480,9 @@ importers: specifier: ^4.2.2 version: 4.3.1 devDependencies: + '@leafygreen-ui/badge': + specifier: workspace:^ + version: link:../badge '@leafygreen-ui/button': specifier: workspace:^ version: link:../button