diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md
index d8a59b7a0b..bdc2160702 100644
--- a/packages/dify-ui/AGENTS.md
+++ b/packages/dify-ui/AGENTS.md
@@ -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
diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md
index cd24a0c078..41e99d0952 100644
--- a/packages/dify-ui/README.md
+++ b/packages/dify-ui/README.md
@@ -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.
diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json
index 73c6c0bd22..20e94c7dee 100644
--- a/packages/dify-ui/package.json
+++ b/packages/dify-ui/package.json
@@ -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:",
diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..a7031c5b12
--- /dev/null
+++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx
@@ -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(
+
+ {ui}
+
,
+)
+
+const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
+
+const renderAutocomplete = ({
+ children,
+ open = false,
+ defaultValue = 'workflow',
+}: {
+ children?: ReactNode
+ open?: boolean
+ defaultValue?: string
+} = {}) => renderWithSafeViewport(
+
+ {children ?? (
+ <>
+
+
+
+
+
+
+ 2 suggestions
+
+
+ Workflow
+
+
+
+ Dataset
+
+
+ No suggestions
+
+ >
+ )}
+ ,
+)
+
+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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ reset
+
+
+ open
+
+
+ ),
+ })
+
+ 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: (
+ <>
+ Clear from label
+ Trigger from label
+
+
+
+
+
+ >
+ ),
+ })
+
+ 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(
+
+
+
+
+
+
+
+ Workflow
+
+
+
+ ,
+ )
+
+ 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(
+
+
+
+
+
+
+
+ Resources
+
+
+ Workflow
+
+
+
+
+
+ ,
+ )
+
+ 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')
+ })
+ })
+})
diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx
new file mode 100644
index 0000000000..71c7c6607d
--- /dev/null
+++ b/packages/dify-ui/src/autocomplete/index.stories.tsx
@@ -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
+
+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
+}) => (
+
+ {item.icon && }
+
+
{item.label}
+ {!dense && item.description && (
+
{item.description}
+ )}
+
+ {item.meta && (
+
+ {item.meta}
+
+ )}
+
+)
+
+const TagSuggestionItem = ({
+ item,
+ index,
+}: {
+ item: Suggestion
+ index?: number
+}) => (
+
+ {item.label}
+ {item.description && {item.description} }
+
+)
+
+const BasicTagAutocomplete = ({
+ size = 'medium',
+}: {
+ size?: 'small' | 'medium' | 'large'
+}) => (
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+ No tag suggestion. Keep the typed value.
+
+
+)
+
+const GroupedSuggestionList = () => {
+ const groups = useAutocompleteFilteredItems()
+
+ return (
+
+ {groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {group.label}
+
+ {(item: Suggestion) => (
+
+ )}
+
+
+ ))}
+
+ )
+}
+
+const CommandPaletteList = () => {
+ const groups = useAutocompleteFilteredItems()
+
+ return (
+
+ {groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {group.label}
+
+ {(item: Suggestion) => (
+
+
+ {item.icon && }
+
+ {item.label}
+ {item.description}
+
+
+
+ Enter
+
+
+ )}
+
+
+ ))}
+
+ )
+}
+
+const LimitedStatus = ({
+ total,
+}: {
+ total: number
+}) => {
+ const items = useAutocompleteFilteredItems()
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ {loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+ No remote suggestion. Keep the typed query.
+
+
+
+ )
+}
+
+const VirtualizedSuggestionList = ({
+ virtualizerRef,
+}: {
+ virtualizerRef: RefObject
+}) => {
+ const scrollRef = useRef(null)
+ const filteredItems = useAutocompleteFilteredItems()
+ const virtualizer = useVirtualizer({
+ count: filteredItems.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 44,
+ overscan: 6,
+ })
+
+ useEffect(() => {
+ virtualizerRef.current = virtualizer
+
+ return () => {
+ virtualizerRef.current = null
+ }
+ }, [virtualizer, virtualizerRef])
+
+ return (
+
+
+ {virtualizer.getVirtualItems().map((virtualItem) => {
+ const item = filteredItems[virtualItem.index]
+
+ if (!item)
+ return null
+
+ return (
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+const VirtualizedStatus = () => {
+ const filteredItems = useAutocompleteFilteredItems()
+
+ return (
+
+ {filteredItems.length}
+ {' '}
+ matching suggestions. Selecting one only replaces the input text.
+
+ )
+}
+
+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()
+ ? {part}
+ : part
+ ))}
+ >
+ )
+}
+
+const FuzzyMatchingDemo = () => {
+ const [value, setValue] = useState('retr')
+ const { contains } = useAutocompleteFilter({ sensitivity: 'base' })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ {item.icon && }
+
+
+
+
+
{item.description}
+
+
+ )}
+
+ No workflow suggestion. Keep typing freely.
+
+
+
+ )
+}
+
+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
+
+export default meta
+type Story = StoryObj
+
+export const SearchTags: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const Sizes: Story = {
+ render: () => (
+
+ {(['small', 'medium', 'large'] as const).map(size => (
+
+
+
+ ))}
+
+ ),
+}
+
+export const InlineAutocomplete: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+ No inline completion. Continue typing freely.
+
+
+
+ ),
+}
+
+export const GroupedSuggestions: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+ No suggestion. Use the text as entered.
+
+
+
+ ),
+}
+
+export const FuzzyMatching: Story = {
+ render: () => ,
+}
+
+export const LimitResults: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+ No suggestion. Submit the typed text instead.
+
+
+
+ ),
+}
+
+export const CommandPalette: Story = {
+ render: () => (
+
+ ),
+}
+
+const VirtualizedLongSuggestionsDemo = () => {
+ const virtualizerRef = useRef(null)
+
+ return (
+
+
{
+ scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ No suggestion. Free-form text is still valid.
+
+
+
+ )
+}
+
+export const VirtualizedLongSuggestions: Story = {
+ render: () => ,
+}
+
+export const AsyncSearch: Story = {
+ render: () => ,
+}
+
+export const Empty: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+ No tag suggestion. The custom text remains valid.
+
+
+
+ ),
+}
+
+export const DisabledAndReadOnly: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {(item: Suggestion, index: number) => (
+
+ )}
+
+
+
+
+ ),
+}
diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx
new file mode 100644
index 0000000000..16c4b19673
--- /dev/null
+++ b/packages/dify-ui/src/autocomplete/index.tsx
@@ -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 = BaseAutocomplete.Root.Props
+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['size']>
+
+export type AutocompleteInputGroupProps
+ = BaseAutocomplete.InputGroup.Props
+ & VariantProps
+
+export function AutocompleteInputGroup({
+ className,
+ size = 'medium',
+ ...props
+}: AutocompleteInputGroupProps) {
+ return (
+
+ )
+}
+
+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
+ & VariantProps
+
+export function AutocompleteInput({
+ className,
+ size = 'medium',
+ type = 'text',
+ autoComplete = 'off',
+ ...props
+}: AutocompleteInputProps) {
+ return (
+
+ )
+}
+
+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
+ & VariantProps
+ & { className?: string }
+
+export function AutocompleteTrigger({
+ className,
+ children,
+ size = 'medium',
+ type = 'button',
+ ...props
+}: AutocompleteControlProps) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export type AutocompleteClearProps
+ = Omit
+ & VariantProps
+ & { className?: string }
+
+export function AutocompleteClear({
+ className,
+ children,
+ size = 'medium',
+ type = 'button',
+ ...props
+}: AutocompleteClearProps) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export function AutocompleteIcon({
+ className,
+ children,
+ ...props
+}: BaseAutocomplete.Icon.Props) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+type AutocompleteContentProps = {
+ children: ReactNode
+ placement?: Placement
+ sideOffset?: number
+ alignOffset?: number
+ className?: string
+ popupClassName?: string
+ portalProps?: Omit
+ 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 (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export function AutocompleteList({
+ className,
+ ...props
+}: BaseAutocomplete.List.Props) {
+ return (
+
+ )
+}
+
+export function AutocompleteItem({
+ className,
+ ...props
+}: BaseAutocomplete.Item.Props) {
+ return (
+
+ )
+}
+
+export type AutocompleteItemTextProps = HTMLAttributes
+
+export function AutocompleteItemText({
+ className,
+ ...props
+}: AutocompleteItemTextProps) {
+ return (
+
+ )
+}
+
+export function AutocompleteLabel({
+ className,
+ ...props
+}: BaseAutocomplete.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+export function AutocompleteSeparator({
+ className,
+ ...props
+}: BaseAutocomplete.Separator.Props) {
+ return (
+
+ )
+}
+
+export function AutocompleteEmpty({
+ className,
+ ...props
+}: BaseAutocomplete.Empty.Props) {
+ return (
+
+ )
+}
+
+export function AutocompleteStatus({
+ className,
+ ...props
+}: BaseAutocomplete.Status.Props) {
+ return (
+
+ )
+}
+
+export function AutocompleteItemIndicator({
+ className,
+ children,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ {children ?? }
+
+ )
+}
diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..705ebe9601
--- /dev/null
+++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx
@@ -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(
+
+ {ui}
+
,
+)
+
+const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
+
+const renderSelectLikeCombobox = ({
+ children,
+ open = false,
+}: {
+ children?: ReactNode
+ open?: boolean
+} = {}) => renderWithSafeViewport(
+
+ {children ?? (
+ <>
+ Resource type
+
+
+
+
+ 2 options
+
+
+ Workflow
+
+
+
+ Dataset
+
+
+ No options
+
+ >
+ )}
+ ,
+)
+
+const renderInputCombobox = ({
+ children,
+ open = false,
+}: {
+ children?: ReactNode
+ open?: boolean
+} = {}) => renderWithSafeViewport(
+
+ {children ?? (
+ <>
+
+
+
+
+
+
+
+
+ Workflow
+
+
+
+
+ >
+ )}
+ ,
+)
+
+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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ ),
+ })
+
+ 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: (
+
+
+
+ ),
+ })
+
+ 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: (
+ <>
+ Clear from label
+ Trigger from label
+
+
+
+
+
+ >
+ ),
+ })
+
+ 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(
+
+
+
+
+
+
+
+ Workflow
+
+
+
+ ,
+ )
+
+ 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(
+
+
+
+
+
+
+
+ Resources
+
+
+ Workflow
+
+
+
+
+
+ ,
+ )
+
+ 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(
+
+
+
+ {(selectedValue: string[]) => (
+
+ {selectedValue.map(item => (
+
+ {item}
+
+
+ ))}
+
+ )}
+
+
+
+ ,
+ )
+
+ 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(
+
+
+
+ {(selectedValue: string[]) => (
+
+ {selectedValue.map(item => (
+
+ Remove Maya
+
+
+ ))}
+
+ )}
+
+
+
+ ,
+ )
+
+ await expect.element(screen.getByRole('button', { name: 'Remove Maya' })).not.toHaveAttribute('aria-label')
+ })
+ })
+})
diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx
new file mode 100644
index 0000000000..f2b5f4d4c6
--- /dev/null
+++ b/packages/dify-ui/src/combobox/index.stories.tsx
@@ -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
+
+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) => (
+
+
+ {option.icon && }
+
+ {option.label}
+ {option.meta && {option.meta} }
+
+
+
+
+)
+
+const renderSimpleOptionItem = (option: Option, index?: number) => (
+
+ {option.label}
+
+
+)
+
+const PopupSearchInput = ({
+ label,
+ placeholder,
+}: {
+ label: string
+ placeholder: string
+}) => (
+
+
+
+
+
+)
+
+const GroupedToolList = () => {
+ const groups = useComboboxFilteredItems()
+
+ return (
+
+ {groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {group.label}
+
+ {(option: Option) => renderOptionItem(option)}
+
+
+ ))}
+
+ )
+}
+
+const VirtualizedModelList = ({
+ virtualizerRef,
+}: {
+ virtualizerRef: RefObject
+}) => {
+ const scrollRef = useRef(null)
+ const filteredItems = useComboboxFilteredItems()
+ const virtualizer = useVirtualizer({
+ count: filteredItems.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 42,
+ overscan: 6,
+ })
+
+ useEffect(() => {
+ virtualizerRef.current = virtualizer
+
+ return () => {
+ virtualizerRef.current = null
+ }
+ }, [virtualizer, virtualizerRef])
+
+ return (
+
+
+ {virtualizer.getVirtualItems().map((virtualItem) => {
+ const option = filteredItems[virtualItem.index]
+
+ if (!option)
+ return null
+
+ return (
+
+ {renderOptionItem(option, virtualItem.index)}
+
+ )
+ })}
+
+
+ )
+}
+
+const FilteredModelStatus = () => {
+ const filteredItems = useComboboxFilteredItems ()
+
+ return (
+
+ {filteredItems.length}
+ {' '}
+ matching models
+
+ )
+}
+
+const VirtualizedLongListDemo = () => {
+ const [value, setValue] = useState (modelCatalogOptions[137]!)
+ const virtualizerRef = useRef(null)
+
+ return (
+
+
{
+ scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
+ }}
+ >
+ Model catalog
+
+
+
+
+
+
+
+ No model matches this filter
+
+
+
+ )
+}
+
+const AsyncDirectoryDemo = () => {
+ const [inputValue, setInputValue] = useState('ma')
+ const [value, setValue] = useState(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 (
+
+ item.value === value.value) ? [value, ...items] : items}
+ value={value}
+ onValueChange={setValue}
+ inputValue={inputValue}
+ onInputValueChange={setInputValue}
+ autoHighlight
+ >
+
+ Owner
+
+
+
+
+
+
+
+
+
+ {loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
+
+ {renderOptionItem}
+ No owner matches this query
+
+
+
+ )
+}
+
+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
+
+export default meta
+type Story = StoryObj
+
+export const SelectLikeDefault: Story = {
+ render: () => (
+
+
+ Model provider
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+ ),
+}
+
+export const PopupInputSearchableSelect: Story = {
+ render: () => (
+
+
+ Data source
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+ ),
+}
+
+export const AsyncSearchSingle: Story = {
+ render: () => ,
+}
+
+export const InputGroupSearchable: Story = {
+ render: () => (
+
+
+
+ Connect source
+
+
+
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+ ),
+}
+
+export const Sizes: Story = {
+ render: () => (
+
+ {(['small', 'medium', 'large'] as const).map(size => (
+
+
+
+
+
+
+ {renderOptionItem}
+
+
+ ))}
+
+ ),
+}
+
+export const Grouped: Story = {
+ render: () => (
+
+
+ Workflow tool
+
+
+
+
+
+
+
+
+
+ ),
+}
+
+const MultipleChipsDemo = () => {
+ const [value, setValue] = useState(defaultReviewers)
+
+ return (
+
+
+
+ Reviewers
+
+
+ {(selectedValue: Option[]) => (
+ <>
+
+ {selectedValue.map(item => (
+
+ {item.label}
+
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+ )
+}
+
+export const MultipleChips: Story = {
+ render: () => ,
+}
+
+export const VirtualizedLongList: Story = {
+ render: () => ,
+}
+
+export const EmptyAndStatus: Story = {
+ render: () => (
+
+
+
+ Connector
+
+
+
+
+
+
+
+
+ Search workspace connectors
+ No connectors found
+ {renderSimpleOptionItem}
+
+
+
+ ),
+}
+
+export const DisabledAndReadOnly: Story = {
+ render: () => (
+
+
+ Disabled provider
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+
+ Read-only source
+
+
+
+
+
+
+
+ {renderOptionItem}
+
+
+
+ ),
+}
+
+const ControlledDemo = () => {
+ const [value, setValue] = useState (defaultTag)
+
+ return (
+
+
+ Default app tag
+
+
+
+
+
+ {renderSimpleOptionItem}
+
+
+
+ Selected:
+ {' '}
+ {value?.label ?? 'None'}
+
+
+ )
+}
+
+export const Controlled: Story = {
+ render: () => ,
+}
diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx
new file mode 100644
index 0000000000..c4f03241f6
--- /dev/null
+++ b/packages/dify-ui/src/combobox/index.tsx
@@ -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
+ = BaseCombobox.Root.Props
+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['size']>
+
+type ComboboxTriggerProps
+ = Omit
+ & VariantProps
+ & {
+ className?: string
+ icon?: ReactNode | false
+ }
+
+export function ComboboxTrigger({
+ className,
+ children,
+ icon,
+ size,
+ type = 'button',
+ ...props
+}: ComboboxTriggerProps) {
+ return (
+
+
+ {children}
+
+ {icon !== false && (
+
+ {icon ?? }
+
+ )}
+
+ )
+}
+
+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
+
+export function ComboboxInputGroup({
+ className,
+ size = 'medium',
+ ...props
+}: ComboboxInputGroupProps) {
+ return (
+
+ )
+}
+
+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
+ & VariantProps
+
+export function ComboboxInput({
+ className,
+ size = 'medium',
+ type = 'text',
+ autoComplete = 'off',
+ ...props
+}: ComboboxInputProps) {
+ return (
+
+ )
+}
+
+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
+ & VariantProps
+ & { className?: string }
+
+export function ComboboxClear({
+ className,
+ children,
+ size = 'medium',
+ type = 'button',
+ ...props
+}: ComboboxClearProps) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export type ComboboxInputTriggerProps
+ = Omit
+ & VariantProps
+ & { className?: string }
+
+export function ComboboxInputTrigger({
+ className,
+ children,
+ size = 'medium',
+ type = 'button',
+ ...props
+}: ComboboxInputTriggerProps) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export function ComboboxIcon({
+ className,
+ children,
+ ...props
+}: BaseCombobox.Icon.Props) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+type ComboboxContentProps = {
+ children: ReactNode
+ placement?: Placement
+ sideOffset?: number
+ alignOffset?: number
+ className?: string
+ popupClassName?: string
+ portalProps?: Omit
+ 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 (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export function ComboboxList({
+ className,
+ ...props
+}: BaseCombobox.List.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxItem({
+ className,
+ ...props
+}: BaseCombobox.Item.Props) {
+ return (
+
+ )
+}
+
+export type ComboboxItemTextProps = HTMLAttributes
+
+export function ComboboxItemText({
+ className,
+ ...props
+}: ComboboxItemTextProps) {
+ return (
+
+ )
+}
+
+export function ComboboxItemIndicator({
+ className,
+ children,
+ ...props
+}: Omit & { children?: ReactNode }) {
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export function ComboboxLabel({
+ className,
+ ...props
+}: BaseCombobox.Label.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxGroupLabel({
+ className,
+ ...props
+}: BaseCombobox.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxSeparator({
+ className,
+ ...props
+}: BaseCombobox.Separator.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxEmpty({
+ className,
+ ...props
+}: BaseCombobox.Empty.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxStatus({
+ className,
+ ...props
+}: BaseCombobox.Status.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxChips({
+ className,
+ ...props
+}: BaseCombobox.Chips.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxChip({
+ className,
+ ...props
+}: BaseCombobox.Chip.Props) {
+ return (
+
+ )
+}
+
+export function ComboboxChipRemove({
+ className,
+ children,
+ type = 'button',
+ ...props
+}: BaseCombobox.ChipRemove.Props) {
+ return (
+
+ {children ?? }
+
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4826ce8163..d2903da1ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md
index 1ab79511f2..7a6436ed87 100644
--- a/web/docs/overlay-migration.md
+++ b/web/docs/overlay-migration.md
@@ -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:
@@ -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,