From fe6fa7fa9ef37a07fe0133de3081b164edc6ae5e Mon Sep 17 00:00:00 2001 From: Powerplex Date: Thu, 30 Nov 2023 19:11:41 +0100 Subject: [PATCH 1/5] feat(dropdown): multiple selection dropdown --- package-lock.json | 1 + .../components/dropdown/src/Dropdown.doc.mdx | 2 +- .../dropdown/src/Dropdown.stories.tsx | 23 +++++++++++ .../dropdown/src/DropdownContext.tsx | 41 ++++++++++++++++++- .../components/dropdown/src/DropdownItem.tsx | 9 +++- .../dropdown/src/DropdownTrigger.tsx | 7 ++-- packages/components/dropdown/src/types.ts | 5 ++- 7 files changed, 78 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71e1c3333..6e1e4bc8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2402,6 +2402,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/packages/components/dropdown/src/Dropdown.doc.mdx b/packages/components/dropdown/src/Dropdown.doc.mdx index c62def7ce..555fe8251 100644 --- a/packages/components/dropdown/src/Dropdown.doc.mdx +++ b/packages/components/dropdown/src/Dropdown.doc.mdx @@ -113,7 +113,7 @@ TODO ### Multiple selection -TODO + ### Read only diff --git a/packages/components/dropdown/src/Dropdown.stories.tsx b/packages/components/dropdown/src/Dropdown.stories.tsx index 5bb5f1f4b..6b43cfd0f 100644 --- a/packages/components/dropdown/src/Dropdown.stories.tsx +++ b/packages/components/dropdown/src/Dropdown.stories.tsx @@ -222,3 +222,26 @@ export const FormFieldLabel: StoryFn = _args => { ) } + +export const MultipleSelection: StoryFn = _args => { + return ( +
+ + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} diff --git a/packages/components/dropdown/src/DropdownContext.tsx b/packages/components/dropdown/src/DropdownContext.tsx index 02ca851c8..00175073d 100644 --- a/packages/components/dropdown/src/DropdownContext.tsx +++ b/packages/components/dropdown/src/DropdownContext.tsx @@ -1,7 +1,7 @@ import { useId } from '@radix-ui/react-id' import { useFormFieldControl } from '@spark-ui/form-field' import { Popover } from '@spark-ui/popover' -import { useSelect } from 'downshift' +import { useMultipleSelection, useSelect, UseSelectState } from 'downshift' import { createContext, Dispatch, @@ -20,6 +20,7 @@ export interface DropdownContextState extends DownshiftState { highlightedItem: DropdownItem | undefined hasPopover: boolean setHasPopover: Dispatch> + multiple: boolean } export type DropdownContextProps = PropsWithChildren<{ @@ -47,6 +48,7 @@ export type DropdownContextProps = PropsWithChildren<{ * The open state of the select when it is initially rendered. Use when you do not need to control its open state. */ defaultOpen?: boolean + multiple?: boolean }> const DropdownContext = createContext(null) @@ -59,6 +61,7 @@ export const DropdownProvider = ({ open, onOpenChange, defaultOpen, + multiple = false, }: DropdownContextProps) => { const [computedItems, setComputedItems] = useState(getItemsFromChildren(children)) const [hasPopover, setHasPopover] = useState(false) @@ -72,6 +75,10 @@ export const DropdownProvider = ({ const controlledDefaultSelectedItem = defaultValue ? computedItems.get(defaultValue) : undefined const controlledDefaultOpen = defaultOpen != null ? defaultOpen : false + const downshiftMultipleSelection = useMultipleSelection({ + // initialSelectedItems: [controlledDefaultSelectedItem as DropdownItem], + }) + const downshift = useSelect({ items: Array.from(computedItems.values()), isItemDisabled: item => item.disabled, @@ -80,7 +87,7 @@ export const DropdownProvider = ({ id, labelId, // Controlled mode (stateful) - selectedItem: controlledSelectedItem, + selectedItem: controlledSelectedItem, // todo: set to null for multiple selection initialSelectedItem: controlledDefaultSelectedItem, onSelectedItemChange: ({ selectedItem }) => { if (selectedItem?.value) { @@ -94,6 +101,34 @@ export const DropdownProvider = ({ } }, initialIsOpen: controlledDefaultOpen, + ...(multiple && { + stateReducer: (state: UseSelectState, { changes, type }) => { + switch (type) { + case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter: + case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton: + case useSelect.stateChangeTypes.ItemClick: + if (changes.selectedItem != null) { + const isAlreadySelected = downshiftMultipleSelection.selectedItems.some( + selectedItem => selectedItem.value === changes.selectedItem?.value + ) + + if (isAlreadySelected) { + downshiftMultipleSelection.removeSelectedItem(changes.selectedItem) + } else { + downshiftMultipleSelection.addSelectedItem(changes.selectedItem) + } + } + + return { + ...changes, + isOpen: true, // keep the menu open after selection. + highlightedIndex: state.highlightedIndex, // preserve highlighted index position + } + default: + return changes + } + }, + }), }) /** @@ -123,7 +158,9 @@ export const DropdownProvider = ({ return ( { - const { computedItems, selectedItem, getItemProps, highlightedItem } = useDropdownContext() + const { multiple, computedItems, selectedItem, selectedItems, getItemProps, highlightedItem } = + useDropdownContext() const index = getIndexByKey(computedItems, value) const itemData: DropdownItem = { disabled, value, text: getItemText(children) } + const isSelected = multiple + ? selectedItems.some(selectedItem => selectedItem.value === value) + : selectedItem?.value === value + return (
  • { - const { isOpen, getToggleButtonProps, getLabelProps, hasPopover } = useDropdownContext() + const { isOpen, getToggleButtonProps, getDropdownProps, getLabelProps, hasPopover } = + useDropdownContext() const [WrapperComponent, wrapperProps] = hasPopover ? [Popover.Trigger, { asChild: true }] @@ -34,10 +35,10 @@ export const Trigger = ({ 'aria-label': ariaLabel, children, className }: Trigge -
    +
    @@ -103,7 +103,7 @@ export const ControlledOpenState: StoryFn = () => { export const CustomItem: StoryFn = _args => { return ( -
    +
    @@ -144,7 +144,7 @@ export const CustomItem: StoryFn = _args => { export const DisabledItem: StoryFn = _args => { return ( -
    +
    @@ -169,7 +169,7 @@ export const DisabledItem: StoryFn = _args => { export const Grouped: StoryFn = _args => { return ( -
    +
    @@ -200,7 +200,7 @@ export const Grouped: StoryFn = _args => { export const FormFieldLabel: StoryFn = _args => { return ( -
    +
    Book @@ -225,7 +225,7 @@ export const FormFieldLabel: StoryFn = _args => { export const MultipleSelection: StoryFn = _args => { return ( -
    +
    diff --git a/packages/components/dropdown/src/Dropdown.test.tsx b/packages/components/dropdown/src/Dropdown.test.tsx index 650c71e6e..500498012 100644 --- a/packages/components/dropdown/src/Dropdown.test.tsx +++ b/packages/components/dropdown/src/Dropdown.test.tsx @@ -1,31 +1,239 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { describe, expect, it } from 'vitest' import { Dropdown } from '.' +const getTrigger = (accessibleName: string) => { + return screen.getByRole('combobox', { name: accessibleName }) +} + +const getListbox = (accessibleName: string) => { + return screen.getByRole('listbox', { name: accessibleName }) +} + +const getItem = (accessibleName: string) => { + return screen.getByRole('option', { name: accessibleName }) +} + describe('Dropdown', () => { - describe('initial rendering', () => { - it('should render trigger and list of options', () => { + it('should render trigger and list of options', () => { + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + expect(getTrigger('Book')).toBeInTheDocument() + + expect(getListbox('Book')).toBeInTheDocument() + + expect(getItem('War and Peace')).toBeInTheDocument() + expect(getItem('1984')).toBeInTheDocument() + expect(getItem('Pride and Prejudice')).toBeInTheDocument() + }) + + describe('Popover behaviour', () => { + it('should open/close the popover when interacting with its trigger', async () => { + const user = userEvent.setup() + + // Given a close dropdown (default state) render( - - War and Peace - 1984 - Pride and Prejudice - + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + const trigger = getTrigger('Book') + + expect(trigger).toHaveAttribute('aria-expanded', 'false') + + // When the user interact with the trigger + await user.click(trigger) + + // Then the dropdown has expanded + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + // When the user interact with the trigger while expanded + await user.click(trigger) + + // Then the dropdown is closed again + expect(trigger).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Dropdown.Value', () => { + it('should display placholder before selection, selected value after selection', async () => { + const user = userEvent.setup() + + // Given a dropdown with no selected value yet + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + + // When the user select an item + await user.click(getTrigger('Book')) + await user.click(getItem('Pride and Prejudice')) + + // Then placeholder is replaced by the selected value + expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') + }) + + it('should display custom value after selection', async () => { + const user = userEvent.setup() + + // Given a dropdown with no selected value yet + render( + + + You have selected a book + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + + // When the user select an item + await user.click(getTrigger('Book')) + await user.click(getItem('Pride and Prejudice')) + + // Then placeholder is replaced by a custom value + expect(getTrigger('Book')).toHaveTextContent('You have selected a book') + }) + }) + + describe('default value', () => { + it('should render default selected option (single selection)', () => { + // Given a dropdown with a default selected value + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + // Then the corresponding item is selected + expect(getItem('1984')).toHaveAttribute('aria-selected', 'true') + }) + + // it('should render default selected option (multiple selection)', () => {}) + }) + + describe('controlled', () => { + it('should control value (single selection)', async () => { + const user = userEvent.setup() + + // Given we control value by outside state and selected value + const ControlledImplementation = () => { + const [value, setValue] = useState('book-1') + + return ( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + } + + render() + + expect(getItem('War and Peace')).toHaveAttribute('aria-selected', 'true') + + expect(getTrigger('Book')).toHaveTextContent('War and Peace') + + // when the user select another item + await user.click(getTrigger('Book')) + await user.click(getItem('Pride and Prejudice')) + + // Then the selected value has been updated + expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') + }) + + // it('should control value (multiple selection)', async () => {}) + + it('should remain forced opened', async () => { + const user = userEvent.setup() + + // Given a dropdown that should remain opened + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + ) - expect(screen.getByRole('combobox', { name: 'Book' })).toBeInTheDocument() + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByRole('listbox', { name: 'Book' })).toBeInTheDocument() + // When the user interacts with the trigger + await user.click(getTrigger('Book')) - expect(screen.getByRole('option', { name: 'War and Peace' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: '1984' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: 'Pride and Prejudice' })).toBeInTheDocument() + // Then the dropdown remains opened + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') }) }) }) diff --git a/packages/components/dropdown/src/DropdownItem.tsx b/packages/components/dropdown/src/DropdownItem.tsx index 29f95f8d1..f03e76b39 100644 --- a/packages/components/dropdown/src/DropdownItem.tsx +++ b/packages/components/dropdown/src/DropdownItem.tsx @@ -34,6 +34,7 @@ export const Item = ({ className, disabled = false, value, children }: ItemProps )} key={value} {...getItemProps({ item: itemData, index })} + aria-selected={isSelected} > {children}
  • From 9b4beeaec097d415e37625e8eaeb44a0474f5152 Mon Sep 17 00:00:00 2001 From: Powerplex Date: Fri, 1 Dec 2023 12:19:40 +0100 Subject: [PATCH 3/5] feat(popover): do not restrict popover width in matchTriggerWidth mode --- packages/components/popover/src/PopoverContent.styles.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/components/popover/src/PopoverContent.styles.ts b/packages/components/popover/src/PopoverContent.styles.ts index 46c0e27e2..d26cda039 100644 --- a/packages/components/popover/src/PopoverContent.styles.ts +++ b/packages/components/popover/src/PopoverContent.styles.ts @@ -15,7 +15,6 @@ export const styles = cva( }, enforceBoundaries: { true: ['max-w-[--radix-popper-available-width]'], - false: ['max-w-[min(var(--sz-384),100vw)]'], }, /** * When there is a close button, padding to the right side must be adjusted to avoid content overlapping with it. @@ -34,6 +33,11 @@ export const styles = cva( inset: true, class: 'pr-none', }, + { + enforceBoundaries: false, + matchTriggerWidth: false, + class: 'max-w-[min(var(--sz-384),100vw)]', + }, ], defaultVariants: { matchTriggerWidth: false, From 39d08984be7a2078bf09e7133a675bb390eb8c1b Mon Sep 17 00:00:00 2001 From: Powerplex Date: Fri, 1 Dec 2023 14:56:47 +0100 Subject: [PATCH 4/5] test(dropdown): basic multiselect toggle tests --- .../components/dropdown/src/Dropdown.test.tsx | 226 ++++++++++++++++-- .../dropdown/src/DropdownContext.tsx | 2 +- .../components/dropdown/src/DropdownValue.tsx | 8 +- 3 files changed, 208 insertions(+), 28 deletions(-) diff --git a/packages/components/dropdown/src/Dropdown.test.tsx b/packages/components/dropdown/src/Dropdown.test.tsx index 500498012..88247c2ea 100644 --- a/packages/components/dropdown/src/Dropdown.test.tsx +++ b/packages/components/dropdown/src/Dropdown.test.tsx @@ -13,12 +13,16 @@ const getListbox = (accessibleName: string) => { return screen.getByRole('listbox', { name: accessibleName }) } +const getItemsGroup = (accessibleName: string) => { + return screen.getByRole('group', { name: accessibleName }) +} + const getItem = (accessibleName: string) => { return screen.getByRole('option', { name: accessibleName }) } describe('Dropdown', () => { - it('should render trigger and list of options', () => { + it('should render trigger and list of items', () => { render( @@ -79,17 +83,105 @@ describe('Dropdown', () => { // Then the dropdown is closed again expect(trigger).toHaveAttribute('aria-expanded', 'false') }) + + it('should remain forced opened', async () => { + const user = userEvent.setup() + + // Given a dropdown that should remain opened + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') + + // When the user interacts with the trigger + await user.click(getTrigger('Book')) + + // Then the dropdown remains opened + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') + }) + + it('should be opened by default but close upon interaction', async () => { + const user = userEvent.setup() + + // Given a dropdown that should remain opened + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') + + // When the user interacts with the trigger + await user.click(getTrigger('Book')) + + // Then the dropdown remains opened + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Dropdown.Group', () => { + it('should link items groups with their label', () => { + // Given a dropdown with items groups and group labels + render( + + + + + + + + Best-sellers + War and Peace + 1984 + + + + Novelties + Pride and Prejudice + Pride and Prejudice + + + + + ) + + // Then each group have an accessible label + expect(getItemsGroup('Best-sellers')).toBeInTheDocument() + expect(getItemsGroup('Novelties')).toBeInTheDocument() + }) }) describe('Dropdown.Value', () => { - it('should display placholder before selection, selected value after selection', async () => { + it('should display custom value after selection', async () => { const user = userEvent.setup() // Given a dropdown with no selected value yet render( - + You have selected a book @@ -108,18 +200,20 @@ describe('Dropdown', () => { await user.click(getTrigger('Book')) await user.click(getItem('Pride and Prejudice')) - // Then placeholder is replaced by the selected value - expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') + // Then placeholder is replaced by a custom value + expect(getTrigger('Book')).toHaveTextContent('You have selected a book') }) + }) - it('should display custom value after selection', async () => { + describe('single selection', () => { + it('should select item', async () => { const user = userEvent.setup() // Given a dropdown with no selected value yet render( - You have selected a book + @@ -138,13 +232,16 @@ describe('Dropdown', () => { await user.click(getTrigger('Book')) await user.click(getItem('Pride and Prejudice')) - // Then placeholder is replaced by a custom value - expect(getTrigger('Book')).toHaveTextContent('You have selected a book') + // Then placeholder is replaced by the selected value + expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') + + // Then the proper item is selected + expect(getItem('War and Peace')).toHaveAttribute('aria-selected', 'false') + expect(getItem('1984')).toHaveAttribute('aria-selected', 'false') + expect(getItem('Pride and Prejudice')).toHaveAttribute('aria-selected', 'true') }) - }) - describe('default value', () => { - it('should render default selected option (single selection)', () => { + it('should render default selected option', () => { // Given a dropdown with a default selected value render( @@ -165,11 +262,7 @@ describe('Dropdown', () => { expect(getItem('1984')).toHaveAttribute('aria-selected', 'true') }) - // it('should render default selected option (multiple selection)', () => {}) - }) - - describe('controlled', () => { - it('should control value (single selection)', async () => { + it('should control value', async () => { const user = userEvent.setup() // Given we control value by outside state and selected value @@ -205,15 +298,51 @@ describe('Dropdown', () => { // Then the selected value has been updated expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') }) + }) - // it('should control value (multiple selection)', async () => {}) + describe('multiple selection', () => { + it('should select items', async () => { + const user = userEvent.setup() - it('should remain forced opened', async () => { + // Given a dropdown with no selected value yet + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + + // When the user select two items + await user.click(getTrigger('Book')) + await user.click(getItem('1984')) + await user.click(getItem('Pride and Prejudice')) + + // Then placeholder is replaced by the selected value + expect(getTrigger('Book')).toHaveTextContent('1984') + + // Then the proper items are selected + expect(getItem('War and Peace')).toHaveAttribute('aria-selected', 'false') + expect(getItem('1984')).toHaveAttribute('aria-selected', 'true') + expect(getItem('Pride and Prejudice')).toHaveAttribute('aria-selected', 'true') + }) + + it('should select all items using keyboard navigation', async () => { const user = userEvent.setup() - // Given a dropdown that should remain opened + // Given a dropdown with no selected value yet render( - + @@ -227,13 +356,60 @@ describe('Dropdown', () => { ) - expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') - // When the user interacts with the trigger + // When the user select all the items one by one using the keyboard await user.click(getTrigger('Book')) + await user.keyboard('[ArrowDown][Enter]') + await user.keyboard('[ArrowDown][Enter]') + await user.keyboard('[ArrowDown][Enter]') - // Then the dropdown remains opened - expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'true') + // Then all items are selected + expect(getItem('War and Peace')).toHaveAttribute('aria-selected', 'true') + expect(getItem('1984')).toHaveAttribute('aria-selected', 'true') + expect(getItem('Pride and Prejudice')).toHaveAttribute('aria-selected', 'true') + }) + + it('should be able to unselect a selected item', async () => { + const user = userEvent.setup() + + // Given a dropdown with no selected value yet + render( + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + ) + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + + // When the user select an item + await user.click(getTrigger('Book')) + await user.click(getItem('1984')) + + // Then placeholder is replaced by the selected value + expect(getTrigger('Book')).toHaveTextContent('1984') + expect(getItem('1984')).toHaveAttribute('aria-selected', 'true') + + // When the user unselect that item + await user.click(getItem('1984')) + + // Then placeholder is shown again as the item is no longer selected + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + expect(getItem('1984')).toHaveAttribute('aria-selected', 'false') }) + + // it('should render default selected items', () => {}) + // it('should control value', async () => {}) }) }) diff --git a/packages/components/dropdown/src/DropdownContext.tsx b/packages/components/dropdown/src/DropdownContext.tsx index 00175073d..2e5a3c4d3 100644 --- a/packages/components/dropdown/src/DropdownContext.tsx +++ b/packages/components/dropdown/src/DropdownContext.tsx @@ -73,7 +73,7 @@ export const DropdownProvider = ({ const controlledSelectedItem = value ? computedItems.get(value) : undefined const controlledDefaultSelectedItem = defaultValue ? computedItems.get(defaultValue) : undefined - const controlledDefaultOpen = defaultOpen != null ? defaultOpen : false + const controlledDefaultOpen = defaultOpen ?? false const downshiftMultipleSelection = useMultipleSelection({ // initialSelectedItems: [controlledDefaultSelectedItem as DropdownItem], diff --git a/packages/components/dropdown/src/DropdownValue.tsx b/packages/components/dropdown/src/DropdownValue.tsx index f2d27ab66..e27f71c69 100644 --- a/packages/components/dropdown/src/DropdownValue.tsx +++ b/packages/components/dropdown/src/DropdownValue.tsx @@ -10,11 +10,15 @@ export interface ValueProps { } export const Value = ({ children, className, placeholder }: ValueProps) => { - const { selectedItem } = useDropdownContext() + const { selectedItem, multiple, selectedItems } = useDropdownContext() + + const hasSelectedItems = !!(multiple ? selectedItems.length : selectedItem) + + const text = multiple ? selectedItems[0]?.text : selectedItem?.text return ( - {!selectedItem ? placeholder : children || selectedItem?.text} + {!hasSelectedItems ? placeholder : children || text} ) } From 56af96c89e0cbc3b065cf96f0f6b6ee4d9cb8d01 Mon Sep 17 00:00:00 2001 From: Powerplex Date: Fri, 1 Dec 2023 15:55:19 +0100 Subject: [PATCH 5/5] fix(dropdown): fixed itemText not properly linked to item by ariaLabelledby --- .../components/dropdown/src/Dropdown.test.tsx | 39 +++++++++++++++++++ .../components/dropdown/src/DropdownItem.tsx | 14 ++++++- .../dropdown/src/DropdownItemContext.tsx | 30 ++++++++++++++ .../dropdown/src/DropdownItemText.tsx | 20 +++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/components/dropdown/src/DropdownItemContext.tsx diff --git a/packages/components/dropdown/src/Dropdown.test.tsx b/packages/components/dropdown/src/Dropdown.test.tsx index 88247c2ea..8ac3f46d1 100644 --- a/packages/components/dropdown/src/Dropdown.test.tsx +++ b/packages/components/dropdown/src/Dropdown.test.tsx @@ -203,6 +203,45 @@ describe('Dropdown', () => { // Then placeholder is replaced by a custom value expect(getTrigger('Book')).toHaveTextContent('You have selected a book') }) + + it('should display text in trigger when selecting an item with custom markup', async () => { + const user = userEvent.setup() + + // Given a dropdown with no selected value yet and custom items markup + render( + + + + + + + + New: + War and Peace + + + New: + 1984 + + + New: + Pride and Prejudice + + + + + ) + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent('Pick a book') + + // When the user select an item + await user.click(getTrigger('Book')) + await user.click(getItem('Pride and Prejudice')) + + // Then placeholder is replaced by the raw text value + expect(getTrigger('Book')).toHaveTextContent('Pride and Prejudice') + }) }) describe('single selection', () => { diff --git a/packages/components/dropdown/src/DropdownItem.tsx b/packages/components/dropdown/src/DropdownItem.tsx index f03e76b39..e0e2f78a6 100644 --- a/packages/components/dropdown/src/DropdownItem.tsx +++ b/packages/components/dropdown/src/DropdownItem.tsx @@ -2,6 +2,7 @@ import { cx } from 'class-variance-authority' import { ReactNode } from 'react' import { useDropdownContext } from './DropdownContext' +import { DropdownItemProvider, useDropdownItemContext } from './DropdownItemContext' import { DropdownItem } from './types' import { getIndexByKey, getItemText } from './utils' @@ -12,10 +13,20 @@ export interface ItemProps { className?: string } -export const Item = ({ className, disabled = false, value, children }: ItemProps) => { +export const Item = ({ children, ...props }: ItemProps) => { + return ( + + {children} + + ) +} + +const ItemContent = ({ className, disabled = false, value, children }: ItemProps) => { const { multiple, computedItems, selectedItem, selectedItems, getItemProps, highlightedItem } = useDropdownContext() + const { textId } = useDropdownItemContext() + const index = getIndexByKey(computedItems, value) const itemData: DropdownItem = { disabled, value, text: getItemText(children) } @@ -35,6 +46,7 @@ export const Item = ({ className, disabled = false, value, children }: ItemProps key={value} {...getItemProps({ item: itemData, index })} aria-selected={isSelected} + aria-labelledby={textId} > {children} diff --git a/packages/components/dropdown/src/DropdownItemContext.tsx b/packages/components/dropdown/src/DropdownItemContext.tsx new file mode 100644 index 000000000..8ca45ccab --- /dev/null +++ b/packages/components/dropdown/src/DropdownItemContext.tsx @@ -0,0 +1,30 @@ +import React, { createContext, type PropsWithChildren, useContext, useState } from 'react' + +type ItemTextId = string | undefined + +interface DropdownItemContextState { + textId: ItemTextId + setTextId: React.Dispatch> +} + +const DropdownItemContext = createContext(null) + +export const DropdownItemProvider = ({ children }: PropsWithChildren) => { + const [textId, setTextId] = useState(undefined) + + return ( + + {children} + + ) +} + +export const useDropdownItemContext = () => { + const context = useContext(DropdownItemContext) + + if (!context) { + throw Error('useDropdownItemContext must be used within a DropdownItem provider') + } + + return context +} diff --git a/packages/components/dropdown/src/DropdownItemText.tsx b/packages/components/dropdown/src/DropdownItemText.tsx index 66a874b21..94324394f 100644 --- a/packages/components/dropdown/src/DropdownItemText.tsx +++ b/packages/components/dropdown/src/DropdownItemText.tsx @@ -1,11 +1,29 @@ +import { useId } from '@radix-ui/react-id' import { cx } from 'class-variance-authority' +import { useEffect } from 'react' + +import { useDropdownItemContext } from './DropdownItemContext' export interface ItemTextProps { children: string } export const ItemText = ({ children }: ItemTextProps) => { - return {children} + const id = useId() + + const { setTextId } = useDropdownItemContext() + + useEffect(() => { + setTextId(id) + + return () => setTextId(undefined) + }) + + return ( + + {children} + + ) } ItemText.id = 'ItemText'