feat: add dify-ui autocomplete and combobox (#35868)

This commit is contained in:
yyh 2026-05-07 13:39:13 +08:00 committed by GitHub
parent 8fd616d27f
commit a24ec60e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2888 additions and 18 deletions

View File

@ -56,4 +56,28 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one
- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`).
- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`.
## Search / Picker Primitive Selection: Autocomplete vs Combobox vs Select
Pick by whether the user is entering free-form text, choosing a remembered value, or selecting from a closed list.
Base UI decision rules:
- [Autocomplete docs]: use `Combobox` instead of `Autocomplete` if the selection should be remembered and the input value cannot be custom.
- [Combobox docs]: do not use `Combobox` for simple search widgets that require unrestricted text entry; use `Autocomplete` instead.
Apply this split in Dify UI:
- `Autocomplete` — free-form text input with optional suggestions or completions. The input value may be custom and does not necessarily become a selected option. Use for search boxes, command-style suggestions, tag suggestions, and async text completion.
- `Combobox` — searchable picker whose value is one or more selected items from a collection. The chosen value is remembered by the root, and free-form text is not the final value. Use for model pickers, user pickers, dataset/document pickers, and multi-select chips.
- `Select` — closed-list picker without text entry. Use when the option set is small or already scannable and filtering is unnecessary.
Composition rules:
- Keep Base UI primitive semantics visible in the public API. Export compound parts such as `ComboboxInputGroup`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, and `ComboboxItemIndicator` instead of wrapping them into one business component.
- For `Combobox` multiple selection, follow the official chips pattern: `ComboboxInputGroup` contains `ComboboxChips`, `ComboboxValue` renders `ComboboxChip` items, and `ComboboxInput` remains inside the chips row. Chips should wrap and let the input group grow vertically instead of forcing horizontal overflow.
- Content primitives must own their Base UI `Portal` and use `z-1002` on `Positioner`, matching the overlay contract in `README.md`.
- Use `w-(--anchor-width)` with viewport-aware max-width for `Autocomplete` and `Combobox` popups. Do not add `min-w-(--anchor-width)` when it would defeat available-width clamping.
[Autocomplete docs]: https://base-ui.com/react/components/autocomplete.md#usage-guidelines
[Combobox docs]: https://base-ui.com/react/components/combobox.md#usage-guidelines
[docs]: https://base-ui.com/react/components/tooltip#infotips

View File

@ -36,12 +36,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities:
@ -65,7 +65,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
## Overlay & portal contract
All overlay primitives (`dialog`, `alert-dialog`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
### Root isolation requirement
@ -83,10 +83,10 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where |
| ----------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
| Layer | z-index | Where |
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.

View File

@ -13,6 +13,10 @@
"types": "./src/alert-dialog/index.tsx",
"import": "./src/alert-dialog/index.tsx"
},
"./autocomplete": {
"types": "./src/autocomplete/index.tsx",
"import": "./src/autocomplete/index.tsx"
},
"./avatar": {
"types": "./src/avatar/index.tsx",
"import": "./src/avatar/index.tsx"
@ -21,6 +25,10 @@
"types": "./src/button/index.tsx",
"import": "./src/button/index.tsx"
},
"./combobox": {
"types": "./src/combobox/index.tsx",
"import": "./src/combobox/index.tsx"
},
"./context-menu": {
"types": "./src/context-menu/index.tsx",
"import": "./src/context-menu/index.tsx"
@ -103,6 +111,7 @@
"@storybook/addon-themes": "catalog:",
"@storybook/react-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tanstack/react-virtual": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript/native-preview": "catalog:",

View File

@ -0,0 +1,252 @@
import type { ReactNode } from 'react'
import { render } from 'vitest-browser-react'
import {
Autocomplete,
AutocompleteClear,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteItemIndicator,
AutocompleteItemText,
AutocompleteLabel,
AutocompleteList,
AutocompleteSeparator,
AutocompleteStatus,
AutocompleteTrigger,
} from '../index'
const renderWithSafeViewport = (ui: ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const renderAutocomplete = ({
children,
open = false,
defaultValue = 'workflow',
}: {
children?: ReactNode
open?: boolean
defaultValue?: string
} = {}) => renderWithSafeViewport(
<Autocomplete open={open} defaultValue={defaultValue} items={['workflow', 'dataset']}>
{children ?? (
<>
<AutocompleteInputGroup data-testid="input-group">
<AutocompleteInput aria-label="Search suggestions" data-testid="input" />
<AutocompleteClear data-testid="clear" />
<AutocompleteTrigger data-testid="trigger" />
</AutocompleteInputGroup>
<AutocompleteContent
positionerProps={{
'role': 'group',
'aria-label': 'autocomplete positioner',
}}
popupProps={{
'role': 'dialog',
'aria-label': 'autocomplete popup',
}}
>
<AutocompleteStatus data-testid="status">2 suggestions</AutocompleteStatus>
<AutocompleteList role="listbox" aria-label="autocomplete list" data-testid="list">
<AutocompleteItem value="workflow">
<AutocompleteItemText>Workflow</AutocompleteItemText>
<AutocompleteItemIndicator />
</AutocompleteItem>
<AutocompleteItem value="dataset">
<AutocompleteItemText>Dataset</AutocompleteItemText>
</AutocompleteItem>
</AutocompleteList>
<AutocompleteEmpty data-testid="empty">No suggestions</AutocompleteEmpty>
</AutocompleteContent>
</>
)}
</Autocomplete>,
)
describe('Autocomplete wrappers', () => {
describe('Input group and input', () => {
it('should apply medium input group and input classes by default', async () => {
const screen = await renderAutocomplete()
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-lg')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('px-3')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('system-sm-regular')
})
it('should apply large input group and input classes when large size is provided', async () => {
const screen = await renderAutocomplete({
children: (
<AutocompleteInputGroup size="large" data-testid="input-group">
<AutocompleteInput size="large" aria-label="Search suggestions" data-testid="input" />
</AutocompleteInputGroup>
),
})
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-[10px]')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('px-4')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('system-md-regular')
})
it('should set input defaults and forward passthrough props', async () => {
const screen = await renderAutocomplete({
children: (
<AutocompleteInputGroup>
<AutocompleteInput
aria-label="Search suggestions"
className="custom-input"
placeholder="Find a resource"
required
/>
</AutocompleteInputGroup>
),
})
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('autocomplete', 'off')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('type', 'text')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('placeholder', 'Find a resource')
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toBeRequired()
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('custom-input')
})
})
describe('Controls', () => {
it('should provide fallback aria labels and decorative icons when labels are omitted', async () => {
const screen = await renderAutocomplete()
await expect.element(screen.getByRole('button', { name: 'Clear autocomplete' })).toHaveAttribute('type', 'button')
await expect.element(screen.getByRole('button', { name: 'Open autocomplete suggestions' })).toHaveAttribute('type', 'button')
expect(screen.getByRole('button', { name: 'Clear autocomplete' }).element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('button', { name: 'Open autocomplete suggestions' }).element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should preserve explicit labels and custom children', async () => {
const screen = await renderAutocomplete({
children: (
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search suggestions" />
<AutocompleteClear aria-label="Reset search">
<span data-testid="custom-clear">reset</span>
</AutocompleteClear>
<AutocompleteTrigger aria-label="Show suggestions">
<span data-testid="custom-trigger">open</span>
</AutocompleteTrigger>
</AutocompleteInputGroup>
),
})
expect(screen.getByRole('button', { name: 'Reset search' }).element()).toContainElement(screen.getByTestId('custom-clear').element())
expect(screen.getByRole('button', { name: 'Show suggestions' }).element()).toContainElement(screen.getByTestId('custom-trigger').element())
expect(screen.getByRole('button', { name: 'Reset search' }).element().querySelector('.i-ri-close-line')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show suggestions' }).element().querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => {
const screen = await renderAutocomplete({
children: (
<>
<span id="clear-label">Clear from label</span>
<span id="trigger-label">Trigger from label</span>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search suggestions" />
<AutocompleteClear aria-labelledby="clear-label" />
<AutocompleteTrigger aria-labelledby="trigger-label" />
</AutocompleteInputGroup>
</>
),
})
await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label')
await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label')
})
})
describe('Content and options', () => {
it('should use default overlay placement and Dify popup classes', async () => {
const screen = await renderAutocomplete({ open: true })
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-1002')
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('rounded-xl')
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('w-(--anchor-width)')
await expect.element(screen.getByRole('listbox', { name: 'autocomplete list' })).toHaveClass('scroll-py-1')
})
it('should apply custom placement side and passthrough popup props', async () => {
const onPopupClick = vi.fn()
const screen = await renderWithSafeViewport(
<Autocomplete open defaultValue="workflow" items={['workflow']}>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search suggestions" />
</AutocompleteInputGroup>
<AutocompleteContent
placement="top-end"
sideOffset={12}
alignOffset={6}
positionerProps={{ 'role': 'group', 'aria-label': 'autocomplete positioner' }}
popupProps={{
'role': 'dialog',
'aria-label': 'autocomplete popup',
'onClick': onPopupClick,
}}
>
<AutocompleteList role="listbox" aria-label="autocomplete list">
<AutocompleteItem value="workflow">
<AutocompleteItemText>Workflow</AutocompleteItemText>
</AutocompleteItem>
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>,
)
asHTMLElement(screen.getByRole('dialog', { name: 'autocomplete popup' }).element()).click()
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'top')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
it('should render item text indicator status and empty wrappers with design classes', async () => {
const screen = await renderAutocomplete({ open: true })
await expect.element(screen.getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
expect(screen.getByText('Workflow').element().parentElement?.querySelector('.i-ri-arrow-right-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should forward custom classes to label separator item text and indicator', async () => {
const screen = await renderWithSafeViewport(
<Autocomplete open defaultValue="workflow" items={['workflow']}>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search suggestions" />
</AutocompleteInputGroup>
<AutocompleteContent popupProps={{ 'role': 'dialog', 'aria-label': 'autocomplete popup' }}>
<AutocompleteList role="listbox" aria-label="autocomplete list">
<AutocompleteGroup items={['workflow']}>
<AutocompleteLabel className="custom-label">Resources</AutocompleteLabel>
<AutocompleteSeparator className="custom-separator" data-testid="separator" />
<AutocompleteItem value="workflow" className="custom-item">
<AutocompleteItemText className="custom-text">Workflow</AutocompleteItemText>
<AutocompleteItemIndicator className="custom-indicator" data-testid="indicator" />
</AutocompleteItem>
</AutocompleteGroup>
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>,
)
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item')
await expect.element(screen.getByText('Workflow')).toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
})
})

View File

@ -0,0 +1,721 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Autocomplete,
AutocompleteClear,
AutocompleteCollection,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteItemText,
AutocompleteLabel,
AutocompleteList,
AutocompleteSeparator,
AutocompleteStatus,
AutocompleteTrigger,
useAutocompleteFilter,
useAutocompleteFilteredItems,
} from '.'
import { cn } from '../cn'
type Suggestion = {
value: string
label: string
description?: string
icon?: string
meta?: string
}
type SuggestionGroup = {
label: string
items: Suggestion[]
}
const inputWidth = 'w-80'
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
const scrollHighlightedVirtualItem = (
item: unknown,
{
reason,
index,
}: {
reason: 'keyboard' | 'pointer' | 'none'
index: number
},
virtualizer: StoryVirtualizer | null,
) => {
if (!item || !virtualizer)
return
const isStart = index === 0
const isEnd = index === virtualizer.options.count - 1
const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd))
if (shouldScroll) {
queueMicrotask(() => {
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' })
})
}
}
const tagSuggestions: Suggestion[] = [
{ value: 'feature', label: 'feature', description: 'Product work and launch notes' },
{ value: 'fix', label: 'fix', description: 'Bug fixes and regressions' },
{ value: 'docs', label: 'docs', description: 'Documentation updates' },
{ value: 'internal', label: 'internal', description: 'Workspace-only notes' },
{ value: 'mobile', label: 'mobile', description: 'Mobile app issues' },
{ value: 'component: autocomplete', label: 'component: autocomplete', description: 'Base UI primitive wrapper' },
{ value: 'component: combobox', label: 'component: combobox', description: 'Filterable predefined selection' },
{ value: 'component: select', label: 'component: select', description: 'Compact predefined selection' },
]
const promptCompletions: Suggestion[] = [
{ value: 'summarize this conversation', label: 'summarize this conversation' },
{ value: 'summarize this dataset with citations', label: 'summarize this dataset with citations' },
{ value: 'summarize this workflow run for an operator', label: 'summarize this workflow run for an operator' },
{ value: 'summarize this support ticket in 3 bullets', label: 'summarize this support ticket in 3 bullets' },
]
const workflowSuggestions: Suggestion[] = [
{ value: 'http-request', label: 'HTTP Request', description: 'Call an external API', icon: 'i-ri-global-line', meta: 'Tool' },
{ value: 'knowledge-retrieval', label: 'Knowledge Retrieval', description: 'Search configured datasets', icon: 'i-ri-database-2-line', meta: 'Tool' },
{ value: 'code-execution', label: 'Code Execution', description: 'Run sandboxed snippets', icon: 'i-ri-code-s-slash-line', meta: 'Tool' },
{ value: 'template-transform', label: 'Template Transform', description: 'Compose variables into output', icon: 'i-ri-braces-line', meta: 'Tool' },
{ value: 'question-classifier', label: 'Question Classifier', description: 'Route by intent', icon: 'i-ri-git-branch-line', meta: 'Tool' },
{ value: 'parameter-extractor', label: 'Parameter Extractor', description: 'Extract typed values', icon: 'i-ri-list-check-3', meta: 'Tool' },
{ value: 'answer-node', label: 'Answer Node', description: 'Return a final assistant answer', icon: 'i-ri-message-3-line', meta: 'Node' },
{ value: 'iteration-node', label: 'Iteration Node', description: 'Run a loop over array items', icon: 'i-ri-repeat-line', meta: 'Node' },
{ value: 'variable-assigner', label: 'Variable Assigner', description: 'Persist intermediate state', icon: 'i-ri-pencil-ruler-2-line', meta: 'Node' },
]
const groupedSuggestions: SuggestionGroup[] = [
{
label: 'Tags',
items: tagSuggestions.slice(0, 5),
},
{
label: 'Workflow Suggestions',
items: workflowSuggestions.slice(0, 5),
},
{
label: 'Prompt Starters',
items: promptCompletions.slice(0, 3),
},
]
const commandGroups: SuggestionGroup[] = [
{
label: 'App',
items: [
{ value: '/run', label: 'Run workflow', description: 'Execute the current draft', icon: 'i-ri-play-circle-line' },
{ value: '/publish', label: 'Publish app', description: 'Ship the current configuration', icon: 'i-ri-upload-cloud-2-line' },
{ value: '/trace', label: 'Open trace', description: 'Inspect the latest workflow run', icon: 'i-ri-route-line' },
],
},
{
label: 'Workspace',
items: [
{ value: '/dataset', label: 'Search datasets', description: 'Find knowledge attached to this app', icon: 'i-ri-database-line' },
{ value: '/members', label: 'Invite members', description: 'Open workspace access settings', icon: 'i-ri-user-add-line' },
{ value: '/usage', label: 'View usage', description: 'Open model and workflow usage', icon: 'i-ri-bar-chart-line' },
],
},
]
const remoteSuggestions: Suggestion[] = [
{ value: 'agent-builder', label: 'Agent Builder', description: 'Workspace app' },
{ value: 'agent-observability', label: 'Agent Observability', description: 'Dataset' },
{ value: 'agent-routing-dataset', label: 'Agent Routing Dataset', description: 'Knowledge source' },
]
const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, index) => {
const family = ['workflow', 'dataset', 'prompt', 'tool'][index % 4]!
const number = new Intl.NumberFormat('en-US', {
minimumIntegerDigits: 4,
}).format(index + 1)
return {
value: `${family}-${index + 1}`,
label: `${family} suggestion ${number}`,
description: `Free-form autocomplete result from ${family} search`,
icon: family === 'dataset'
? 'i-ri-database-2-line'
: family === 'prompt'
? 'i-ri-text-snippet'
: family === 'tool'
? 'i-ri-tools-line'
: 'i-ri-flow-chart',
meta: family,
}
})
const getSuggestionLabel = (item: Suggestion) => item.label
const SuggestionItem = ({
item,
index,
dense,
}: {
item: Suggestion
index?: number
dense?: boolean
}) => (
<AutocompleteItem value={item} index={index}>
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
const TagSuggestionItem = ({
item,
index,
}: {
item: Suggestion
index?: number
}) => (
<AutocompleteItem value={item} index={index}>
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{item.description && <span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">{item.description}</span>}
</AutocompleteItem>
)
const BasicTagAutocomplete = ({
size = 'medium',
}: {
size?: 'small' | 'medium' | 'large'
}) => (
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
openOnInputClick
>
<AutocompleteInputGroup size={size}>
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput size={size} placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" />
<AutocompleteClear size={size} />
<AutocompleteTrigger size={size} />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
)
const GroupedSuggestionList = () => {
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
return (
<AutocompleteList>
{groups.map((group, groupIndex) => (
<AutocompleteGroup key={group.label} items={group.items}>
{groupIndex > 0 && <AutocompleteSeparator />}
<AutocompleteLabel>{group.label}</AutocompleteLabel>
<AutocompleteCollection>
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteCollection>
</AutocompleteGroup>
))}
</AutocompleteList>
)
}
const CommandPaletteList = () => {
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
return (
<AutocompleteList className="max-h-72 rounded-lg border border-divider-subtle bg-components-panel-bg p-1 shadow-xs">
{groups.map((group, groupIndex) => (
<AutocompleteGroup key={group.label} items={group.items}>
{groupIndex > 0 && <AutocompleteSeparator />}
<AutocompleteLabel>{group.label}</AutocompleteLabel>
<AutocompleteCollection>
{(item: Suggestion) => (
<AutocompleteItem key={item.value} value={item} className="grid grid-cols-[1fr_auto]">
<span className="flex min-w-0 items-center gap-2">
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
<span className="min-w-0">
<AutocompleteItemText className="block px-0">{item.label}</AutocompleteItemText>
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
</span>
</span>
<kbd className="rounded-md border border-divider-subtle bg-components-badge-bg-dimm px-1.5 py-0.5 text-text-quaternary system-2xs-medium">
Enter
</kbd>
</AutocompleteItem>
)}
</AutocompleteCollection>
</AutocompleteGroup>
))}
</AutocompleteList>
)
}
const LimitedStatus = ({
total,
}: {
total: number
}) => {
const items = useAutocompleteFilteredItems<Suggestion>()
const hidden = Math.max(0, total - items.length)
return hidden > 0
? `${hidden} more suggestions hidden. Refine the query to narrow results.`
: `${items.length} suggestions available.`
}
const AsyncSearchDemo = () => {
const [value, setValue] = useState('agent')
const [loading, setLoading] = useState(false)
const [items, setItems] = useState(remoteSuggestions)
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
setItems(
value.trim()
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
: remoteSuggestions,
)
setLoading(false)
}, 500)
return () => window.clearTimeout(timeout)
}, [value])
return (
<div className={inputWidth}>
<Autocomplete
items={items}
value={value}
onValueChange={setValue}
itemToStringValue={getSuggestionLabel}
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Search remote resources…" aria-label="Search remote resources" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteStatus>
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
)
}
const VirtualizedSuggestionList = ({
virtualizerRef,
}: {
virtualizerRef: RefObject<StoryVirtualizer | null>
}) => {
const scrollRef = useRef<HTMLDivElement | null>(null)
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
const virtualizer = useVirtualizer({
count: filteredItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 44,
overscan: 6,
})
useEffect(() => {
virtualizerRef.current = virtualizer
return () => {
virtualizerRef.current = null
}
}, [virtualizer, virtualizerRef])
return (
<div
ref={scrollRef}
className="max-h-[min(22rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain outline-hidden"
>
<AutocompleteList
className="relative max-h-none overflow-visible p-0"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = filteredItems[virtualItem.index]
if (!item)
return null
return (
<div
key={virtualItem.key}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<SuggestionItem item={item} index={virtualItem.index} />
</div>
)
})}
</AutocompleteList>
</div>
)
}
const VirtualizedStatus = () => {
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
return (
<AutocompleteStatus className="border-b border-divider-subtle text-text-quaternary tabular-nums">
{filteredItems.length}
{' '}
matching suggestions. Selecting one only replaces the input text.
</AutocompleteStatus>
)
}
const FuzzyHighlight = ({
text,
query,
}: {
text: string
query: string
}) => {
const parts = useMemo(() => {
const trimmed = query.trim()
if (!trimmed)
return [text]
const escaped = trimmed.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return text.split(new RegExp(`(${escaped})`, 'i'))
}, [query, text])
return (
<>
{parts.map((part, index) => (
part.toLowerCase() === query.trim().toLowerCase()
? <mark key={`${part}-${index}`} className="bg-transparent text-text-accent">{part}</mark>
: part
))}
</>
)
}
const FuzzyMatchingDemo = () => {
const [value, setValue] = useState('retr')
const { contains } = useAutocompleteFilter({ sensitivity: 'base' })
return (
<div className={inputWidth}>
<Autocomplete
items={workflowSuggestions}
value={value}
onValueChange={setValue}
filter={contains}
itemToStringValue={getSuggestionLabel}
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-sparkling-2-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Fuzzy search workflow suggestions…" aria-label="Fuzzy search workflow suggestions" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<AutocompleteItem key={item.value} value={item} index={index}>
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
<div className="min-w-0 grow">
<AutocompleteItemText className="block px-0">
<FuzzyHighlight text={item.label} query={value} />
</AutocompleteItemText>
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
</div>
</AutocompleteItem>
)}
</AutocompleteList>
<AutocompleteEmpty>No workflow suggestion. Keep typing freely.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
)
}
const meta = {
title: 'Base/UI/Autocomplete',
component: Autocomplete,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound autocomplete built on Base UI Autocomplete. Use it for free-form inputs where suggestions can replace or complete the typed text, but selection is not persistent state.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Autocomplete>
export default meta
type Story = StoryObj<typeof meta>
export const SearchTags: Story = {
render: () => (
<div className={inputWidth}>
<BasicTagAutocomplete />
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<div key={size} className={inputWidth}>
<BasicTagAutocomplete size={size} />
</div>
))}
</div>
),
}
export const InlineAutocomplete: Story = {
render: () => (
<div className={inputWidth}>
<Autocomplete
items={promptCompletions}
itemToStringValue={getSuggestionLabel}
mode="both"
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-text-snippet ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Type a prompt starter…" aria-label="Type a prompt starter" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} dense />
)}
</AutocompleteList>
<AutocompleteEmpty>No inline completion. Continue typing freely.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
),
}
export const GroupedSuggestions: Story = {
render: () => (
<div className={inputWidth}>
<Autocomplete
items={groupedSuggestions}
itemToStringValue={getSuggestionLabel}
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-command-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Search tags, nodes, or prompt starters…" aria-label="Search tags, nodes, or prompt starters" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent popupClassName="w-[420px]">
<GroupedSuggestionList />
<AutocompleteEmpty>No suggestion. Use the text as entered.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
),
}
export const FuzzyMatching: Story = {
render: () => <FuzzyMatchingDemo />,
}
export const LimitResults: Story = {
render: () => (
<div className={inputWidth}>
<Autocomplete
items={workflowSuggestions}
itemToStringValue={getSuggestionLabel}
limit={5}
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-tools-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Search workflow suggestions…" aria-label="Search workflow suggestions" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent popupClassName="w-[420px]">
<AutocompleteStatus className="border-b border-divider-subtle">
<LimitedStatus total={workflowSuggestions.length} />
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
<AutocompleteEmpty>No suggestion. Submit the typed text instead.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
),
}
export const CommandPalette: Story = {
render: () => (
<div className="w-[440px] rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-2 shadow-xs">
<Autocomplete
open
inline
items={commandGroups}
itemToStringValue={getSuggestionLabel}
autoHighlight="always"
keepHighlight
>
<AutocompleteInputGroup className="mb-2">
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Run a command…" aria-label="Run a command" />
<AutocompleteClear />
</AutocompleteInputGroup>
<CommandPaletteList />
</Autocomplete>
</div>
),
}
const VirtualizedLongSuggestionsDemo = () => {
const virtualizerRef = useRef<StoryVirtualizer | null>(null)
return (
<div className={inputWidth}>
<Autocomplete
items={virtualizedSuggestions}
itemToStringValue={getSuggestionLabel}
virtualized
openOnInputClick
onItemHighlighted={(item, details) => {
scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
}}
>
<AutocompleteInputGroup>
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Search 1,000 workspace suggestions…" aria-label="Search 1,000 workspace suggestions" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent popupClassName="w-[440px] p-1">
<VirtualizedStatus />
<VirtualizedSuggestionList virtualizerRef={virtualizerRef} />
<AutocompleteEmpty>No suggestion. Free-form text is still valid.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
)
}
export const VirtualizedLongSuggestions: Story = {
render: () => <VirtualizedLongSuggestionsDemo />,
}
export const AsyncSearch: Story = {
render: () => <AsyncSearchDemo />,
}
export const Empty: Story = {
render: () => (
<div className={inputWidth}>
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
defaultValue="private-release-note"
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<AutocompleteInput placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. The custom text remains valid.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
),
}
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Disabled tag autocomplete" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
</div>
),
}

View File

@ -0,0 +1,381 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import type { HTMLAttributes, ReactNode } from 'react'
import type { Placement } from '../placement'
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import {
overlayIndicatorClassName,
overlayLabelClassName,
overlayPopupAnimationClassName,
overlaySeparatorClassName,
} from '../overlay-shared'
import { parsePlacement } from '../placement'
export type { Placement }
export const Autocomplete = BaseAutocomplete.Root
export const AutocompleteValue = BaseAutocomplete.Value
export const AutocompleteGroup = BaseAutocomplete.Group
export const AutocompleteCollection = BaseAutocomplete.Collection
export const AutocompleteRow = BaseAutocomplete.Row
export const useAutocompleteFilter = BaseAutocomplete.useFilter
export const useAutocompleteFilteredItems = BaseAutocomplete.useFilteredItems
export type AutocompleteRootProps<ItemValue> = BaseAutocomplete.Root.Props<ItemValue>
export type AutocompleteRootChangeEventDetails = BaseAutocomplete.Root.ChangeEventDetails
export type AutocompleteRootHighlightEventDetails = BaseAutocomplete.Root.HighlightEventDetails
const autocompletePopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const autocompleteListClassName = [
'max-h-[min(20rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden scroll-py-1',
'data-empty:max-h-none data-empty:p-0',
]
const autocompleteItemClassName = [
'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'hover:bg-state-base-hover-alt hover:text-text-primary',
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
'motion-reduce:transition-none',
]
const autocompleteInputGroupVariants = cva(
[
'group/autocomplete flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'h-6 rounded-md',
medium: 'h-8 rounded-lg',
large: 'h-9 rounded-[10px]',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type AutocompleteSize = NonNullable<VariantProps<typeof autocompleteInputGroupVariants>['size']>
export type AutocompleteInputGroupProps
= BaseAutocomplete.InputGroup.Props
& VariantProps<typeof autocompleteInputGroupVariants>
export function AutocompleteInputGroup({
className,
size = 'medium',
...props
}: AutocompleteInputGroupProps) {
return (
<BaseAutocomplete.InputGroup
className={cn(autocompleteInputGroupVariants({ size }), className)}
{...props}
/>
)
}
const autocompleteInputVariants = cva(
[
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-hidden',
'placeholder:text-components-input-text-placeholder',
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
'data-readonly:cursor-default',
],
{
variants: {
size: {
small: 'px-2 py-1 system-xs-regular',
medium: 'px-3 py-[7px] system-sm-regular',
large: 'px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type AutocompleteInputProps
= Omit<BaseAutocomplete.Input.Props, 'size'>
& VariantProps<typeof autocompleteInputVariants>
export function AutocompleteInput({
className,
size = 'medium',
type = 'text',
autoComplete = 'off',
...props
}: AutocompleteInputProps) {
return (
<BaseAutocomplete.Input
type={type}
autoComplete={autoComplete}
className={cn(autocompleteInputVariants({ size }), className)}
{...props}
/>
)
}
const autocompleteControlVariants = cva(
[
'flex shrink-0 touch-manipulation items-center justify-center rounded-md text-text-tertiary outline-hidden transition-colors',
'hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
'group-data-disabled/autocomplete:cursor-not-allowed group-data-disabled/autocomplete:hover:bg-transparent group-data-disabled/autocomplete:focus-visible:bg-transparent group-data-disabled/autocomplete:focus-visible:ring-0',
'group-data-readonly/autocomplete:hidden',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'mr-1 size-4',
medium: 'mr-1.5 size-5',
large: 'mr-2 size-5',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type AutocompleteControlProps
= Omit<BaseAutocomplete.Trigger.Props, 'className'>
& VariantProps<typeof autocompleteControlVariants>
& { className?: string }
export function AutocompleteTrigger({
className,
children,
size = 'medium',
type = 'button',
...props
}: AutocompleteControlProps) {
return (
<BaseAutocomplete.Trigger
type={type}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open autocomplete suggestions')}
className={cn(autocompleteControlVariants({ size }), className)}
{...props}
>
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
</BaseAutocomplete.Trigger>
)
}
export type AutocompleteClearProps
= Omit<BaseAutocomplete.Clear.Props, 'className'>
& VariantProps<typeof autocompleteControlVariants>
& { className?: string }
export function AutocompleteClear({
className,
children,
size = 'medium',
type = 'button',
...props
}: AutocompleteClearProps) {
return (
<BaseAutocomplete.Clear
type={type}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear autocomplete')}
className={cn(
autocompleteControlVariants({ size }),
'data-ending-style:opacity-0 data-starting-style:opacity-0',
className,
)}
{...props}
>
{children ?? <span className="i-ri-close-line size-4" aria-hidden="true" />}
</BaseAutocomplete.Clear>
)
}
export function AutocompleteIcon({
className,
children,
...props
}: BaseAutocomplete.Icon.Props) {
return (
<BaseAutocomplete.Icon
className={cn('flex shrink-0 items-center text-text-tertiary', className)}
{...props}
>
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
</BaseAutocomplete.Icon>
)
}
type AutocompleteContentProps = {
children: ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
portalProps?: Omit<BaseAutocomplete.Portal.Props, 'children'>
positionerProps?: Omit<
BaseAutocomplete.Positioner.Props,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
BaseAutocomplete.Popup.Props,
'children' | 'className'
>
}
export function AutocompleteContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
portalProps,
positionerProps,
popupProps,
}: AutocompleteContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseAutocomplete.Portal {...portalProps}>
<BaseAutocomplete.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
{...positionerProps}
>
<BaseAutocomplete.Popup
className={cn(
autocompletePopupClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
)
}
export function AutocompleteList({
className,
...props
}: BaseAutocomplete.List.Props) {
return (
<BaseAutocomplete.List
className={cn(autocompleteListClassName, className)}
{...props}
/>
)
}
export function AutocompleteItem({
className,
...props
}: BaseAutocomplete.Item.Props) {
return (
<BaseAutocomplete.Item
className={cn(autocompleteItemClassName, className)}
{...props}
/>
)
}
export type AutocompleteItemTextProps = HTMLAttributes<HTMLSpanElement>
export function AutocompleteItemText({
className,
...props
}: AutocompleteItemTextProps) {
return (
<span
className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)}
{...props}
/>
)
}
export function AutocompleteLabel({
className,
...props
}: BaseAutocomplete.GroupLabel.Props) {
return (
<BaseAutocomplete.GroupLabel
className={cn(overlayLabelClassName, className)}
{...props}
/>
)
}
export function AutocompleteSeparator({
className,
...props
}: BaseAutocomplete.Separator.Props) {
return (
<BaseAutocomplete.Separator
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)
}
export function AutocompleteEmpty({
className,
...props
}: BaseAutocomplete.Empty.Props) {
return (
<BaseAutocomplete.Empty
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
{...props}
/>
)
}
export function AutocompleteStatus({
className,
...props
}: BaseAutocomplete.Status.Props) {
return (
<BaseAutocomplete.Status
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
{...props}
/>
)
}
export function AutocompleteItemIndicator({
className,
children,
...props
}: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(overlayIndicatorClassName, className)}
{...props}
>
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
</span>
)
}

View File

@ -0,0 +1,363 @@
import type { ReactNode } from 'react'
import { render } from 'vitest-browser-react'
import {
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxInput,
ComboboxInputGroup,
ComboboxInputTrigger,
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemText,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
} from '../index'
const renderWithSafeViewport = (ui: ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const renderSelectLikeCombobox = ({
children,
open = false,
}: {
children?: ReactNode
open?: boolean
} = {}) => renderWithSafeViewport(
<Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}>
{children ?? (
<>
<ComboboxLabel data-testid="label">Resource type</ComboboxLabel>
<ComboboxTrigger aria-label="Resource type" data-testid="trigger">
<ComboboxValue placeholder="Select resource" />
</ComboboxTrigger>
<ComboboxContent
positionerProps={{
'role': 'group',
'aria-label': 'combobox positioner',
}}
popupProps={{
'role': 'dialog',
'aria-label': 'combobox popup',
}}
>
<ComboboxStatus data-testid="status">2 options</ComboboxStatus>
<ComboboxList role="listbox" aria-label="combobox list" data-testid="list">
<ComboboxItem value="workflow">
<ComboboxItemText>Workflow</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
<ComboboxItem value="dataset">
<ComboboxItemText>Dataset</ComboboxItemText>
</ComboboxItem>
</ComboboxList>
<ComboboxEmpty data-testid="empty">No options</ComboboxEmpty>
</ComboboxContent>
</>
)}
</Combobox>,
)
const renderInputCombobox = ({
children,
open = false,
}: {
children?: ReactNode
open?: boolean
} = {}) => renderWithSafeViewport(
<Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}>
{children ?? (
<>
<ComboboxInputGroup data-testid="input-group">
<ComboboxInput aria-label="Search resources" data-testid="input" />
<ComboboxClear data-testid="clear" />
<ComboboxInputTrigger data-testid="input-trigger" />
</ComboboxInputGroup>
<ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}>
<ComboboxList role="listbox" aria-label="combobox list">
<ComboboxItem value="workflow">
<ComboboxItemText>Workflow</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</>
)}
</Combobox>,
)
describe('Combobox wrappers', () => {
describe('Select-like trigger', () => {
it('should render label and apply medium trigger classes by default', async () => {
const screen = await renderSelectLikeCombobox()
await expect.element(screen.getByText('Resource type')).toHaveClass('system-sm-medium')
await expect.element(screen.getByRole('combobox', { name: 'Resource type' })).toHaveClass('rounded-lg')
await expect.element(screen.getByRole('combobox', { name: 'Resource type' })).toHaveClass('system-sm-regular')
})
it('should apply small and large trigger size variants', async () => {
const smallScreen = await renderSelectLikeCombobox({
children: (
<ComboboxTrigger aria-label="Small resource type" size="small">
<ComboboxValue placeholder="Select resource" />
</ComboboxTrigger>
),
})
await expect.element(smallScreen.getByRole('combobox', { name: 'Small resource type' })).toHaveClass('rounded-md')
await expect.element(smallScreen.getByRole('combobox', { name: 'Small resource type' })).toHaveClass('system-xs-regular')
const largeScreen = await renderSelectLikeCombobox({
children: (
<ComboboxTrigger aria-label="Large resource type" size="large">
<ComboboxValue placeholder="Select resource" />
</ComboboxTrigger>
),
})
await expect.element(largeScreen.getByRole('combobox', { name: 'Large resource type' })).toHaveClass('rounded-[10px]')
await expect.element(largeScreen.getByRole('combobox', { name: 'Large resource type' })).toHaveClass('system-md-regular')
})
it('should render default trigger icon and support hiding it', async () => {
const withIcon = await renderSelectLikeCombobox()
expect(withIcon.getByTestId('trigger').element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
const withoutIcon = await renderSelectLikeCombobox({
children: (
<ComboboxTrigger aria-label="Resource type without icon" icon={false}>
<ComboboxValue placeholder="Select resource" />
</ComboboxTrigger>
),
})
expect(withoutIcon.getByRole('combobox', { name: 'Resource type without icon' }).element().querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
})
describe('Input group and controls', () => {
it('should apply medium input group and input classes by default', async () => {
const screen = await renderInputCombobox()
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-lg')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('px-3')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('system-sm-regular')
})
it('should apply large input group and input classes when large size is provided', async () => {
const screen = await renderInputCombobox({
children: (
<ComboboxInputGroup size="large" data-testid="input-group">
<ComboboxInput size="large" aria-label="Search resources" />
</ComboboxInputGroup>
),
})
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-[10px]')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('px-4')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('system-md-regular')
})
it('should set input defaults and forward passthrough props', async () => {
const screen = await renderInputCombobox({
children: (
<ComboboxInputGroup>
<ComboboxInput
aria-label="Search resources"
className="custom-input"
placeholder="Find a resource"
required
/>
</ComboboxInputGroup>
),
})
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('autocomplete', 'off')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('type', 'text')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('placeholder', 'Find a resource')
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toBeRequired()
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('custom-input')
})
it('should provide fallback aria labels and decorative icons for input controls', async () => {
const screen = await renderInputCombobox()
await expect.element(screen.getByRole('button', { name: 'Clear combobox' })).toHaveAttribute('type', 'button')
await expect.element(screen.getByRole('button', { name: 'Open combobox options' })).toHaveAttribute('type', 'button')
expect(screen.getByRole('button', { name: 'Clear combobox' }).element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('button', { name: 'Open combobox options' }).element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => {
const screen = await renderInputCombobox({
children: (
<>
<span id="clear-label">Clear from label</span>
<span id="trigger-label">Trigger from label</span>
<ComboboxInputGroup>
<ComboboxInput aria-label="Search resources" />
<ComboboxClear aria-labelledby="clear-label" />
<ComboboxInputTrigger aria-labelledby="trigger-label" />
</ComboboxInputGroup>
</>
),
})
await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label')
await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label')
})
})
describe('Content and options', () => {
it('should use default overlay placement and Dify popup classes', async () => {
const screen = await renderSelectLikeCombobox({ open: true })
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-1002')
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('rounded-xl')
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('w-(--anchor-width)')
await expect.element(screen.getByRole('listbox', { name: 'combobox list' })).toHaveClass('scroll-py-1')
})
it('should apply custom placement side and passthrough popup props', async () => {
const onPopupClick = vi.fn()
const screen = await renderWithSafeViewport(
<Combobox open defaultValue="workflow" items={['workflow']}>
<ComboboxTrigger aria-label="Resource type">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent
placement="top-end"
sideOffset={12}
alignOffset={6}
positionerProps={{ 'role': 'group', 'aria-label': 'combobox positioner' }}
popupProps={{
'role': 'dialog',
'aria-label': 'combobox popup',
'onClick': onPopupClick,
}}
>
<ComboboxList role="listbox" aria-label="combobox list">
<ComboboxItem value="workflow">
<ComboboxItemText>Workflow</ComboboxItemText>
</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</Combobox>,
)
asHTMLElement(screen.getByRole('dialog', { name: 'combobox popup' }).element()).click()
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'top')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
it('should render item text indicator status and empty wrappers with design classes', async () => {
const screen = await renderSelectLikeCombobox({ open: true })
await expect.element(screen.getByTestId('list').getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
expect(screen.getByTestId('list').getByText('Workflow').element().parentElement?.querySelector('.i-ri-check-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should forward custom classes to group label separator item text and indicator', async () => {
const screen = await renderWithSafeViewport(
<Combobox open defaultValue="workflow" items={['workflow']}>
<ComboboxTrigger aria-label="Resource type">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}>
<ComboboxList role="listbox" aria-label="combobox list" data-testid="custom-list">
<ComboboxGroup items={['workflow']}>
<ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel>
<ComboboxSeparator className="custom-separator" data-testid="separator" />
<ComboboxItem value="workflow" className="custom-item">
<ComboboxItemText className="custom-text">Workflow</ComboboxItemText>
<ComboboxItemIndicator className="custom-indicator" data-testid="indicator" />
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</Combobox>,
)
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item')
await expect.element(screen.getByTestId('custom-list').getByText('Workflow')).toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
})
describe('Multiple selection chips', () => {
it('should render chip wrappers and default remove button label', async () => {
const screen = await renderWithSafeViewport(
<Combobox multiple defaultValue={['maya']} items={['maya', 'nora']}>
<ComboboxInputGroup>
<ComboboxValue>
{(selectedValue: string[]) => (
<ComboboxChips className="custom-chips" data-testid="chips">
{selectedValue.map(item => (
<ComboboxChip key={item} className="custom-chip">
<span>{item}</span>
<ComboboxChipRemove data-testid="remove-chip" />
</ComboboxChip>
))}
</ComboboxChips>
)}
</ComboboxValue>
<ComboboxInput aria-label="Reviewers" />
</ComboboxInputGroup>
</Combobox>,
)
await expect.element(screen.getByTestId('chips')).toHaveClass('custom-chips')
await expect.element(screen.getByText('maya').element().parentElement!).toHaveClass('custom-chip')
await expect.element(screen.getByRole('button', { name: 'Remove selected item' })).toHaveAttribute('type', 'button')
expect(screen.getByTestId('remove-chip').element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
})
it('should preserve chip remove aria-labelledby over fallback label', async () => {
const screen = await renderWithSafeViewport(
<Combobox multiple defaultValue={['maya']} items={['maya']}>
<ComboboxInputGroup>
<ComboboxValue>
{(selectedValue: string[]) => (
<ComboboxChips>
{selectedValue.map(item => (
<ComboboxChip key={item}>
<span id="remove-maya">Remove Maya</span>
<ComboboxChipRemove aria-labelledby="remove-maya" />
</ComboboxChip>
))}
</ComboboxChips>
)}
</ComboboxValue>
<ComboboxInput aria-label="Reviewers" />
</ComboboxInputGroup>
</Combobox>,
)
await expect.element(screen.getByRole('button', { name: 'Remove Maya' })).not.toHaveAttribute('aria-label')
})
})
})

View File

@ -0,0 +1,618 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef, useState } from 'react'
import {
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxClear,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxInput,
ComboboxInputGroup,
ComboboxInputTrigger,
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemText,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
useComboboxFilteredItems,
} from '.'
import { cn } from '../cn'
type Option = {
value: string
label: string
meta?: string
icon?: string
disabled?: boolean
}
type OptionGroup = {
label: string
items: Option[]
}
const fieldWidth = 'w-80'
const wideFieldWidth = 'w-[520px]'
const nativeFieldLabelClassName = 'mb-1 block text-text-secondary system-sm-medium'
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
const scrollHighlightedVirtualItem = (
item: unknown,
{
reason,
index,
}: {
reason: 'keyboard' | 'pointer' | 'none'
index: number
},
virtualizer: StoryVirtualizer | null,
) => {
if (!item || !virtualizer)
return
const isStart = index === 0
const isEnd = index === virtualizer.options.count - 1
const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd))
if (shouldScroll) {
queueMicrotask(() => {
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' })
})
}
}
const providerOptions: Option[] = [
{ value: 'openai', label: 'OpenAI', meta: 'GPT-5, GPT-4.1', icon: 'i-ri-openai-fill' },
{ value: 'anthropic', label: 'Anthropic', meta: 'Claude Opus, Sonnet', icon: 'i-ri-sparkling-2-line' },
{ value: 'google', label: 'Google', meta: 'Gemini 2.5', icon: 'i-ri-google-fill' },
{ value: 'azure-openai', label: 'Azure OpenAI', meta: 'Enterprise workspace', icon: 'i-ri-microsoft-fill' },
{ value: 'localai', label: 'LocalAI', meta: 'Self-hosted endpoint', icon: 'i-ri-server-line', disabled: true },
]
const dataSourceOptions: Option[] = [
{ value: 'knowledge-base', label: 'Knowledge Base', meta: 'Vector index', icon: 'i-ri-database-2-line' },
{ value: 'notion', label: 'Notion', meta: 'Synced pages', icon: 'i-ri-notion-fill' },
{ value: 'website', label: 'Website crawler', meta: 'Public URLs', icon: 'i-ri-global-line' },
{ value: 's3', label: 'S3 bucket', meta: 'Private files', icon: 'i-ri-cloud-line' },
{ value: 'slack', label: 'Slack', meta: 'Channel history', icon: 'i-ri-slack-fill' },
]
const reviewerOptions: Option[] = [
{ value: 'maya', label: 'Maya Chen', meta: 'Product owner' },
{ value: 'liam', label: 'Liam Brooks', meta: 'Prompt engineer' },
{ value: 'nora', label: 'Nora Park', meta: 'Data steward' },
{ value: 'owen', label: 'Owen Reed', meta: 'Security reviewer' },
{ value: 'yuki', label: 'Yuki Tanaka', meta: 'ML engineer' },
]
const toolGroups: OptionGroup[] = [
{
label: 'Retrieval',
items: [
{ value: 'dataset-search', label: 'Dataset search', meta: 'Search workspace knowledge', icon: 'i-ri-search-eye-line' },
{ value: 'web-scraper', label: 'Web scraper', meta: 'Fetch public pages', icon: 'i-ri-global-line' },
],
},
{
label: 'Actions',
items: [
{ value: 'http-request', label: 'HTTP request', meta: 'Call external APIs', icon: 'i-ri-terminal-box-line' },
{ value: 'code-runner', label: 'Code runner', meta: 'Execute sandboxed scripts', icon: 'i-ri-code-s-slash-line' },
],
},
{
label: 'Operations',
items: [
{ value: 'human-review', label: 'Human review', meta: 'Assign approval task', icon: 'i-ri-user-voice-line' },
{ value: 'audit-log', label: 'Audit log', meta: 'Record workflow events', icon: 'i-ri-file-list-3-line' },
],
},
]
const tagOptions: Option[] = [
{ value: 'rag', label: 'RAG' },
{ value: 'agent', label: 'Agent' },
{ value: 'production', label: 'Production' },
{ value: 'evaluation', label: 'Evaluation' },
{ value: 'finance', label: 'Finance' },
{ value: 'support', label: 'Support' },
]
const directoryOptions: Option[] = [
{ value: 'maya-chen', label: 'Maya Chen', meta: 'Product owner · maya@example.com', icon: 'i-ri-user-3-line' },
{ value: 'liam-brooks', label: 'Liam Brooks', meta: 'Prompt engineer · liam@example.com', icon: 'i-ri-user-3-line' },
{ value: 'nora-park', label: 'Nora Park', meta: 'Data steward · nora@example.com', icon: 'i-ri-user-3-line' },
{ value: 'owen-reed', label: 'Owen Reed', meta: 'Security reviewer · owen@example.com', icon: 'i-ri-shield-user-line' },
{ value: 'yuki-tanaka', label: 'Yuki Tanaka', meta: 'ML engineer · yuki@example.com', icon: 'i-ri-user-3-line' },
{ value: 'ava-martin', label: 'Ava Martin', meta: 'Support lead · ava@example.com', icon: 'i-ri-customer-service-2-line' },
]
const emptyOptions: Option[] = [
{ value: 'billing', label: 'Billing connector' },
{ value: 'zendesk', label: 'Zendesk' },
{ value: 'github', label: 'GitHub issues' },
]
const modelCatalogOptions: Option[] = Array.from({ length: 1000 }, (_, index) => {
const provider = ['OpenAI', 'Anthropic', 'Google', 'Mistral', 'DeepSeek'][index % 5]!
const family = ['chat', 'reasoning', 'vision', 'embedding'][index % 4]!
const number = new Intl.NumberFormat('en-US', {
minimumIntegerDigits: 4,
}).format(index + 1)
return {
value: `model-${index + 1}`,
label: `${provider} ${family} ${number}`,
meta: `${provider} provider · ${family}`,
icon: family === 'embedding'
? 'i-ri-vector-triangle'
: family === 'vision'
? 'i-ri-image-circle-line'
: family === 'reasoning'
? 'i-ri-brain-line'
: 'i-ri-chat-1-line',
}
})
const sizeOptions: Option[] = providerOptions.slice(0, 3)
const defaultProvider = providerOptions[0]!
const disabledProvider = providerOptions[1]!
const defaultDataSource = dataSourceOptions[0]!
const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[2]!]
const defaultTag = tagOptions[2]!
const renderOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled}>
<ComboboxItemText className="flex items-center gap-2 px-0">
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
<span className="min-w-0 flex-1">
<span className="block truncate text-text-secondary system-sm-medium">{option.label}</span>
{option.meta && <span className="block truncate text-text-tertiary system-xs-regular">{option.meta}</span>}
</span>
</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)
const renderSimpleOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index}>
<ComboboxItemText>{option.label}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)
const PopupSearchInput = ({
label,
placeholder,
}: {
label: string
placeholder: string
}) => (
<ComboboxInputGroup className="mb-1 border-divider-subtle bg-components-input-bg-normal">
<span aria-hidden className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput aria-label={label} placeholder={`${placeholder}`} className="pl-2" />
<ComboboxClear />
</ComboboxInputGroup>
)
const GroupedToolList = () => {
const groups = useComboboxFilteredItems<OptionGroup>()
return (
<ComboboxList className="p-0">
{groups.map((group, groupIndex) => (
<ComboboxGroup key={group.label} items={group.items}>
{groupIndex > 0 && <ComboboxSeparator />}
<ComboboxGroupLabel>{group.label}</ComboboxGroupLabel>
<ComboboxCollection>
{(option: Option) => renderOptionItem(option)}
</ComboboxCollection>
</ComboboxGroup>
))}
</ComboboxList>
)
}
const VirtualizedModelList = ({
virtualizerRef,
}: {
virtualizerRef: RefObject<StoryVirtualizer | null>
}) => {
const scrollRef = useRef<HTMLDivElement | null>(null)
const filteredItems = useComboboxFilteredItems<Option>()
const virtualizer = useVirtualizer({
count: filteredItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 42,
overscan: 6,
})
useEffect(() => {
virtualizerRef.current = virtualizer
return () => {
virtualizerRef.current = null
}
}, [virtualizer, virtualizerRef])
return (
<div
ref={scrollRef}
className="max-h-[min(22rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain outline-hidden"
>
<ComboboxList
className="relative max-h-none overflow-visible p-0"
style={{
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const option = filteredItems[virtualItem.index]
if (!option)
return null
return (
<div
key={virtualItem.key}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderOptionItem(option, virtualItem.index)}
</div>
)
})}
</ComboboxList>
</div>
)
}
const FilteredModelStatus = () => {
const filteredItems = useComboboxFilteredItems<Option>()
return (
<ComboboxStatus className="border-y border-divider-subtle px-2 py-1 text-text-quaternary tabular-nums">
{filteredItems.length}
{' '}
matching models
</ComboboxStatus>
)
}
const VirtualizedLongListDemo = () => {
const [value, setValue] = useState<Option | null>(modelCatalogOptions[137]!)
const virtualizerRef = useRef<StoryVirtualizer | null>(null)
return (
<div className={fieldWidth}>
<Combobox
items={modelCatalogOptions}
value={value}
onValueChange={setValue}
virtualized
autoHighlight
onItemHighlighted={(item, details) => {
scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
}}
>
<ComboboxLabel>Model catalog</ComboboxLabel>
<ComboboxTrigger aria-label="Model catalog">
<ComboboxValue placeholder="Select model" />
</ComboboxTrigger>
<ComboboxContent popupClassName="w-[440px] p-1">
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
<FilteredModelStatus />
<VirtualizedModelList virtualizerRef={virtualizerRef} />
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
)
}
const AsyncDirectoryDemo = () => {
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option | null>(null)
const [items, setItems] = useState(directoryOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
setItems(
query
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: directoryOptions.slice(0, 5),
)
setLoading(false)
}, 450)
return () => window.clearTimeout(timeout)
}, [inputValue])
return (
<div className={fieldWidth}>
<Combobox
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
autoHighlight
>
<label className={nativeFieldLabelClassName}>
Owner
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search owners…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
)
}
const meta = {
title: 'Base/UI/Combobox',
component: Combobox,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound combobox built on Base UI Combobox for searchable predefined selections. Compose triggers, inputs, lists, groups, status, empty states, and chips without importing Base UI primitives directly.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Combobox>
export default meta
type Story = StoryObj<typeof meta>
export const SelectLikeDefault: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={providerOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxLabel>Model provider</ComboboxLabel>
<ComboboxTrigger aria-label="Model provider">
<ComboboxValue placeholder="Select provider" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search model providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const PopupInputSearchableSelect: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource} autoHighlight>
<ComboboxLabel>Data source</ComboboxLabel>
<ComboboxTrigger aria-label="Data source">
<ComboboxValue placeholder="Choose source" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const AsyncSearchSingle: Story = {
render: () => <AsyncDirectoryDemo />,
}
export const InputGroupSearchable: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<label className={nativeFieldLabelClassName}>
Connect source
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search data sources…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<Combobox key={size} items={sizeOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxTrigger aria-label={`${size} model provider`} size={size}>
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label={`Search ${size} model providers`} placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
))}
</div>
),
}
export const Grouped: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={toolGroups} defaultValue={defaultTool} autoHighlight>
<ComboboxLabel>Workflow tool</ComboboxLabel>
<ComboboxTrigger aria-label="Workflow tool">
<ComboboxValue placeholder="Select tool" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
<GroupedToolList />
</ComboboxContent>
</Combobox>
</div>
),
}
const MultipleChipsDemo = () => {
const [value, setValue] = useState<Option[]>(defaultReviewers)
return (
<div className={wideFieldWidth}>
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue} autoHighlight>
<label className={nativeFieldLabelClassName}>
Reviewers
<ComboboxInputGroup className="mt-1 h-auto min-h-8 flex-nowrap py-1">
<ComboboxValue>
{(selectedValue: Option[]) => (
<>
<ComboboxChips className="flex-nowrap">
{selectedValue.map(item => (
<ComboboxChip key={item.value}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
</ComboboxChips>
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-16 px-2" />
</>
)}
</ComboboxValue>
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
export const MultipleChips: Story = {
render: () => <MultipleChipsDemo />,
}
export const VirtualizedLongList: Story = {
render: () => <VirtualizedLongListDemo />,
}
export const EmptyAndStatus: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={emptyOptions} defaultInputValue="salesforce" autoHighlight>
<label className={nativeFieldLabelClassName}>
Connector
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search connectors…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
<ComboboxEmpty>No connectors found</ComboboxEmpty>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<label className={nativeFieldLabelClassName}>
Read-only source
<ComboboxInputGroup className="mt-1">
<ComboboxInput placeholder="Read-only data source…" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
const ControlledDemo = () => {
const [value, setValue] = useState<Option | null>(defaultTag)
return (
<div className="flex w-80 flex-col items-start gap-3">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList className="p-0">{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 text-text-tertiary system-xs-regular">
Selected:
{' '}
{value?.label ?? 'None'}
</span>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
}

View File

@ -0,0 +1,497 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import type { HTMLAttributes, ReactNode } from 'react'
import type { Placement } from '../placement'
import { Combobox as BaseCombobox } from '@base-ui/react/combobox'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import {
overlayIndicatorClassName,
overlayLabelClassName,
overlayPopupAnimationClassName,
overlaySeparatorClassName,
} from '../overlay-shared'
import { parsePlacement } from '../placement'
export type { Placement }
export const Combobox = BaseCombobox.Root
export const ComboboxValue = BaseCombobox.Value
export const ComboboxGroup = BaseCombobox.Group
export const ComboboxCollection = BaseCombobox.Collection
export const ComboboxRow = BaseCombobox.Row
export const useComboboxFilter = BaseCombobox.useFilter
export const useComboboxFilteredItems = BaseCombobox.useFilteredItems
export type ComboboxRootProps<Value, Multiple extends boolean | undefined = false>
= BaseCombobox.Root.Props<Value, Multiple>
export type ComboboxRootChangeEventDetails = BaseCombobox.Root.ChangeEventDetails
export type ComboboxRootHighlightEventDetails = BaseCombobox.Root.HighlightEventDetails
const comboboxPopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const comboboxListClassName = [
'max-h-[min(20rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden scroll-py-1',
'data-empty:max-h-none data-empty:p-0',
]
const comboboxItemClassName = [
'mx-1 grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'hover:bg-state-base-hover-alt hover:text-text-primary',
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
'data-selected:text-text-primary',
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
'motion-reduce:transition-none',
]
const comboboxTriggerVariants = cva(
[
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden transition-colors',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
'data-disabled:data-placeholder:text-components-input-text-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'h-6 gap-px rounded-md px-2 py-1 system-xs-regular',
medium: 'h-8 gap-0.5 rounded-lg px-3 py-2 system-sm-regular',
large: 'h-9 gap-0.5 rounded-[10px] px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type ComboboxSize = NonNullable<VariantProps<typeof comboboxTriggerVariants>['size']>
type ComboboxTriggerProps
= Omit<BaseCombobox.Trigger.Props, 'className'>
& VariantProps<typeof comboboxTriggerVariants>
& {
className?: string
icon?: ReactNode | false
}
export function ComboboxTrigger({
className,
children,
icon,
size,
type = 'button',
...props
}: ComboboxTriggerProps) {
return (
<BaseCombobox.Trigger
type={type}
className={cn(comboboxTriggerVariants({ size, className }))}
{...props}
>
<span className="min-w-0 grow truncate">
{children}
</span>
{icon !== false && (
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
{icon ?? <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />}
</BaseCombobox.Icon>
)}
</BaseCombobox.Trigger>
)
}
const comboboxInputGroupVariants = cva(
[
'group/combobox flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
'data-open:border-components-input-border-active data-open:bg-components-input-bg-active',
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'min-h-6 rounded-md',
medium: 'min-h-8 rounded-lg',
large: 'min-h-9 rounded-[10px]',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type ComboboxInputGroupProps
= BaseCombobox.InputGroup.Props
& VariantProps<typeof comboboxInputGroupVariants>
export function ComboboxInputGroup({
className,
size = 'medium',
...props
}: ComboboxInputGroupProps) {
return (
<BaseCombobox.InputGroup
className={cn(comboboxInputGroupVariants({ size }), className)}
{...props}
/>
)
}
const comboboxInputVariants = cva(
[
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-hidden',
'placeholder:text-components-input-text-placeholder',
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
'data-readonly:cursor-default',
],
{
variants: {
size: {
small: 'px-2 py-1 system-xs-regular',
medium: 'px-3 py-[7px] system-sm-regular',
large: 'px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type ComboboxInputProps
= Omit<BaseCombobox.Input.Props, 'size'>
& VariantProps<typeof comboboxInputVariants>
export function ComboboxInput({
className,
size = 'medium',
type = 'text',
autoComplete = 'off',
...props
}: ComboboxInputProps) {
return (
<BaseCombobox.Input
type={type}
autoComplete={autoComplete}
className={cn(comboboxInputVariants({ size }), className)}
{...props}
/>
)
}
const comboboxControlVariants = cva(
[
'flex shrink-0 touch-manipulation items-center justify-center rounded-md text-text-tertiary outline-hidden transition-colors',
'hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
'group-data-disabled/combobox:cursor-not-allowed group-data-disabled/combobox:hover:bg-transparent group-data-disabled/combobox:focus-visible:bg-transparent group-data-disabled/combobox:focus-visible:ring-0',
'group-data-readonly/combobox:hidden',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'mr-1 size-4',
medium: 'mr-1.5 size-5',
large: 'mr-2 size-5',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type ComboboxClearProps
= Omit<BaseCombobox.Clear.Props, 'className'>
& VariantProps<typeof comboboxControlVariants>
& { className?: string }
export function ComboboxClear({
className,
children,
size = 'medium',
type = 'button',
...props
}: ComboboxClearProps) {
return (
<BaseCombobox.Clear
type={type}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear combobox')}
className={cn(
comboboxControlVariants({ size }),
'data-ending-style:opacity-0 data-starting-style:opacity-0',
className,
)}
{...props}
>
{children ?? <span className="i-ri-close-line size-4" aria-hidden="true" />}
</BaseCombobox.Clear>
)
}
export type ComboboxInputTriggerProps
= Omit<BaseCombobox.Trigger.Props, 'className'>
& VariantProps<typeof comboboxControlVariants>
& { className?: string }
export function ComboboxInputTrigger({
className,
children,
size = 'medium',
type = 'button',
...props
}: ComboboxInputTriggerProps) {
return (
<BaseCombobox.Trigger
type={type}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open combobox options')}
className={cn(comboboxControlVariants({ size }), className)}
{...props}
>
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
</BaseCombobox.Trigger>
)
}
export function ComboboxIcon({
className,
children,
...props
}: BaseCombobox.Icon.Props) {
return (
<BaseCombobox.Icon
className={cn('flex shrink-0 items-center text-text-tertiary', className)}
{...props}
>
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
</BaseCombobox.Icon>
)
}
type ComboboxContentProps = {
children: ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
portalProps?: Omit<BaseCombobox.Portal.Props, 'children'>
positionerProps?: Omit<
BaseCombobox.Positioner.Props,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
BaseCombobox.Popup.Props,
'children' | 'className'
>
}
export function ComboboxContent({
children,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
portalProps,
positionerProps,
popupProps,
}: ComboboxContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseCombobox.Portal {...portalProps}>
<BaseCombobox.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
{...positionerProps}
>
<BaseCombobox.Popup
className={cn(
comboboxPopupClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseCombobox.Popup>
</BaseCombobox.Positioner>
</BaseCombobox.Portal>
)
}
export function ComboboxList({
className,
...props
}: BaseCombobox.List.Props) {
return (
<BaseCombobox.List
className={cn(comboboxListClassName, className)}
{...props}
/>
)
}
export function ComboboxItem({
className,
...props
}: BaseCombobox.Item.Props) {
return (
<BaseCombobox.Item
className={cn(comboboxItemClassName, className)}
{...props}
/>
)
}
export type ComboboxItemTextProps = HTMLAttributes<HTMLSpanElement>
export function ComboboxItemText({
className,
...props
}: ComboboxItemTextProps) {
return (
<span
className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)}
{...props}
/>
)
}
export function ComboboxItemIndicator({
className,
children,
...props
}: Omit<BaseCombobox.ItemIndicator.Props, 'children'> & { children?: ReactNode }) {
return (
<BaseCombobox.ItemIndicator
className={cn(overlayIndicatorClassName, className)}
{...props}
>
{children ?? <span className="i-ri-check-line h-4 w-4" aria-hidden="true" />}
</BaseCombobox.ItemIndicator>
)
}
export function ComboboxLabel({
className,
...props
}: BaseCombobox.Label.Props) {
return (
<BaseCombobox.Label
className={cn('mb-1 block text-text-secondary system-sm-medium', className)}
{...props}
/>
)
}
export function ComboboxGroupLabel({
className,
...props
}: BaseCombobox.GroupLabel.Props) {
return (
<BaseCombobox.GroupLabel
className={cn(overlayLabelClassName, className)}
{...props}
/>
)
}
export function ComboboxSeparator({
className,
...props
}: BaseCombobox.Separator.Props) {
return (
<BaseCombobox.Separator
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)
}
export function ComboboxEmpty({
className,
...props
}: BaseCombobox.Empty.Props) {
return (
<BaseCombobox.Empty
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
{...props}
/>
)
}
export function ComboboxStatus({
className,
...props
}: BaseCombobox.Status.Props) {
return (
<BaseCombobox.Status
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
{...props}
/>
)
}
export function ComboboxChips({
className,
...props
}: BaseCombobox.Chips.Props) {
return (
<BaseCombobox.Chips
className={cn('flex w-full min-w-0 flex-wrap items-center gap-1 px-1', className)}
{...props}
/>
)
}
export function ComboboxChip({
className,
...props
}: BaseCombobox.Chip.Props) {
return (
<BaseCombobox.Chip
className={cn('inline-flex max-w-full min-w-0 items-center gap-1 rounded-md bg-state-base-hover px-1.5 py-0.5 text-text-secondary system-xs-medium', className)}
{...props}
/>
)
}
export function ComboboxChipRemove({
className,
children,
type = 'button',
...props
}: BaseCombobox.ChipRemove.Props) {
return (
<BaseCombobox.ChipRemove
type={type}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Remove selected item')}
className={cn('flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active', className)}
{...props}
>
{children ?? <span className="i-ri-close-line size-3" aria-hidden="true" />}
</BaseCombobox.ChipRemove>
)
}

3
pnpm-lock.yaml generated
View File

@ -757,6 +757,9 @@ importers:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.4(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))
'@tanstack/react-virtual':
specifier: 'catalog:'
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/react':
specifier: 'catalog:'
version: 19.2.14

View File

@ -18,6 +18,8 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
- `@langgenius/dify-ui/popover`
- `@langgenius/dify-ui/dialog`
- `@langgenius/dify-ui/alert-dialog`
- `@langgenius/dify-ui/autocomplete`
- `@langgenius/dify-ui/combobox`
- `@langgenius/dify-ui/select`
- `@langgenius/dify-ui/toast`
- Tracking issue: <https://github.com/langgenius/dify/issues/32767>
@ -56,13 +58,13 @@ All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index val
During the migration period, legacy and new overlays coexist. Legacy overlays
portal to `document.body` with explicit z-index values:
| Layer | z-index | Components |
| --------------------------------- | -------------- | -------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
| Layer | z-index | Components |
| --------------------------------- | -------------- | -------------------------------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
`z-1002` sits above all common legacy overlays, so new primitives always
render on top without needing per-call-site z-index hacks. Among themselves,