From a24ec60e51c8c25c66d39c39d2ce19b0a6539507 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 7 May 2026 13:39:13 +0800 Subject: [PATCH] feat: add dify-ui autocomplete and combobox (#35868) --- packages/dify-ui/AGENTS.md | 24 + packages/dify-ui/README.md | 22 +- packages/dify-ui/package.json | 9 + .../src/autocomplete/__tests__/index.spec.tsx | 252 ++++++ .../src/autocomplete/index.stories.tsx | 721 ++++++++++++++++++ packages/dify-ui/src/autocomplete/index.tsx | 381 +++++++++ .../src/combobox/__tests__/index.spec.tsx | 363 +++++++++ .../dify-ui/src/combobox/index.stories.tsx | 618 +++++++++++++++ packages/dify-ui/src/combobox/index.tsx | 497 ++++++++++++ pnpm-lock.yaml | 3 + web/docs/overlay-migration.md | 16 +- 11 files changed, 2888 insertions(+), 18 deletions(-) create mode 100644 packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/autocomplete/index.stories.tsx create mode 100644 packages/dify-ui/src/autocomplete/index.tsx create mode 100644 packages/dify-ui/src/combobox/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/combobox/index.stories.tsx create mode 100644 packages/dify-ui/src/combobox/index.tsx 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 && +) + +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 && + + 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 && + )} + + 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