+
+
+
+
+
+
+
+ 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/Dropdown.test.tsx b/packages/components/dropdown/src/Dropdown.test.tsx
index 650c71e6e..8ac3f46d1 100644
--- a/packages/components/dropdown/src/Dropdown.test.tsx
+++ b/packages/components/dropdown/src/Dropdown.test.tsx
@@ -1,31 +1,454 @@
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 getItemsGroup = (accessibleName: string) => {
+ return screen.getByRole('group', { 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 items', () => {
+ 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')
+ })
+
+ 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 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')
+ })
+
+ 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', () => {
+ it('should select 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('Pride and Prejudice'))
+
+ // 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')
+ })
+
+ it('should render default selected option', () => {
+ // Given a dropdown with a default selected value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
)
- expect(screen.getByRole('combobox', { name: 'Book' })).toBeInTheDocument()
+ // Then the corresponding item is selected
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'true')
+ })
+
+ it('should control value', async () => {
+ const user = userEvent.setup()
- expect(screen.getByRole('listbox', { name: 'Book' })).toBeInTheDocument()
+ // Given we control value by outside state and selected value
+ const ControlledImplementation = () => {
+ const [value, setValue] = useState('book-1')
- 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()
+ 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')
})
})
+
+ describe('multiple selection', () => {
+ it('should select items', 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 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 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 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 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 02ca851c8..2e5a3c4d3 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)
@@ -70,7 +73,11 @@ 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],
+ })
const downshift = useSelect({
items: Array.from(computedItems.values()),
@@ -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()
+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) }
+ const isSelected = multiple
+ ? selectedItems.some(selectedItem => selectedItem.value === value)
+ : selectedItem?.value === value
+
return (