From 9ec4f0da1669702c14133d31f13469a0395d8e47 Mon Sep 17 00:00:00 2001 From: Powerplex Date: Wed, 17 Jan 2024 17:23:01 +0100 Subject: [PATCH] feat(combobox): combobox v1 poc --- package-lock.json | 1 + packages/components/combobox/package.json | 10 + .../components/combobox/src/ComboBoxEmpty.tsx | 22 + .../components/combobox/src/Combobox.doc.mdx | 189 +++++- .../combobox/src/Combobox.stories.tsx | 550 ++++++++++++++- .../combobox/src/Combobox.styles.ts | 0 .../components/combobox/src/Combobox.test.tsx | 631 +++++++++++++++++- packages/components/combobox/src/Combobox.tsx | 10 +- .../combobox/src/ComboboxContext.tsx | 236 +++++++ .../combobox/src/ComboboxDivider.tsx | 14 + .../components/combobox/src/ComboboxGroup.tsx | 35 + .../combobox/src/ComboboxInput.styles.tsx | 33 + .../components/combobox/src/ComboboxInput.tsx | 92 +++ .../components/combobox/src/ComboboxItem.tsx | 101 +++ .../combobox/src/ComboboxItemContext.tsx | 53 ++ .../combobox/src/ComboboxItemIndicator.tsx | 32 + .../combobox/src/ComboboxItemText.tsx | 31 + .../components/combobox/src/ComboboxItems.tsx | 43 ++ .../src/ComboboxItemsGroupContext.tsx | 28 + .../components/combobox/src/ComboboxLabel.tsx | 27 + .../combobox/src/ComboboxLeadingIcon.tsx | 12 + .../combobox/src/ComboboxPopover.tsx | 50 ++ .../combobox/src/ComboboxStateIndicator.tsx | 28 + .../components/combobox/src/ComboboxValue.tsx | 31 + packages/components/combobox/src/index.ts | 61 +- packages/components/combobox/src/types.ts | 12 + .../components/combobox/src/useCombobox.ts | 138 ++++ packages/components/combobox/src/utils.ts | 109 +++ 28 files changed, 2568 insertions(+), 11 deletions(-) create mode 100644 packages/components/combobox/src/ComboBoxEmpty.tsx delete mode 100644 packages/components/combobox/src/Combobox.styles.ts create mode 100644 packages/components/combobox/src/ComboboxContext.tsx create mode 100644 packages/components/combobox/src/ComboboxDivider.tsx create mode 100644 packages/components/combobox/src/ComboboxGroup.tsx create mode 100644 packages/components/combobox/src/ComboboxInput.styles.tsx create mode 100644 packages/components/combobox/src/ComboboxInput.tsx create mode 100644 packages/components/combobox/src/ComboboxItem.tsx create mode 100644 packages/components/combobox/src/ComboboxItemContext.tsx create mode 100644 packages/components/combobox/src/ComboboxItemIndicator.tsx create mode 100644 packages/components/combobox/src/ComboboxItemText.tsx create mode 100644 packages/components/combobox/src/ComboboxItems.tsx create mode 100644 packages/components/combobox/src/ComboboxItemsGroupContext.tsx create mode 100644 packages/components/combobox/src/ComboboxLabel.tsx create mode 100644 packages/components/combobox/src/ComboboxLeadingIcon.tsx create mode 100644 packages/components/combobox/src/ComboboxPopover.tsx create mode 100644 packages/components/combobox/src/ComboboxStateIndicator.tsx create mode 100644 packages/components/combobox/src/ComboboxValue.tsx create mode 100644 packages/components/combobox/src/types.ts create mode 100644 packages/components/combobox/src/useCombobox.ts create mode 100644 packages/components/combobox/src/utils.ts diff --git a/package-lock.json b/package-lock.json index 702ca2274..0bd528e02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2399,6 +2399,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/combobox/package.json b/packages/components/combobox/package.json index 44f625c18..4ec32848b 100644 --- a/packages/components/combobox/package.json +++ b/packages/components/combobox/package.json @@ -27,6 +27,16 @@ "react-dom": "^16.8 || ^17.0 || ^18.0", "tailwindcss": "^3.0.0" }, + "dependencies": { + "@radix-ui/react-id": "1.0.1", + "@spark-ui/form-field": "^1.4.1", + "@spark-ui/icon": "^2.1.1", + "@spark-ui/icons": "^1.21.6", + "@spark-ui/popover": "^1.5.2", + "@spark-ui/visually-hidden": "^1.2.0", + "class-variance-authority": "0.7.0", + "downshift": "^8.2.3" + }, "repository": { "type": "git", "url": "https://github.com/adevinta/spark.git", diff --git a/packages/components/combobox/src/ComboBoxEmpty.tsx b/packages/components/combobox/src/ComboBoxEmpty.tsx new file mode 100644 index 000000000..efc67c8ae --- /dev/null +++ b/packages/components/combobox/src/ComboBoxEmpty.tsx @@ -0,0 +1,22 @@ +import { forwardRef, type ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' + +interface EmptyProps { + className?: string + children: ReactNode +} + +export const Empty = forwardRef( + ({ className, children }: EmptyProps, forwardedRef: Ref) => { + const { filteredItemsMap } = useComboboxContext() + + return filteredItemsMap.size === 0 ? ( +
+ {children} +
+ ) : null + } +) + +Empty.displayName = 'Combobox.Empty' diff --git a/packages/components/combobox/src/Combobox.doc.mdx b/packages/components/combobox/src/Combobox.doc.mdx index d3cb04900..0f699f79c 100644 --- a/packages/components/combobox/src/Combobox.doc.mdx +++ b/packages/components/combobox/src/Combobox.doc.mdx @@ -1,5 +1,6 @@ import { Meta, Canvas } from '@storybook/addon-docs' -import { ArgTypes } from '@storybook/blocks' +import { ArgTypes as ExtendedArgTypes } from '@docs/helpers/ArgTypes' +import { Callout } from '@docs/helpers/Callout' import { Combobox } from '.' @@ -9,7 +10,7 @@ import * as stories from './Combobox.stories' # Combobox -An input that behaves similarly to a select, with the addition of a free text input to filter options. +TODO ## Install @@ -25,8 +26,188 @@ import { Combobox } from '@spark-ui/combobox' ## Props - + -## Variants +## Usage + +### Default + +AutoSuggest is used as the default behaviour. The user can type anything in the input, the list is showing optional suggestions. + +### Disabled + +Use `disabled` on the root component to disable the combobox entirely. + + + +### Disabled Item + +Use `disabled` on individual `Combobox.Item` to disable them. + + + +### Filtering - AutoSelect + +Use `autoSelect` to filter out items that does not match the input value. This behaviour is not case-sensitive. + +For more custom filtering, logic should be done on call-site to render only desired items. + + + +### Filtering - Manual + +Use your own logic to filter out items depending on the inputValue or some external logic. + +This example showcases case-sensitive filtering. + + + +### Groups + +Similar to `optgroup` HTML tag, you can gather your items in groups. + +It is important to use `Combobox.Label` inside each `Combobox.Group` to give it an accessible name. + + + +### Item indicator + +Renders when the parent `ComboboxMenu.Item` is selected. + +You can style this element directly, or you can use it as a wrapper to put an icon into, or both. + + + +### Leading icon + +Use `Combobox.LeadingIcon` inside `Combobox.Input` to prefix your trigger with an icon. + + + +### Read only + +Use `readOnly` prop to indicate the combobox is only readable. + + + +### Status + +Use `state` prop to assign a specific state to the combobox, choosing from: `error`, `alert` and `success`. By doing so, the outline styles will be updated, and a status indicator will be displayed accordingly. + +You could also wrap `Combobox` with a `FormField` and pass the prop to `Formfield` instead. + + + +### Multiple selection + +When using `multiple` mode, the component manages an array of values and no longer a single value. + +It means you must adapt `value`, `onValueChange` and `defaultValue` accordingly. + +In `multiple` mode, the combobox won't close when the user selects an item, and it is possible to unselect every item. + +In multiple selection mode, the input will go back to empty state after each selection in the list. +This is up to the developer to make it clear to the user which items are selected, by using other components such as chips, for example. + + + +## Advanced usage + +### Custom item + +If your `Combobox.Item` contains anything else than raw text, you may use any JSX markup to customize it. + +**If you do so, you MUST use `Combobox.ItemText` inside of your item to give it a proper accessible name.** + + + +## Form field + +### Label + +Use `FormField.Label` to add a label to the input. + + + +### Hidden label + +In certain cases, a visible label may not be necessary. To achieve this behavior, use the `VisuallyHidden` component. + + + +### Required + +Use the `isRequired` prop of the `FormField` to indicate that the combobox is required. + + + +### Disabled + +The combobox `disabled` field status can be managed by the FormField `disabled` flag. + + + +### ReadOnly + +Apply `readOnly` to the wrapping `FormField` to indicate the combobox is only readable. + + + +### Validation + +Set the `state` prop of the `FormField` to `error` to indicate that the combobox is invalid. Optionally use the `FormField.ErrorMessage` to describe why the combobox is invalid. + + diff --git a/packages/components/combobox/src/Combobox.stories.tsx b/packages/components/combobox/src/Combobox.stories.tsx index 8e41b24b0..a0a63eb2d 100644 --- a/packages/components/combobox/src/Combobox.stories.tsx +++ b/packages/components/combobox/src/Combobox.stories.tsx @@ -1,4 +1,10 @@ +/* eslint-disable max-lines */ +import { FormField } from '@spark-ui/form-field' +import { BookmarkFill } from '@spark-ui/icons/dist/icons/BookmarkFill' +import { Tag } from '@spark-ui/tag' +import { VisuallyHidden } from '@spark-ui/visually-hidden' import { Meta, StoryFn } from '@storybook/react' +import React, { ComponentProps, useState } from 'react' import { Combobox } from '.' @@ -9,4 +15,546 @@ const meta: Meta = { export default meta -export const Default: StoryFn = _args => +// V1 +// Default (autosuggest, free form input, set list of suggestions) -> https://ariakit.org/components/combobox +// Filtering (using compound and on-call-site-logic) -> https://ariakit.org/examples/combobox-filtering +// AutoComplete (user MUST pick a value or multiple values from the list, the input is NOT free-form) +// Multiselect combobox -> https://ariakit.org/examples/combobox-multiple +// Combobox cancel (using input clear button) -> https://ariakit.org/examples/combobox-cancel +// -> to display when no result is displayed + +// V2 +// autoSelect (stateless filtering of items, not on call-site) -> https://ariakit.org/examples/combobox-filtering-integrated +// Combobox disclosure -> https://ariakit.org/examples/combobox-disclosure + +// V3 or later if necessary +// Combobox tabs -> https://ariakit.org/examples/combobox-tabs + +export const Default: StoryFn = _args => { + return ( +
+ + + + + + + + + + + No results found + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const CustomItem: StoryFn = _args => { + return ( +
+ + + + + + + + + To Kill a Mockingbird + New + + + War and Peace + New + + + The Idiot + New + + + A Picture of Dorian Gray + New + + + 1984 + New + + + Pride and Prejudice + New + + + + +
+ ) +} + +export const Disabled: StoryFn = _args => { + return ( +
+ + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const FilteringAutoSelect: StoryFn = _args => { + const items = { + 'book-1': 'To Kill a Mockingbird', + 'book-2': 'War and Peace', + 'book-3': 'The Idiot', + 'book-4': 'A Picture of Dorian Gray', + 'book-5': '1984', + 'book-6': 'Pride and Prejudice', + } + + return ( +
+ + + + + + + + + + + No results found + {Object.entries(items).map(([value, text]) => ( + + {text} + + ))} + + + +
+ ) +} + +export const FilteringManual: StoryFn = () => { + const items = { + 'book-1': 'To Kill a Mockingbird', + 'book-2': 'War and Peace', + 'book-3': 'The Idiot', + 'book-4': 'A Picture of Dorian Gray', + 'book-5': '1984', + 'book-6': 'Pride and Prejudice', + } + const [inputValue, setInputValue] = useState('') + + return ( +
+ + + + + + + + + + + No results found + {Object.entries(items).map(([value, text]) => { + if (!text.includes(inputValue)) return null + + return ( + + {text} + + ) + })} + + + +
+ ) +} + +export const ReadOnly: StoryFn = _args => { + return ( +
+ + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const DisabledItem: StoryFn = _args => { + return ( +
+ + + + + + + + To Kill a Mockingbird + War and Peace + + The Idiot + + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const Grouped: StoryFn = _args => { + return ( +
+ + + + + + + + Best-sellers + To Kill a Mockingbird + War and Peace + The Idiot + + + + + + Novelties + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const LeadingIcon: StoryFn = _args => { + return ( +
+ + + + + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const ItemIndicator: StoryFn = _args => { + return ( +
+ + + + + + + + + + To Kill a Mockingbird + + + + War and Peace + + + + The Idiot + + + + A Picture of Dorian Gray + + + + 1984 + + + + Pride and Prejudice + + + + +
+ ) +} + +export const Statuses: StoryFn = () => { + type Status = ComponentProps['state'] + + const statuses: Status[] = ['error', 'alert', 'success'] + + return ( +
+ {statuses.map(status => { + return ( + + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + ) + })} +
+ ) +} + +export const MultipleSelection: StoryFn = _args => { + return ( +
+ + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + +
+ ) +} + +export const FormFieldLabel: StoryFn = _args => { + return ( +
+ + Book + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const FormFieldHiddenLabel: StoryFn = _args => { + return ( +
+ + + Book + + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const FormFieldReadOnly: StoryFn = _args => { + return ( +
+ + Book + + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const FormFieldDisabled: StoryFn = _args => { + return ( +
+ + Book + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const FormFieldRequired: StoryFn = _args => { + return ( +
+ + Book + + + + + + + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice + + + + +
+ ) +} + +export const FormFieldValidation: StoryFn = () => { + const [state, setState] = useState('error') + + return ( +
+ + Statuses + { + setState(value === 'default' ? undefined : (value as 'success' | 'alert' | 'error')) + }} + > + + + + + + default + success + alert + error + + + + + An effective title significantly increases your chances of making a sale + + Well done! + Take care of this field + The field is invalid + +
+ ) +} diff --git a/packages/components/combobox/src/Combobox.styles.ts b/packages/components/combobox/src/Combobox.styles.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/components/combobox/src/Combobox.test.tsx b/packages/components/combobox/src/Combobox.test.tsx index 18ec46b06..78000a87d 100644 --- a/packages/components/combobox/src/Combobox.test.tsx +++ b/packages/components/combobox/src/Combobox.test.tsx @@ -1,12 +1,635 @@ +/* eslint-disable max-lines */ +import { FormField } from '@spark-ui/form-field' +import { BookmarkFill } from '@spark-ui/icons/dist/icons/BookmarkFill' 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 { Combobox } from './Combobox' +import { Combobox } 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('Combobox', () => { - it('should render', () => { - render() + 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 combobox (default state) + render( + + + + + + + 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 combobox has expanded + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + // When the user interact with the trigger while expanded + await user.click(trigger) + + // Then the combobox is closed again + expect(trigger).toHaveAttribute('aria-expanded', 'false') + }) + + it('should open/close the items list when interacting with its trigger (no popover)', async () => { + const user = userEvent.setup() + + // Given a close combobox without a popover(default state) + render( + + + + + + + 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 combobox has expanded + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + // When the user interact with the trigger while expanded + await user.click(trigger) + + // Then the combobox is closed again + expect(trigger).toHaveAttribute('aria-expanded', 'false') + }) + + it('should remain forced opened', async () => { + const user = userEvent.setup() + + // Given a combobox 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 combobox 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 combobox 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 combobox remains opened + expect(getTrigger('Book')).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Combobox.Group', () => { + it('should link items groups with their label', () => { + // Given a combobox 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('Combobox.Value', () => { + it('should display custom value after selection', async () => { + const user = userEvent.setup() + + // Given a combobox 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 combobox 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') + }) + + it('should update displayed text when selected item text changes', async () => { + const user = userEvent.setup() + const initialSelectedItemText = 'To Kill a Mockingbird' + const updatedSelectedItemText = 'Updated title' + + const Implementation = () => { + const [value, setValue] = useState('book-1') + const [bookText, setBookText] = useState(initialSelectedItemText) + + return ( +
+ + + + + + + + + + {bookText} + War and Peace + The Idiot + + + +
+ ) + } + + render() + + // Then placeholder should be displayed + expect(getTrigger('Book')).toHaveTextContent(initialSelectedItemText) + + await user.click(screen.getByText('Update book name')) + + // Then placeholder text should be updated + expect(getTrigger('Book')).toHaveTextContent(updatedSelectedItemText) + }) + }) + + describe('statuses (combined with FormField', () => { + it('should render error message when field is in error', () => { + render( + + Book + + + + + + + War and Peace + 1984 + Pride and Prejudice + + + + You forgot to select a book + + ) + + expect(getTrigger('Book')).toBeInTheDocument() + + expect(screen.getByText('You forgot to select a book')).toBeInTheDocument() + }) + }) + + describe('single selection', () => { + it('should select item', async () => { + const user = userEvent.setup() + + // Given a combobox 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 combobox 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 with proper indicator when including it', () => { + // Given a combobox with a default selected value + render( + + + + + + + + + War and Peace + + + + 1984 + + + + Pride and Prejudice + + + + + ) + + // Then the corresponding item is selected + expect(screen.getByLabelText('selected')).toBeVisible() + }) + + it('should control value', 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 select item using autocomplete (keyboard)', async () => { + const user = userEvent.setup() + + // Given a combobox 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 type "p" to highlight first item matching, then select it + await user.click(getTrigger('Book')) + await user.keyboard('{p}{Enter}') + + // 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('multiple selection', () => { + it('should select items', async () => { + const user = userEvent.setup() + + // Given a combobox 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 and suffix indicating remaining items + expect(getTrigger('Book')).toHaveTextContent('1984, +1') + + // 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 combobox 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(getTrigger('Book')).toHaveTextContent('War and Peace, +2') + 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 combobox 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') + }) - expect(screen.getByText(/combobox/)).toBeInTheDocument() + // it('should render default selected items', () => {}) + // it('should control value', async () => {}) }) }) diff --git a/packages/components/combobox/src/Combobox.tsx b/packages/components/combobox/src/Combobox.tsx index ce8dac473..839d79b78 100644 --- a/packages/components/combobox/src/Combobox.tsx +++ b/packages/components/combobox/src/Combobox.tsx @@ -1 +1,9 @@ -export const Combobox = () => <>combobox +import { type ComboboxContextProps, ComboboxProvider } from './ComboboxContext' + +export type ComboboxProps = ComboboxContextProps + +export const Combobox = ({ children, ...props }: ComboboxProps) => { + return {children} +} + +Combobox.displayName = 'Combobox' diff --git a/packages/components/combobox/src/ComboboxContext.tsx b/packages/components/combobox/src/ComboboxContext.tsx new file mode 100644 index 000000000..c8e0c6990 --- /dev/null +++ b/packages/components/combobox/src/ComboboxContext.tsx @@ -0,0 +1,236 @@ +import { useId } from '@radix-ui/react-id' +import { useFormFieldControl } from '@spark-ui/form-field' +import { Popover } from '@spark-ui/popover' +import { + createContext, + Dispatch, + Fragment, + PropsWithChildren, + SetStateAction, + useContext, + useEffect, + useState, +} from 'react' + +import { type ComboboxItem, type DownshiftState, type ItemsMap } from './types' +import { useCombobox } from './useCombobox' +import { getElementByIndex, getItemsFromChildren, hasChildComponent } from './utils' + +export interface ComboboxContextState extends DownshiftState { + itemsMap: ItemsMap + filteredItemsMap: ItemsMap + highlightedItem: ComboboxItem | undefined + hasPopover: boolean + setHasPopover: Dispatch> + multiple: boolean + disabled: boolean + readOnly: boolean + state?: 'error' | 'alert' | 'success' + lastInteractionType: 'mouse' | 'keyboard' + setLastInteractionType: (type: 'mouse' | 'keyboard') => void +} + +export type ComboboxContextCommonProps = PropsWithChildren<{ + /** + * The controlled open state of the select. Must be used in conjunction with `onOpenChange`. + */ + open?: boolean + /** + * Event handler called when the open state of the select changes. + */ + onOpenChange?: (isOpen: boolean) => void + /** + * The open state of the select when it is initially rendered. Use when you do not need to control its open state. + */ + defaultOpen?: boolean + /** + * Use `state` prop to assign a specific state to the combobox, choosing from: `error`, `alert` and `success`. By doing so, the outline styles will be updated, and a state indicator will be displayed accordingly. + */ + state?: 'error' | 'alert' | 'success' + /** + * When true, prevents the user from interacting with the combobox. + */ + disabled?: boolean + /** + * Sets the combobox as interactive or not. + */ + readOnly?: boolean + /** + * When true, the items will be filtered depending on the value of the input (not case-sensitive). + */ + autoSelect?: boolean +}> + +interface ComboboxPropsSingle { + /** + * Prop 'multiple' indicating whether multiple values are allowed. + */ + multiple?: false + /** + * The value of the select when initially rendered. Use when you do not need to control the state of the select. + */ + defaultValue?: string + /** + * The controlled value of the select. Should be used in conjunction with `onValueChange`. + */ + value?: string + /** + * Event handler called when the value changes. + */ + onValueChange?: (value: string) => void +} + +interface ComboboxPropsMultiple { + /** + * Prop 'multiple' indicating whether multiple values are allowed. + */ + multiple: true + /** + * The value of the select when initially rendered. Use when you do not need to control the state of the select. + */ + defaultValue?: string[] + /** + * The controlled value of the select. Should be used in conjunction with `onValueChange`. + */ + value?: string[] + /** + * Event handler called when the value changes. + */ + onValueChange?: (value: string[]) => void +} + +export type ComboboxContextProps = ComboboxContextCommonProps & + (ComboboxPropsSingle | ComboboxPropsMultiple) + +const ComboboxContext = createContext(null) + +const getFilteredItemsMap = (map: ItemsMap, inputValue: string | undefined): ItemsMap => { + if (!inputValue) return map + + return new Map( + Array.from(map).filter(([_, { text }]) => text.toLowerCase().includes(inputValue.toLowerCase())) + ) +} + +export const ComboboxProvider = ({ + autoSelect = false, + children, + defaultValue, + value, + onValueChange, + open, + onOpenChange, + defaultOpen, + multiple = false, + disabled: disabledProp = false, + readOnly: readOnlyProp = false, + state: stateProp, +}: ComboboxContextProps) => { + const [inputValue, setInputValue] = useState('') + const [itemsMap, setItemsMap] = useState(getItemsFromChildren(children)) + const [filteredItemsMap, setFilteredItems] = useState( + autoSelect ? getFilteredItemsMap(itemsMap, inputValue) : itemsMap + ) + + const [hasPopover, setHasPopover] = useState( + hasChildComponent(children, 'Combobox.Popover') + ) + const [lastInteractionType, setLastInteractionType] = useState<'mouse' | 'keyboard'>('mouse') + + const field = useFormFieldControl() + + const state = field.state || stateProp + const id = useId(field.id) + const labelId = useId(field.labelId) + const disabled = field.disabled ?? disabledProp + const readOnly = field.readOnly ?? readOnlyProp + + useEffect(() => { + setFilteredItems(autoSelect ? getFilteredItemsMap(itemsMap, inputValue) : itemsMap) + }, [inputValue, itemsMap]) + + const comboboxState = useCombobox({ + itemsMap, + defaultValue, + value, + onValueChange, + open, + onOpenChange, + defaultOpen, + multiple, + id, + labelId, + inputValue, + setInputValue, + filteredItems: filteredItemsMap, + }) + + /** + * Indices in a Map are set when an element is added to the Map. + * If for some reason, in the Combobox: + * - items order changes + * - items are added + * - items are removed + * + * The Map must be rebuilt from the new children in order to preserve logical indices. + * + * Downshift is heavily indices based for keyboard navigation, so it it important. + */ + useEffect(() => { + const newMap = getItemsFromChildren(children) + + const previousItems = [...itemsMap.values()] + const newItems = [...newMap.values()] + + const hasItemsChanges = + previousItems.length !== newItems.length || + previousItems.some((item, index) => { + const hasUpdatedValue = item.value !== newItems[index]?.value + const hasUpdatedText = item.text !== newItems[index]?.text + + return hasUpdatedValue || hasUpdatedText + }) + + if (hasItemsChanges) { + setItemsMap(newMap) + } + }, [children]) + + /** + * Warning: + * Downshift is expecting the items list to always be rendered, as per a11y guidelines. + * This is why the `Popover` is always opened in this component, but visually hidden instead from Combobox.Popover. + */ + const [WrapperComponent, wrapperProps] = hasPopover ? [Popover, { open: true }] : [Fragment, {}] + + return ( + + {children} + + ) +} + +export const useComboboxContext = () => { + const context = useContext(ComboboxContext) + + if (!context) { + throw Error('useComboboxContext must be used within a Combobox provider') + } + + return context +} diff --git a/packages/components/combobox/src/ComboboxDivider.tsx b/packages/components/combobox/src/ComboboxDivider.tsx new file mode 100644 index 000000000..72f284087 --- /dev/null +++ b/packages/components/combobox/src/ComboboxDivider.tsx @@ -0,0 +1,14 @@ +import { cx } from 'class-variance-authority' +import { forwardRef, type Ref } from 'react' + +interface DividerProps { + className?: string +} + +export const Divider = forwardRef( + ({ className }: DividerProps, forwardedRef: Ref) => { + return
+ } +) + +Divider.displayName = 'Combobox.Divider' diff --git a/packages/components/combobox/src/ComboboxGroup.tsx b/packages/components/combobox/src/ComboboxGroup.tsx new file mode 100644 index 000000000..c9df016e3 --- /dev/null +++ b/packages/components/combobox/src/ComboboxGroup.tsx @@ -0,0 +1,35 @@ +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' + +import { ComboboxGroupProvider, useComboboxGroupContext } from './ComboboxItemsGroupContext' + +interface GroupProps { + children: ReactNode + className?: string +} + +export const Group = forwardRef( + ({ children, ...props }: GroupProps, forwardedRef: Ref) => { + return ( + + + {children} + + + ) + } +) + +const GroupContent = forwardRef( + ({ children, className }: GroupProps, forwardedRef: Ref) => { + const { labelId } = useComboboxGroupContext() + + return ( +
+ {children} +
+ ) + } +) + +Group.displayName = 'Combobox.Group' diff --git a/packages/components/combobox/src/ComboboxInput.styles.tsx b/packages/components/combobox/src/ComboboxInput.styles.tsx new file mode 100644 index 000000000..64d144ff5 --- /dev/null +++ b/packages/components/combobox/src/ComboboxInput.styles.tsx @@ -0,0 +1,33 @@ +import { cva } from 'class-variance-authority' + +export const styles = cva( + [ + 'flex w-full items-center justify-between', + 'min-h-sz-44 rounded-lg bg-surface text-on-surface px-lg', + // outline styles + 'ring-1 outline-none ring-inset focus:ring-2', + ], + { + variants: { + state: { + undefined: 'ring-outline focus:ring-outline-high', + error: 'ring-error', + alert: 'ring-alert', + success: 'ring-success', + }, + disabled: { + true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3', + }, + readOnly: { + true: 'disabled:bg-on-surface/dim-5 cursor-not-allowed text-on-surface/dim-3', + }, + }, + compoundVariants: [ + { + disabled: false, + state: undefined, + class: 'hover:ring-outline-high', + }, + ], + } +) diff --git a/packages/components/combobox/src/ComboboxInput.tsx b/packages/components/combobox/src/ComboboxInput.tsx new file mode 100644 index 000000000..065e89223 --- /dev/null +++ b/packages/components/combobox/src/ComboboxInput.tsx @@ -0,0 +1,92 @@ +// import { Icon } from '@spark-ui/icon' +// import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalDown' +import { Input as SparkInput, type InputProps as SparkInputProps } from '@spark-ui/input' +import { Popover } from '@spark-ui/popover' +import { useMergeRefs } from '@spark-ui/use-merge-refs' +import { VisuallyHidden } from '@spark-ui/visually-hidden' +import { forwardRef, Fragment, ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' +// import { styles } from './ComboboxInput.styles' +// import { ComboboxStateIndicator } from './ComboboxStateIndicator' + +interface InputProps extends SparkInputProps { + 'aria-label'?: string + children: ReactNode + className?: string +} + +export const Input = forwardRef( + ( + { + 'aria-label': ariaLabel, + // children, className, + ...props + }: InputProps, + forwardedRef: Ref + ) => { + const { + // getToggleButtonProps, + getDropdownProps, + getInputProps, + getLabelProps, + hasPopover, + disabled, + readOnly, + // state, + setLastInteractionType, + } = useComboboxContext() + + const [WrapperComponent, wrapperProps] = hasPopover + ? [Popover.Trigger, { asChild: true }] + : [Fragment, {}] + + const { ref: downshiftRef, ...downshiftInputProps } = getInputProps({ + ...getDropdownProps(), + onKeyDown: () => { + setLastInteractionType('keyboard') + }, + }) + + const ref = useMergeRefs(forwardedRef, downshiftRef) + + return ( + <> + {ariaLabel && ( + + + + )} + +
+ + {/* */} +
+
+ + ) + } +) + +Input.displayName = 'Combobox.Input' diff --git a/packages/components/combobox/src/ComboboxItem.tsx b/packages/components/combobox/src/ComboboxItem.tsx new file mode 100644 index 000000000..85656ebd0 --- /dev/null +++ b/packages/components/combobox/src/ComboboxItem.tsx @@ -0,0 +1,101 @@ +import { useMergeRefs } from '@spark-ui/use-merge-refs' +import { cva, cx } from 'class-variance-authority' +import { forwardRef, type HTMLAttributes, type ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' +import { ComboboxItemProvider, useComboboxItemContext } from './ComboboxItemContext' + +export interface ItemProps extends HTMLAttributes { + disabled?: boolean + value: string + children: ReactNode + className?: string +} + +export const Item = forwardRef( + ({ children, ...props }: ItemProps, forwardedRef: Ref) => { + const { value, disabled } = props + + return ( + + + {children} + + + ) + } +) + +const styles = cva('px-lg py-md text-body-1', { + variants: { + selected: { + true: 'font-bold', + }, + disabled: { + true: 'opacity-dim-3 cursor-not-allowed', + false: 'cursor-pointer', + }, + highlighted: { + true: '', + }, + interactionType: { + mouse: '', + keyboard: '', + }, + }, + compoundVariants: [ + { + highlighted: true, + interactionType: 'mouse', + class: 'bg-surface-hovered', + }, + { + highlighted: true, + interactionType: 'keyboard', + class: 'u-ring', + }, + ], +}) + +const ItemContent = forwardRef( + ( + { className, disabled = false, value, children }: ItemProps, + forwardedRef: Ref + ) => { + const { getItemProps, highlightedItem, lastInteractionType, filteredItemsMap } = + useComboboxContext() + const { textId, index, itemData, isSelected } = useComboboxItemContext() + + const isHighlighted = highlightedItem?.value === value + + const isVisible = Array.from(filteredItemsMap).some(([key]) => key === value) + + const { ref: downshiftRef, ...downshiftItemProps } = getItemProps({ item: itemData, index }) + const ref = useMergeRefs(forwardedRef, downshiftRef) + + if (!isVisible) return null + + return ( +
  • + {children} +
  • + ) + } +) + +Item.displayName = 'Combobox.Item' diff --git a/packages/components/combobox/src/ComboboxItemContext.tsx b/packages/components/combobox/src/ComboboxItemContext.tsx new file mode 100644 index 000000000..1068a9ad7 --- /dev/null +++ b/packages/components/combobox/src/ComboboxItemContext.tsx @@ -0,0 +1,53 @@ +import React, { createContext, type PropsWithChildren, useContext, useState } from 'react' + +import { useComboboxContext } from './ComboboxContext' +import { ComboboxItem } from './types' +import { getIndexByKey, getItemText } from './utils' + +type ItemTextId = string | undefined + +interface ComboboxItemContextState { + textId: ItemTextId + setTextId: React.Dispatch> + isSelected: boolean + itemData: ComboboxItem + index: number + disabled: boolean +} + +const ComboboxItemContext = createContext(null) + +export const ComboboxItemProvider = ({ + value, + disabled = false, + children, +}: PropsWithChildren<{ value: string; disabled?: boolean }>) => { + const { multiple, itemsMap, selectedItem, selectedItems } = useComboboxContext() + + const [textId, setTextId] = useState(undefined) + + const index = getIndexByKey(itemsMap, value) + const itemData: ComboboxItem = { disabled, value, text: getItemText(children) } + + const isSelected = multiple + ? selectedItems.some(selectedItem => selectedItem.value === value) + : selectedItem?.value === value + + return ( + + {children} + + ) +} + +export const useComboboxItemContext = () => { + const context = useContext(ComboboxItemContext) + + if (!context) { + throw Error('useComboboxItemContext must be used within a ComboboxItem provider') + } + + return context +} diff --git a/packages/components/combobox/src/ComboboxItemIndicator.tsx b/packages/components/combobox/src/ComboboxItemIndicator.tsx new file mode 100644 index 000000000..1fc5b818c --- /dev/null +++ b/packages/components/combobox/src/ComboboxItemIndicator.tsx @@ -0,0 +1,32 @@ +import { Check } from '@spark-ui/icons/dist/icons/Check' +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' +import { useComboboxItemContext } from './ComboboxItemContext' + +export interface ItemIndicatorProps { + children?: ReactNode + className?: string + label?: string +} + +export const ItemIndicator = forwardRef( + ({ className, children, label }: ItemIndicatorProps, forwardedRef: Ref) => { + const { disabled, isSelected } = useComboboxItemContext() + const { multiple } = useComboboxContext() + const childElement = + children || (multiple ? : ) + + return ( + + {isSelected && childElement} + + ) + } +) + +ItemIndicator.displayName = 'Combobox.ItemIndicator' diff --git a/packages/components/combobox/src/ComboboxItemText.tsx b/packages/components/combobox/src/ComboboxItemText.tsx new file mode 100644 index 000000000..336d138e1 --- /dev/null +++ b/packages/components/combobox/src/ComboboxItemText.tsx @@ -0,0 +1,31 @@ +import { useId } from '@radix-ui/react-id' +import { cx } from 'class-variance-authority' +import { forwardRef, type Ref, useEffect } from 'react' + +import { useComboboxItemContext } from './ComboboxItemContext' + +export interface ItemTextProps { + children: string +} + +export const ItemText = forwardRef( + ({ children }: ItemTextProps, forwardedRef: Ref) => { + const id = useId() + + const { setTextId } = useComboboxItemContext() + + useEffect(() => { + setTextId(id) + + return () => setTextId(undefined) + }) + + return ( + + {children} + + ) + } +) + +ItemText.displayName = 'Combobox.ItemText' diff --git a/packages/components/combobox/src/ComboboxItems.tsx b/packages/components/combobox/src/ComboboxItems.tsx new file mode 100644 index 000000000..9ef298798 --- /dev/null +++ b/packages/components/combobox/src/ComboboxItems.tsx @@ -0,0 +1,43 @@ +import { useMergeRefs } from '@spark-ui/use-merge-refs' +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' + +interface ItemsProps { + children: ReactNode + className?: string +} + +export const Items = forwardRef( + ({ children, className, ...props }: ItemsProps, forwardedRef: Ref) => { + const { isOpen, getMenuProps, hasPopover, setLastInteractionType } = useComboboxContext() + + const { ref: downshiftRef, ...downshiftMenuProps } = getMenuProps({ + onMouseMove: () => { + setLastInteractionType('mouse') + }, + }) + + const ref = useMergeRefs(forwardedRef, downshiftRef) + + return ( +
      + {children} +
    + ) + } +) + +Items.displayName = 'Combobox.Items' diff --git a/packages/components/combobox/src/ComboboxItemsGroupContext.tsx b/packages/components/combobox/src/ComboboxItemsGroupContext.tsx new file mode 100644 index 000000000..2f93c6c84 --- /dev/null +++ b/packages/components/combobox/src/ComboboxItemsGroupContext.tsx @@ -0,0 +1,28 @@ +import { useId } from '@radix-ui/react-id' +import React, { createContext, type PropsWithChildren, useContext } from 'react' + +export interface ComboboxContextState { + labelId: string +} + +type ComboboxContextProps = PropsWithChildren + +const ComboboxGroupContext = createContext(null) + +export const ComboboxGroupProvider = ({ children }: ComboboxContextProps) => { + const labelId = useId() + + return ( + {children} + ) +} + +export const useComboboxGroupContext = () => { + const context = useContext(ComboboxGroupContext) + + if (!context) { + throw Error('useComboboxGroupContext must be used within a ComboboxGroup provider') + } + + return context +} diff --git a/packages/components/combobox/src/ComboboxLabel.tsx b/packages/components/combobox/src/ComboboxLabel.tsx new file mode 100644 index 000000000..8bb8fc191 --- /dev/null +++ b/packages/components/combobox/src/ComboboxLabel.tsx @@ -0,0 +1,27 @@ +import { cx } from 'class-variance-authority' +import { forwardRef, type Ref } from 'react' + +import { useComboboxGroupContext } from './ComboboxItemsGroupContext' + +interface LabelProps { + children: string + className?: string +} + +export const Label = forwardRef( + ({ children, className }: LabelProps, forwardedRef: Ref) => { + const { labelId } = useComboboxGroupContext() + + return ( +
    + {children} +
    + ) + } +) + +Label.displayName = 'Combobox.Label' diff --git a/packages/components/combobox/src/ComboboxLeadingIcon.tsx b/packages/components/combobox/src/ComboboxLeadingIcon.tsx new file mode 100644 index 000000000..9c91f968c --- /dev/null +++ b/packages/components/combobox/src/ComboboxLeadingIcon.tsx @@ -0,0 +1,12 @@ +import { Icon } from '@spark-ui/icon' +import { ReactElement } from 'react' + +export const LeadingIcon = ({ children }: { children: ReactElement }) => { + return ( + + {children} + + ) +} + +LeadingIcon.displayName = 'Combobox.LeadingIcon' diff --git a/packages/components/combobox/src/ComboboxPopover.tsx b/packages/components/combobox/src/ComboboxPopover.tsx new file mode 100644 index 000000000..5641a358f --- /dev/null +++ b/packages/components/combobox/src/ComboboxPopover.tsx @@ -0,0 +1,50 @@ +import { Popover as SparkPopover } from '@spark-ui/popover' +import { cx } from 'class-variance-authority' +import { ComponentProps, forwardRef, Ref, useEffect } from 'react' + +import { useComboboxContext } from './ComboboxContext' + +export const Popover = forwardRef( + ( + { + children, + matchTriggerWidth = true, + sideOffset = 4, + className, + ...props + }: ComponentProps, + forwardedRef: Ref + ) => { + const { isOpen, setHasPopover } = useComboboxContext() + + useEffect(() => { + setHasPopover(true) + + return () => setHasPopover(false) + }, []) + + return ( + { + /** + * With a combobox pattern, the focus should remain on the trigger at all times. + * Passing the focus to the combobox popover would break keyboard navigation. + */ + e.preventDefault() + }} + {...props} + data-spark-component="combobox-popover" + > + {children} + + ) + } +) + +Popover.displayName = 'Combobox.Popover' diff --git a/packages/components/combobox/src/ComboboxStateIndicator.tsx b/packages/components/combobox/src/ComboboxStateIndicator.tsx new file mode 100644 index 000000000..099086044 --- /dev/null +++ b/packages/components/combobox/src/ComboboxStateIndicator.tsx @@ -0,0 +1,28 @@ +import { Icon } from '@spark-ui/icon' +import { AlertOutline } from '@spark-ui/icons/dist/icons/AlertOutline' +import { Check } from '@spark-ui/icons/dist/icons/Check' +import { WarningOutline } from '@spark-ui/icons/dist/icons/WarningOutline' +import { cx } from 'class-variance-authority' + +import { useComboboxContext } from './ComboboxContext' + +const icons = { + error: , + alert: , + success: , +} + +export const ComboboxStateIndicator = () => { + const { state } = useComboboxContext() + + if (!state) return null + + return ( + + {icons[state]} + + ) +} + +ComboboxStateIndicator.id = 'StateIndicator' +ComboboxStateIndicator.displayName = 'Combobox.StateIndicator' diff --git a/packages/components/combobox/src/ComboboxValue.tsx b/packages/components/combobox/src/ComboboxValue.tsx new file mode 100644 index 000000000..5705b489f --- /dev/null +++ b/packages/components/combobox/src/ComboboxValue.tsx @@ -0,0 +1,31 @@ +import { cx } from 'class-variance-authority' +import { forwardRef, ReactNode, type Ref } from 'react' + +import { useComboboxContext } from './ComboboxContext' + +export interface ValueProps { + children?: ReactNode + className?: string + placeholder: string +} + +export const Value = forwardRef( + ({ children, className, placeholder }: ValueProps, forwardedRef: Ref) => { + const { selectedItem, multiple, selectedItems } = useComboboxContext() + + const hasSelectedItems = !!(multiple ? selectedItems.length : selectedItem) + const text = multiple ? selectedItems[0]?.text : selectedItem?.text + const suffix = selectedItems.length > 1 ? `, +${selectedItems.length - 1}` : '' + + return ( + + + {!hasSelectedItems ? placeholder : children || text} + + {suffix && {suffix}} + + ) + } +) + +Value.displayName = 'Combobox.Value' diff --git a/packages/components/combobox/src/index.ts b/packages/components/combobox/src/index.ts index 79749a4f8..ca31bfd5f 100644 --- a/packages/components/combobox/src/index.ts +++ b/packages/components/combobox/src/index.ts @@ -1 +1,60 @@ -export { Combobox } from './Combobox' +import type { FC } from 'react' + +import { Combobox as Root, type ComboboxProps } from './Combobox' +import { ComboboxProvider, useComboboxContext } from './ComboboxContext' +import { Divider } from './ComboboxDivider' +import { Empty } from './ComboboxEmpty' +import { Group } from './ComboboxGroup' +import { Input } from './ComboboxInput' +import { Item } from './ComboboxItem' +import { ItemIndicator } from './ComboboxItemIndicator' +import { Items } from './ComboboxItems' +import { ItemText } from './ComboboxItemText' +import { Label } from './ComboboxLabel' +import { LeadingIcon } from './ComboboxLeadingIcon' +import { Popover } from './ComboboxPopover' +import { Value } from './ComboboxValue' + +export { useComboboxContext, ComboboxProvider } + +export const Combobox: FC & { + Group: typeof Group + Item: typeof Item + Items: typeof Items + ItemText: typeof ItemText + ItemIndicator: typeof ItemIndicator + Label: typeof Label + Popover: typeof Popover + Divider: typeof Divider + Input: typeof Input + Value: typeof Value + LeadingIcon: typeof LeadingIcon + Empty: typeof Empty +} = Object.assign(Root, { + Group, + Item, + Items, + ItemText, + ItemIndicator, + Label, + Popover, + Divider, + Input, + Value, + LeadingIcon, + Empty, +}) + +Combobox.displayName = 'Combobox' +Group.displayName = 'Combobox.Group' +Items.displayName = 'Combobox.Items' +Item.displayName = 'Combobox.Item' +ItemText.displayName = 'Combobox.ItemText' +ItemIndicator.displayName = 'Combobox.ItemIndicator' +Label.displayName = 'Combobox.Label' +Popover.displayName = 'Combobox.Popover' +Divider.displayName = 'Combobox.Divider' +Input.displayName = 'Combobox.Input' +Value.displayName = 'Combobox.Value' +LeadingIcon.displayName = 'Combobox.LeadingIcon' +Empty.displayName = 'Combobox.Empty' diff --git a/packages/components/combobox/src/types.ts b/packages/components/combobox/src/types.ts new file mode 100644 index 000000000..27b08eb43 --- /dev/null +++ b/packages/components/combobox/src/types.ts @@ -0,0 +1,12 @@ +import { type UseComboboxReturnValue, type UseMultipleSelectionReturnValue } from 'downshift' + +export interface ComboboxItem { + disabled: boolean + value: string + text: string +} + +export type ItemsMap = Map + +export type DownshiftState = UseComboboxReturnValue & + UseMultipleSelectionReturnValue diff --git a/packages/components/combobox/src/useCombobox.ts b/packages/components/combobox/src/useCombobox.ts new file mode 100644 index 000000000..24cb6e915 --- /dev/null +++ b/packages/components/combobox/src/useCombobox.ts @@ -0,0 +1,138 @@ +import { + useCombobox as useDownshiftCombobox, + UseComboboxProps, + useMultipleSelection, +} from 'downshift' +import { Dispatch, SetStateAction } from 'react' + +import { type ComboboxItem, type ItemsMap } from './types' + +type OnChangeValueType = string & string[] + +export interface DownshiftProps { + itemsMap: ItemsMap + filteredItems: ItemsMap + inputValue?: string + setInputValue: Dispatch> + value: string | string[] | undefined + defaultValue: string | string[] | undefined + onValueChange: ((value: string) => void) | ((value: string[]) => void) | undefined + open: boolean | undefined + onOpenChange: ((isOpen: boolean) => void) | undefined + defaultOpen: boolean | undefined + multiple: boolean | undefined + id: string + labelId: string +} + +/** + * This hooks abstract the complexity of using downshift with both single and multiple selection. + */ +export const useCombobox = ({ + itemsMap, + defaultValue, + value, + onValueChange, + open, + onOpenChange, + defaultOpen, + inputValue, + filteredItems, + setInputValue, + multiple, + id, + labelId, +}: DownshiftProps) => { + const items = [...itemsMap.values()] + + const downshiftMultipleSelection = useMultipleSelection({ + selectedItems: value + ? items.filter(item => (value as string[]).includes(item.value)) + : undefined, + initialSelectedItems: defaultValue + ? items.filter(item => (defaultValue as string[]).includes(item.value)) + : undefined, + + onSelectedItemsChange: ({ selectedItems }) => { + if (selectedItems != null && multiple) { + onValueChange?.(selectedItems.map(item => item.value) as OnChangeValueType) + } + }, + }) + + /** + * Custom state reducer for multiple selection behaviour: + * - keeps the component opened when the user selects an item + * - preserves the higlighted index when the user select an item + * - selected items can be unselected, even the last selected item (as opposed to single selection behaviour) + */ + const stateReducer: UseComboboxProps['stateReducer'] = ( + state, + { changes, type } + ) => { + if (!multiple) return changes + + const { selectedItems, removeSelectedItem, addSelectedItem } = downshiftMultipleSelection + + switch (type) { + case useDownshiftCombobox.stateChangeTypes.ItemClick: + if (changes.selectedItem != null) { + const isAlreadySelected = selectedItems.some( + selectedItem => selectedItem.value === changes.selectedItem?.value + ) + + if (isAlreadySelected) removeSelectedItem(changes.selectedItem) + else addSelectedItem(changes.selectedItem) + } + + return { + ...changes, + isOpen: true, // keep the menu open after selection. + highlightedIndex: state.highlightedIndex, // preserve highlighted index position + } + default: + return changes + } + } + + const downshift = useDownshiftCombobox({ + items, + isItemDisabled: item => { + const isFilteredOut = + !!inputValue && + ![...filteredItems].some(([_, filteredItem]) => { + return item.value === filteredItem.value + }) + + return item.disabled || isFilteredOut + }, + itemToString: item => (item ? item.text : ''), + // a11y attributes + id, + labelId, + // Controlled open state + isOpen: open, // undefined must be passed for stateful behaviour (uncontrolled) + onIsOpenChange: ({ isOpen }) => { + if (isOpen != null) onOpenChange?.(isOpen) + }, + initialIsOpen: defaultOpen ?? false, + stateReducer, + // Controlled mode (single selection) + selectedItem: value ? itemsMap.get(value as string) : undefined, + initialSelectedItem: defaultValue ? itemsMap.get(defaultValue as string) : undefined, + onSelectedItemChange: ({ selectedItem }) => { + if (selectedItem?.value && !multiple) { + onValueChange?.(selectedItem?.value as OnChangeValueType) + } + }, + inputValue, + onInputValueChange({ inputValue }) { + setInputValue(inputValue) + }, + }) + + return { + ...downshift, + ...downshiftMultipleSelection, + } +} diff --git a/packages/components/combobox/src/utils.ts b/packages/components/combobox/src/utils.ts new file mode 100644 index 000000000..6818c1ec3 --- /dev/null +++ b/packages/components/combobox/src/utils.ts @@ -0,0 +1,109 @@ +import React, { type FC, isValidElement, type ReactElement, type ReactNode } from 'react' + +import { type ItemProps } from './ComboboxItem' +import { type ComboboxItem, type ItemsMap } from './types' + +export function getIndexByKey(map: ItemsMap, targetKey: string) { + let index = 0 + for (const [key] of map.entries()) { + if (key === targetKey) { + return index + } + index++ + } + + return -1 +} + +const getKeyAtIndex = (map: ItemsMap, index: number) => { + let i = 0 + for (const key of map.keys()) { + if (i === index) return key + i++ + } + + return undefined +} + +export const getElementByIndex = (map: ItemsMap, index: number) => { + const key = getKeyAtIndex(map, index) + + return key !== undefined ? map.get(key) : undefined +} + +const getElementDisplayName = (element?: ReactElement) => { + return element ? (element.type as FC & { displayName?: string }).displayName : '' +} + +export const getOrderedItems = ( + children: ReactNode, + result: ComboboxItem[] = [] +): ComboboxItem[] => { + React.Children.forEach(children, child => { + if (!isValidElement(child)) return + + if (getElementDisplayName(child) === 'Combobox.Item') { + const childProps = child.props as ItemProps + result.push({ + value: childProps.value, + disabled: !!childProps.disabled, + text: getItemText(childProps.children), + }) + } + + if (child.props.children) { + getOrderedItems(child.props.children, result) + } + }) + + return result +} + +/** + * If Combobox.Item children: + * - is a string, then the string is used. + * - is JSX markup, then we look for Combobox.ItemText to get its string value. + */ +export const getItemText = (children: ReactNode, itemText = ''): string => { + if (typeof children === 'string') { + return children + } + + React.Children.forEach(children, child => { + if (!isValidElement(child)) return + + if (getElementDisplayName(child) === 'Combobox.ItemText') { + itemText = child.props.children + } + + if (child.props.children) { + getItemText(child.props.children, itemText) + } + }) + + return itemText +} + +export const getItemsFromChildren = (children: ReactNode): ItemsMap => { + const newMap: ItemsMap = new Map() + + getOrderedItems(children).forEach(itemData => { + newMap.set(itemData.value, itemData) + }) + + return newMap +} + +export const hasChildComponent = (children: ReactNode, displayName: string): boolean => { + return React.Children.toArray(children).some(child => { + if (!isValidElement(child)) return false + + if (getElementDisplayName(child) === displayName) { + return true + } else if (child.props.children) { + return hasChildComponent(child.props.children, displayName) + } + + return false + }) +}