diff --git a/package-lock.json b/package-lock.json
index 07704e855..95050f2e6 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": {
@@ -33344,6 +33345,16 @@
"name": "@spark-ui/combobox",
"version": "0.1.0",
"license": "MIT",
+ "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"
+ },
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0",
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/Combobox.doc.mdx b/packages/components/combobox/src/Combobox.doc.mdx
index d3cb04900..d39ac4619 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,197 @@ 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.
+
+### Controlled
+
+
+
+### Controlled open state
+
+
+
+### Disabled
+
+Use `disabled` on the root component to disable the combobox entirely.
+
+
+
+### Disabled Item
+
+Use `disabled` on individual `Combobox.Item` to disable them.
+
+
+
+### Filtering - AutoFilter
+
+Use `autoFilter` 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
+
+### Default
+
+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.
+
+
+
+### Controlled
+
+
+
+## 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..b4bd620bf 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 { Button } from '@spark-ui/button'
+import { FormField } from '@spark-ui/form-field'
+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,637 @@ const meta: Meta = {
export default meta
-export const Default: StoryFn = _args =>
+/**
+ * Minimal anatomy:
+ * - Combobox
+ * - Combobox.Trigger
+ * - Combobox.Input
+ * - Combobox.Popover
+ * - Combobox.Items
+ * - Combobox.Item
+ *
+ * Full anatomy:
+ * - Combobox
+ * - Combobox.Trigger
+ * - Combobox.LeadingIcon
+ * - Combobox.SelectedItems
+ * - Combobox.Input
+ * - Combobox.ClearButton
+ * - Combobox.Disclosure
+ * - Combobox.Popover
+ * - Combobox.Items
+ * - Combobox.Group
+ * - Combobox.Label
+ * - Combobox.Item
+ * - Combobox.ItemIndicator
+ * - Combobox.ItemText
+ *
+ * Filtering behaviour:
+ * - default: no filtering.
+ * - autoFilter: filters out values not matching the input.
+ * - autoSelect: filters out values not matching the input AND highlight the first matching item.
+ * - autoComplete: restrict typing in the input to any of the items values and highlight the rest of the first matching item behind the typing cursor
+ * - custom filtering: controlled mode for advancer filtering. Not managed by Spark.
+ *
+ * Optional parts:
+ * - Combobox.LeadingIcon
+ * - Combobox.ClearButton
+ * - Combobox.Disclosure
+ * - Combobox.Empty
+ * - Combobox.SelectedItems (chips)
+ * - Combobox.Popover
+ *
+ * Selection type:
+ * - single
+ * - multiple
+ */
+
+export const Default: StoryFn = _args => {
+ return (
+
+
+
+
+
+
+ To Kill a Mockingbird
+ War and Peace
+ The Idiot
+ A Picture of Dorian Gray
+ 1984
+ Pride and Prejudice
+
+
+
+
+
+
+
+
+
+
+ To Kill a Mockingbird
+ New
+
+
+ War and Peace
+ New
+
+
+ The Idiot
+ New
+
+
+ A Picture of Dorian Gray
+ New
+
+
+ 1984
+ New
+
+
+ Pride and Prejudice
+ New
+
+
+
+
+
+
+
+
+
+
+ Best-sellers
+ To Kill a Mockingbird
+ War and Peace
+ The Idiot
+
+
+
+
+
+ Novelties
+ A Picture of Dorian Gray
+ 1984
+ Pride and Prejudice
+
+
+
+
+
+ {statuses.map(status => {
+ return (
+
+
+
+
+
+ To Kill a Mockingbird
+ War and Peace
+ The Idiot
+ A Picture of Dorian Gray
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+ })}
+
+
+ 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
deleted file mode 100644
index 18ec46b06..000000000
--- a/packages/components/combobox/src/Combobox.test.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
-
-import { Combobox } from './Combobox'
-
-describe('Combobox', () => {
- it('should render', () => {
- render()
-
- expect(screen.getByText(/combobox/)).toBeInTheDocument()
- })
-})
diff --git a/packages/components/combobox/src/Combobox.tsx b/packages/components/combobox/src/Combobox.tsx
index ce8dac473..6bff53235 100644
--- a/packages/components/combobox/src/Combobox.tsx
+++ b/packages/components/combobox/src/Combobox.tsx
@@ -1 +1,19 @@
-export const Combobox = () => <>combobox>
+import { type ComboboxContextProps, ComboboxProvider } from './ComboboxContext'
+
+export type ComboboxProps = ComboboxContextProps
+
+export const Combobox = ({
+ children,
+ autoFilter = false,
+ disabled = false,
+ readOnly = false,
+ ...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..f2e9e0517
--- /dev/null
+++ b/packages/components/combobox/src/ComboboxContext.tsx
@@ -0,0 +1,251 @@
+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
+ setIsInputControlled: Dispatch>
+ setOnInputValueChange: Dispatch void) | undefined>>
+}
+
+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).
+ */
+ autoFilter?: 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 = ({
+ autoFilter = true,
+ children,
+ defaultValue,
+ value,
+ onValueChange,
+ open,
+ onOpenChange,
+ defaultOpen,
+ multiple = false,
+ disabled: disabledProp = false,
+ readOnly: readOnlyProp = false,
+ state: stateProp,
+}: ComboboxContextProps) => {
+ // Input state
+ const field = useFormFieldControl()
+ const [inputValue, setInputValue] = useState('')
+ const [onInputValueChange, setOnInputValueChange] = useState<(value: string) => void>()
+ const [isInputControlled, setIsInputControlled] = useState(false)
+ 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
+
+ // Items state
+ const [itemsMap, setItemsMap] = useState(getItemsFromChildren(children))
+ const [filteredItemsMap, setFilteredItems] = useState(
+ autoFilter ? getFilteredItemsMap(itemsMap, inputValue) : itemsMap
+ )
+ const [hasPopover, setHasPopover] = useState(
+ hasChildComponent(children, 'Combobox.Popover')
+ )
+ const [lastInteractionType, setLastInteractionType] = useState<'mouse' | 'keyboard'>('mouse')
+
+ useEffect(() => {
+ setFilteredItems(autoFilter ? getFilteredItemsMap(itemsMap, inputValue) : itemsMap)
+ }, [inputValue, itemsMap])
+
+ const handleDownshiftInputChange = (value: string | undefined) => {
+ if (!isInputControlled) {
+ setInputValue(value)
+ }
+ }
+
+ // Downshift state
+ const comboboxState = useCombobox({
+ itemsMap,
+ defaultValue,
+ value,
+ onValueChange,
+ open,
+ onOpenChange,
+ defaultOpen,
+ multiple,
+ id,
+ labelId,
+ inputValue,
+ setInputValue: handleDownshiftInputChange,
+ onInputValueChange,
+ 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/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 ? (
+