mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
feat: add dify-ui autocomplete and combobox (#35868)
This commit is contained in:
parent
8fd616d27f
commit
a24ec60e51
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:",
|
||||
|
||||
252
packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx
Normal file
252
packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteClear,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteGroup,
|
||||
AutocompleteInput,
|
||||
AutocompleteInputGroup,
|
||||
AutocompleteItem,
|
||||
AutocompleteItemIndicator,
|
||||
AutocompleteItemText,
|
||||
AutocompleteLabel,
|
||||
AutocompleteList,
|
||||
AutocompleteSeparator,
|
||||
AutocompleteStatus,
|
||||
AutocompleteTrigger,
|
||||
} from '../index'
|
||||
|
||||
const renderWithSafeViewport = (ui: ReactNode) => render(
|
||||
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
|
||||
{ui}
|
||||
</div>,
|
||||
)
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
const renderAutocomplete = ({
|
||||
children,
|
||||
open = false,
|
||||
defaultValue = 'workflow',
|
||||
}: {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
defaultValue?: string
|
||||
} = {}) => renderWithSafeViewport(
|
||||
<Autocomplete open={open} defaultValue={defaultValue} items={['workflow', 'dataset']}>
|
||||
{children ?? (
|
||||
<>
|
||||
<AutocompleteInputGroup data-testid="input-group">
|
||||
<AutocompleteInput aria-label="Search suggestions" data-testid="input" />
|
||||
<AutocompleteClear data-testid="clear" />
|
||||
<AutocompleteTrigger data-testid="trigger" />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'autocomplete positioner',
|
||||
}}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'autocomplete popup',
|
||||
}}
|
||||
>
|
||||
<AutocompleteStatus data-testid="status">2 suggestions</AutocompleteStatus>
|
||||
<AutocompleteList role="listbox" aria-label="autocomplete list" data-testid="list">
|
||||
<AutocompleteItem value="workflow">
|
||||
<AutocompleteItemText>Workflow</AutocompleteItemText>
|
||||
<AutocompleteItemIndicator />
|
||||
</AutocompleteItem>
|
||||
<AutocompleteItem value="dataset">
|
||||
<AutocompleteItemText>Dataset</AutocompleteItemText>
|
||||
</AutocompleteItem>
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty data-testid="empty">No suggestions</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</>
|
||||
)}
|
||||
</Autocomplete>,
|
||||
)
|
||||
|
||||
describe('Autocomplete wrappers', () => {
|
||||
describe('Input group and input', () => {
|
||||
it('should apply medium input group and input classes by default', async () => {
|
||||
const screen = await renderAutocomplete()
|
||||
|
||||
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-lg')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('px-3')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should apply large input group and input classes when large size is provided', async () => {
|
||||
const screen = await renderAutocomplete({
|
||||
children: (
|
||||
<AutocompleteInputGroup size="large" data-testid="input-group">
|
||||
<AutocompleteInput size="large" aria-label="Search suggestions" data-testid="input" />
|
||||
</AutocompleteInputGroup>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-[10px]')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('px-4')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('system-md-regular')
|
||||
})
|
||||
|
||||
it('should set input defaults and forward passthrough props', async () => {
|
||||
const screen = await renderAutocomplete({
|
||||
children: (
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput
|
||||
aria-label="Search suggestions"
|
||||
className="custom-input"
|
||||
placeholder="Find a resource"
|
||||
required
|
||||
/>
|
||||
</AutocompleteInputGroup>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('autocomplete', 'off')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('type', 'text')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('placeholder', 'Find a resource')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toBeRequired()
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('custom-input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Controls', () => {
|
||||
it('should provide fallback aria labels and decorative icons when labels are omitted', async () => {
|
||||
const screen = await renderAutocomplete()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Clear autocomplete' })).toHaveAttribute('type', 'button')
|
||||
await expect.element(screen.getByRole('button', { name: 'Open autocomplete suggestions' })).toHaveAttribute('type', 'button')
|
||||
expect(screen.getByRole('button', { name: 'Clear autocomplete' }).element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Open autocomplete suggestions' }).element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should preserve explicit labels and custom children', async () => {
|
||||
const screen = await renderAutocomplete({
|
||||
children: (
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Search suggestions" />
|
||||
<AutocompleteClear aria-label="Reset search">
|
||||
<span data-testid="custom-clear">reset</span>
|
||||
</AutocompleteClear>
|
||||
<AutocompleteTrigger aria-label="Show suggestions">
|
||||
<span data-testid="custom-trigger">open</span>
|
||||
</AutocompleteTrigger>
|
||||
</AutocompleteInputGroup>
|
||||
),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Reset search' }).element()).toContainElement(screen.getByTestId('custom-clear').element())
|
||||
expect(screen.getByRole('button', { name: 'Show suggestions' }).element()).toContainElement(screen.getByTestId('custom-trigger').element())
|
||||
expect(screen.getByRole('button', { name: 'Reset search' }).element().querySelector('.i-ri-close-line')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Show suggestions' }).element().querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => {
|
||||
const screen = await renderAutocomplete({
|
||||
children: (
|
||||
<>
|
||||
<span id="clear-label">Clear from label</span>
|
||||
<span id="trigger-label">Trigger from label</span>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Search suggestions" />
|
||||
<AutocompleteClear aria-labelledby="clear-label" />
|
||||
<AutocompleteTrigger aria-labelledby="trigger-label" />
|
||||
</AutocompleteInputGroup>
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label')
|
||||
await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content and options', () => {
|
||||
it('should use default overlay placement and Dify popup classes', async () => {
|
||||
const screen = await renderAutocomplete({ open: true })
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'bottom')
|
||||
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-align', 'start')
|
||||
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-1002')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('rounded-xl')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('w-(--anchor-width)')
|
||||
await expect.element(screen.getByRole('listbox', { name: 'autocomplete list' })).toHaveClass('scroll-py-1')
|
||||
})
|
||||
|
||||
it('should apply custom placement side and passthrough popup props', async () => {
|
||||
const onPopupClick = vi.fn()
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Autocomplete open defaultValue="workflow" items={['workflow']}>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Search suggestions" />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={6}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'autocomplete positioner' }}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'autocomplete popup',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
>
|
||||
<AutocompleteList role="listbox" aria-label="autocomplete list">
|
||||
<AutocompleteItem value="workflow">
|
||||
<AutocompleteItemText>Workflow</AutocompleteItemText>
|
||||
</AutocompleteItem>
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('dialog', { name: 'autocomplete popup' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'top')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render item text indicator status and empty wrappers with design classes', async () => {
|
||||
const screen = await renderAutocomplete({ open: true })
|
||||
|
||||
await expect.element(screen.getByText('Workflow')).toHaveClass('system-sm-medium')
|
||||
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
|
||||
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
|
||||
expect(screen.getByText('Workflow').element().parentElement?.querySelector('.i-ri-arrow-right-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should forward custom classes to label separator item text and indicator', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Autocomplete open defaultValue="workflow" items={['workflow']}>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Search suggestions" />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent popupProps={{ 'role': 'dialog', 'aria-label': 'autocomplete popup' }}>
|
||||
<AutocompleteList role="listbox" aria-label="autocomplete list">
|
||||
<AutocompleteGroup items={['workflow']}>
|
||||
<AutocompleteLabel className="custom-label">Resources</AutocompleteLabel>
|
||||
<AutocompleteSeparator className="custom-separator" data-testid="separator" />
|
||||
<AutocompleteItem value="workflow" className="custom-item">
|
||||
<AutocompleteItemText className="custom-text">Workflow</AutocompleteItemText>
|
||||
<AutocompleteItemIndicator className="custom-indicator" data-testid="indicator" />
|
||||
</AutocompleteItem>
|
||||
</AutocompleteGroup>
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
|
||||
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
|
||||
await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item')
|
||||
await expect.element(screen.getByText('Workflow')).toHaveClass('custom-text')
|
||||
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
|
||||
})
|
||||
})
|
||||
})
|
||||
721
packages/dify-ui/src/autocomplete/index.stories.tsx
Normal file
721
packages/dify-ui/src/autocomplete/index.stories.tsx
Normal file
@ -0,0 +1,721 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { RefObject } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteClear,
|
||||
AutocompleteCollection,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteGroup,
|
||||
AutocompleteInput,
|
||||
AutocompleteInputGroup,
|
||||
AutocompleteItem,
|
||||
AutocompleteItemText,
|
||||
AutocompleteLabel,
|
||||
AutocompleteList,
|
||||
AutocompleteSeparator,
|
||||
AutocompleteStatus,
|
||||
AutocompleteTrigger,
|
||||
useAutocompleteFilter,
|
||||
useAutocompleteFilteredItems,
|
||||
} from '.'
|
||||
import { cn } from '../cn'
|
||||
|
||||
type Suggestion = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
type SuggestionGroup = {
|
||||
label: string
|
||||
items: Suggestion[]
|
||||
}
|
||||
|
||||
const inputWidth = 'w-80'
|
||||
|
||||
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
|
||||
|
||||
const scrollHighlightedVirtualItem = (
|
||||
item: unknown,
|
||||
{
|
||||
reason,
|
||||
index,
|
||||
}: {
|
||||
reason: 'keyboard' | 'pointer' | 'none'
|
||||
index: number
|
||||
},
|
||||
virtualizer: StoryVirtualizer | null,
|
||||
) => {
|
||||
if (!item || !virtualizer)
|
||||
return
|
||||
|
||||
const isStart = index === 0
|
||||
const isEnd = index === virtualizer.options.count - 1
|
||||
const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd))
|
||||
|
||||
if (shouldScroll) {
|
||||
queueMicrotask(() => {
|
||||
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tagSuggestions: Suggestion[] = [
|
||||
{ value: 'feature', label: 'feature', description: 'Product work and launch notes' },
|
||||
{ value: 'fix', label: 'fix', description: 'Bug fixes and regressions' },
|
||||
{ value: 'docs', label: 'docs', description: 'Documentation updates' },
|
||||
{ value: 'internal', label: 'internal', description: 'Workspace-only notes' },
|
||||
{ value: 'mobile', label: 'mobile', description: 'Mobile app issues' },
|
||||
{ value: 'component: autocomplete', label: 'component: autocomplete', description: 'Base UI primitive wrapper' },
|
||||
{ value: 'component: combobox', label: 'component: combobox', description: 'Filterable predefined selection' },
|
||||
{ value: 'component: select', label: 'component: select', description: 'Compact predefined selection' },
|
||||
]
|
||||
|
||||
const promptCompletions: Suggestion[] = [
|
||||
{ value: 'summarize this conversation', label: 'summarize this conversation' },
|
||||
{ value: 'summarize this dataset with citations', label: 'summarize this dataset with citations' },
|
||||
{ value: 'summarize this workflow run for an operator', label: 'summarize this workflow run for an operator' },
|
||||
{ value: 'summarize this support ticket in 3 bullets', label: 'summarize this support ticket in 3 bullets' },
|
||||
]
|
||||
|
||||
const workflowSuggestions: Suggestion[] = [
|
||||
{ value: 'http-request', label: 'HTTP Request', description: 'Call an external API', icon: 'i-ri-global-line', meta: 'Tool' },
|
||||
{ value: 'knowledge-retrieval', label: 'Knowledge Retrieval', description: 'Search configured datasets', icon: 'i-ri-database-2-line', meta: 'Tool' },
|
||||
{ value: 'code-execution', label: 'Code Execution', description: 'Run sandboxed snippets', icon: 'i-ri-code-s-slash-line', meta: 'Tool' },
|
||||
{ value: 'template-transform', label: 'Template Transform', description: 'Compose variables into output', icon: 'i-ri-braces-line', meta: 'Tool' },
|
||||
{ value: 'question-classifier', label: 'Question Classifier', description: 'Route by intent', icon: 'i-ri-git-branch-line', meta: 'Tool' },
|
||||
{ value: 'parameter-extractor', label: 'Parameter Extractor', description: 'Extract typed values', icon: 'i-ri-list-check-3', meta: 'Tool' },
|
||||
{ value: 'answer-node', label: 'Answer Node', description: 'Return a final assistant answer', icon: 'i-ri-message-3-line', meta: 'Node' },
|
||||
{ value: 'iteration-node', label: 'Iteration Node', description: 'Run a loop over array items', icon: 'i-ri-repeat-line', meta: 'Node' },
|
||||
{ value: 'variable-assigner', label: 'Variable Assigner', description: 'Persist intermediate state', icon: 'i-ri-pencil-ruler-2-line', meta: 'Node' },
|
||||
]
|
||||
|
||||
const groupedSuggestions: SuggestionGroup[] = [
|
||||
{
|
||||
label: 'Tags',
|
||||
items: tagSuggestions.slice(0, 5),
|
||||
},
|
||||
{
|
||||
label: 'Workflow Suggestions',
|
||||
items: workflowSuggestions.slice(0, 5),
|
||||
},
|
||||
{
|
||||
label: 'Prompt Starters',
|
||||
items: promptCompletions.slice(0, 3),
|
||||
},
|
||||
]
|
||||
|
||||
const commandGroups: SuggestionGroup[] = [
|
||||
{
|
||||
label: 'App',
|
||||
items: [
|
||||
{ value: '/run', label: 'Run workflow', description: 'Execute the current draft', icon: 'i-ri-play-circle-line' },
|
||||
{ value: '/publish', label: 'Publish app', description: 'Ship the current configuration', icon: 'i-ri-upload-cloud-2-line' },
|
||||
{ value: '/trace', label: 'Open trace', description: 'Inspect the latest workflow run', icon: 'i-ri-route-line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Workspace',
|
||||
items: [
|
||||
{ value: '/dataset', label: 'Search datasets', description: 'Find knowledge attached to this app', icon: 'i-ri-database-line' },
|
||||
{ value: '/members', label: 'Invite members', description: 'Open workspace access settings', icon: 'i-ri-user-add-line' },
|
||||
{ value: '/usage', label: 'View usage', description: 'Open model and workflow usage', icon: 'i-ri-bar-chart-line' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const remoteSuggestions: Suggestion[] = [
|
||||
{ value: 'agent-builder', label: 'Agent Builder', description: 'Workspace app' },
|
||||
{ value: 'agent-observability', label: 'Agent Observability', description: 'Dataset' },
|
||||
{ value: 'agent-routing-dataset', label: 'Agent Routing Dataset', description: 'Knowledge source' },
|
||||
]
|
||||
|
||||
const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, index) => {
|
||||
const family = ['workflow', 'dataset', 'prompt', 'tool'][index % 4]!
|
||||
const number = new Intl.NumberFormat('en-US', {
|
||||
minimumIntegerDigits: 4,
|
||||
}).format(index + 1)
|
||||
|
||||
return {
|
||||
value: `${family}-${index + 1}`,
|
||||
label: `${family} suggestion ${number}`,
|
||||
description: `Free-form autocomplete result from ${family} search`,
|
||||
icon: family === 'dataset'
|
||||
? 'i-ri-database-2-line'
|
||||
: family === 'prompt'
|
||||
? 'i-ri-text-snippet'
|
||||
: family === 'tool'
|
||||
? 'i-ri-tools-line'
|
||||
: 'i-ri-flow-chart',
|
||||
meta: family,
|
||||
}
|
||||
})
|
||||
|
||||
const getSuggestionLabel = (item: Suggestion) => item.label
|
||||
|
||||
const SuggestionItem = ({
|
||||
item,
|
||||
index,
|
||||
dense,
|
||||
}: {
|
||||
item: Suggestion
|
||||
index?: number
|
||||
dense?: boolean
|
||||
}) => (
|
||||
<AutocompleteItem value={item} index={index}>
|
||||
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
|
||||
<div className="flex min-w-0 grow flex-col">
|
||||
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
|
||||
{!dense && item.description && (
|
||||
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.meta && (
|
||||
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
|
||||
{item.meta}
|
||||
</span>
|
||||
)}
|
||||
</AutocompleteItem>
|
||||
)
|
||||
|
||||
const TagSuggestionItem = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: Suggestion
|
||||
index?: number
|
||||
}) => (
|
||||
<AutocompleteItem value={item} index={index}>
|
||||
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
|
||||
{item.description && <span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">{item.description}</span>}
|
||||
</AutocompleteItem>
|
||||
)
|
||||
|
||||
const BasicTagAutocomplete = ({
|
||||
size = 'medium',
|
||||
}: {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}) => (
|
||||
<Autocomplete
|
||||
items={tagSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup size={size}>
|
||||
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput size={size} placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" />
|
||||
<AutocompleteClear size={size} />
|
||||
<AutocompleteTrigger size={size} />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<TagSuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
)
|
||||
|
||||
const GroupedSuggestionList = () => {
|
||||
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
|
||||
|
||||
return (
|
||||
<AutocompleteList>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<AutocompleteGroup key={group.label} items={group.items}>
|
||||
{groupIndex > 0 && <AutocompleteSeparator />}
|
||||
<AutocompleteLabel>{group.label}</AutocompleteLabel>
|
||||
<AutocompleteCollection>
|
||||
{(item: Suggestion) => (
|
||||
<SuggestionItem key={item.value} item={item} />
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
</AutocompleteGroup>
|
||||
))}
|
||||
</AutocompleteList>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandPaletteList = () => {
|
||||
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
|
||||
|
||||
return (
|
||||
<AutocompleteList className="max-h-72 rounded-lg border border-divider-subtle bg-components-panel-bg p-1 shadow-xs">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<AutocompleteGroup key={group.label} items={group.items}>
|
||||
{groupIndex > 0 && <AutocompleteSeparator />}
|
||||
<AutocompleteLabel>{group.label}</AutocompleteLabel>
|
||||
<AutocompleteCollection>
|
||||
{(item: Suggestion) => (
|
||||
<AutocompleteItem key={item.value} value={item} className="grid grid-cols-[1fr_auto]">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
|
||||
<span className="min-w-0">
|
||||
<AutocompleteItemText className="block px-0">{item.label}</AutocompleteItemText>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
<kbd className="rounded-md border border-divider-subtle bg-components-badge-bg-dimm px-1.5 py-0.5 text-text-quaternary system-2xs-medium">
|
||||
Enter
|
||||
</kbd>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
</AutocompleteGroup>
|
||||
))}
|
||||
</AutocompleteList>
|
||||
)
|
||||
}
|
||||
|
||||
const LimitedStatus = ({
|
||||
total,
|
||||
}: {
|
||||
total: number
|
||||
}) => {
|
||||
const items = useAutocompleteFilteredItems<Suggestion>()
|
||||
const hidden = Math.max(0, total - items.length)
|
||||
|
||||
return hidden > 0
|
||||
? `${hidden} more suggestions hidden. Refine the query to narrow results.`
|
||||
: `${items.length} suggestions available.`
|
||||
}
|
||||
|
||||
const AsyncSearchDemo = () => {
|
||||
const [value, setValue] = useState('agent')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState(remoteSuggestions)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const timeout = window.setTimeout(() => {
|
||||
setItems(
|
||||
value.trim()
|
||||
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
|
||||
: remoteSuggestions,
|
||||
)
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Search remote resources…" aria-label="Search remote resources" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteStatus>
|
||||
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
|
||||
</AutocompleteStatus>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<SuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VirtualizedSuggestionList = ({
|
||||
virtualizerRef,
|
||||
}: {
|
||||
virtualizerRef: RefObject<StoryVirtualizer | null>
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 6,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
virtualizerRef.current = virtualizer
|
||||
|
||||
return () => {
|
||||
virtualizerRef.current = null
|
||||
}
|
||||
}, [virtualizer, virtualizerRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[min(22rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain outline-hidden"
|
||||
>
|
||||
<AutocompleteList
|
||||
className="relative max-h-none overflow-visible p-0"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = filteredItems[virtualItem.index]
|
||||
|
||||
if (!item)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SuggestionItem item={item} index={virtualItem.index} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</AutocompleteList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VirtualizedStatus = () => {
|
||||
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
|
||||
|
||||
return (
|
||||
<AutocompleteStatus className="border-b border-divider-subtle text-text-quaternary tabular-nums">
|
||||
{filteredItems.length}
|
||||
{' '}
|
||||
matching suggestions. Selecting one only replaces the input text.
|
||||
</AutocompleteStatus>
|
||||
)
|
||||
}
|
||||
|
||||
const FuzzyHighlight = ({
|
||||
text,
|
||||
query,
|
||||
}: {
|
||||
text: string
|
||||
query: string
|
||||
}) => {
|
||||
const parts = useMemo(() => {
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (!trimmed)
|
||||
return [text]
|
||||
|
||||
const escaped = trimmed.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return text.split(new RegExp(`(${escaped})`, 'i'))
|
||||
}, [query, text])
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
part.toLowerCase() === query.trim().toLowerCase()
|
||||
? <mark key={`${part}-${index}`} className="bg-transparent text-text-accent">{part}</mark>
|
||||
: part
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FuzzyMatchingDemo = () => {
|
||||
const [value, setValue] = useState('retr')
|
||||
const { contains } = useAutocompleteFilter({ sensitivity: 'base' })
|
||||
|
||||
return (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={workflowSuggestions}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
filter={contains}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-sparkling-2-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Fuzzy search workflow suggestions…" aria-label="Fuzzy search workflow suggestions" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<AutocompleteItem key={item.value} value={item} index={index}>
|
||||
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
|
||||
<div className="min-w-0 grow">
|
||||
<AutocompleteItemText className="block px-0">
|
||||
<FuzzyHighlight text={item.label} query={value} />
|
||||
</AutocompleteItemText>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
|
||||
</div>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No workflow suggestion. Keep typing freely.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Autocomplete',
|
||||
component: Autocomplete,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound autocomplete built on Base UI Autocomplete. Use it for free-form inputs where suggestions can replace or complete the typed text, but selection is not persistent state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Autocomplete>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const SearchTags: Story = {
|
||||
render: () => (
|
||||
<div className={inputWidth}>
|
||||
<BasicTagAutocomplete />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(['small', 'medium', 'large'] as const).map(size => (
|
||||
<div key={size} className={inputWidth}>
|
||||
<BasicTagAutocomplete size={size} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineAutocomplete: Story = {
|
||||
render: () => (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={promptCompletions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="both"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-text-snippet ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Type a prompt starter…" aria-label="Type a prompt starter" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<SuggestionItem key={item.value} item={item} index={index} dense />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No inline completion. Continue typing freely.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const GroupedSuggestions: Story = {
|
||||
render: () => (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={groupedSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-command-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Search tags, nodes, or prompt starters…" aria-label="Search tags, nodes, or prompt starters" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent popupClassName="w-[420px]">
|
||||
<GroupedSuggestionList />
|
||||
<AutocompleteEmpty>No suggestion. Use the text as entered.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const FuzzyMatching: Story = {
|
||||
render: () => <FuzzyMatchingDemo />,
|
||||
}
|
||||
|
||||
export const LimitResults: Story = {
|
||||
render: () => (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={workflowSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
limit={5}
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-tools-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Search workflow suggestions…" aria-label="Search workflow suggestions" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent popupClassName="w-[420px]">
|
||||
<AutocompleteStatus className="border-b border-divider-subtle">
|
||||
<LimitedStatus total={workflowSuggestions.length} />
|
||||
</AutocompleteStatus>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<SuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No suggestion. Submit the typed text instead.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CommandPalette: Story = {
|
||||
render: () => (
|
||||
<div className="w-[440px] rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-2 shadow-xs">
|
||||
<Autocomplete
|
||||
open
|
||||
inline
|
||||
items={commandGroups}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
autoHighlight="always"
|
||||
keepHighlight
|
||||
>
|
||||
<AutocompleteInputGroup className="mb-2">
|
||||
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Run a command…" aria-label="Run a command" />
|
||||
<AutocompleteClear />
|
||||
</AutocompleteInputGroup>
|
||||
<CommandPaletteList />
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const VirtualizedLongSuggestionsDemo = () => {
|
||||
const virtualizerRef = useRef<StoryVirtualizer | null>(null)
|
||||
|
||||
return (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={virtualizedSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
virtualized
|
||||
openOnInputClick
|
||||
onItemHighlighted={(item, details) => {
|
||||
scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
|
||||
}}
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Search 1,000 workspace suggestions…" aria-label="Search 1,000 workspace suggestions" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent popupClassName="w-[440px] p-1">
|
||||
<VirtualizedStatus />
|
||||
<VirtualizedSuggestionList virtualizerRef={virtualizerRef} />
|
||||
<AutocompleteEmpty>No suggestion. Free-form text is still valid.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedLongSuggestions: Story = {
|
||||
render: () => <VirtualizedLongSuggestionsDemo />,
|
||||
}
|
||||
|
||||
export const AsyncSearch: Story = {
|
||||
render: () => <AsyncSearchDemo />,
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={tagSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
defaultValue="private-release-note"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-search-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<AutocompleteInput placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<TagSuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No tag suggestion. The custom text remains valid.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DisabledAndReadOnly: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Disabled tag autocomplete" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<TagSuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion, index: number) => (
|
||||
<SuggestionItem key={item.value} item={item} index={index} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
381
packages/dify-ui/src/autocomplete/index.tsx
Normal file
381
packages/dify-ui/src/autocomplete/index.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
'use client'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import type { Placement } from '../placement'
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
import {
|
||||
overlayIndicatorClassName,
|
||||
overlayLabelClassName,
|
||||
overlayPopupAnimationClassName,
|
||||
overlaySeparatorClassName,
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
export const Autocomplete = BaseAutocomplete.Root
|
||||
export const AutocompleteValue = BaseAutocomplete.Value
|
||||
export const AutocompleteGroup = BaseAutocomplete.Group
|
||||
export const AutocompleteCollection = BaseAutocomplete.Collection
|
||||
export const AutocompleteRow = BaseAutocomplete.Row
|
||||
export const useAutocompleteFilter = BaseAutocomplete.useFilter
|
||||
export const useAutocompleteFilteredItems = BaseAutocomplete.useFilteredItems
|
||||
|
||||
export type AutocompleteRootProps<ItemValue> = BaseAutocomplete.Root.Props<ItemValue>
|
||||
export type AutocompleteRootChangeEventDetails = BaseAutocomplete.Root.ChangeEventDetails
|
||||
export type AutocompleteRootHighlightEventDetails = BaseAutocomplete.Root.HighlightEventDetails
|
||||
|
||||
const autocompletePopupClassName = [
|
||||
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
|
||||
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
|
||||
]
|
||||
|
||||
const autocompleteListClassName = [
|
||||
'max-h-[min(20rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden scroll-py-1',
|
||||
'data-empty:max-h-none data-empty:p-0',
|
||||
]
|
||||
|
||||
const autocompleteItemClassName = [
|
||||
'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt hover:text-text-primary',
|
||||
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
const autocompleteInputGroupVariants = cva(
|
||||
[
|
||||
'group/autocomplete flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
|
||||
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
|
||||
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
|
||||
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'h-6 rounded-md',
|
||||
medium: 'h-8 rounded-lg',
|
||||
large: 'h-9 rounded-[10px]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AutocompleteSize = NonNullable<VariantProps<typeof autocompleteInputGroupVariants>['size']>
|
||||
|
||||
export type AutocompleteInputGroupProps
|
||||
= BaseAutocomplete.InputGroup.Props
|
||||
& VariantProps<typeof autocompleteInputGroupVariants>
|
||||
|
||||
export function AutocompleteInputGroup({
|
||||
className,
|
||||
size = 'medium',
|
||||
...props
|
||||
}: AutocompleteInputGroupProps) {
|
||||
return (
|
||||
<BaseAutocomplete.InputGroup
|
||||
className={cn(autocompleteInputGroupVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const autocompleteInputVariants = cva(
|
||||
[
|
||||
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-hidden',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
|
||||
'data-readonly:cursor-default',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'px-2 py-1 system-xs-regular',
|
||||
medium: 'px-3 py-[7px] system-sm-regular',
|
||||
large: 'px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AutocompleteInputProps
|
||||
= Omit<BaseAutocomplete.Input.Props, 'size'>
|
||||
& VariantProps<typeof autocompleteInputVariants>
|
||||
|
||||
export function AutocompleteInput({
|
||||
className,
|
||||
size = 'medium',
|
||||
type = 'text',
|
||||
autoComplete = 'off',
|
||||
...props
|
||||
}: AutocompleteInputProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Input
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
className={cn(autocompleteInputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const autocompleteControlVariants = cva(
|
||||
[
|
||||
'flex shrink-0 touch-manipulation items-center justify-center rounded-md text-text-tertiary outline-hidden transition-colors',
|
||||
'hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
|
||||
'group-data-disabled/autocomplete:cursor-not-allowed group-data-disabled/autocomplete:hover:bg-transparent group-data-disabled/autocomplete:focus-visible:bg-transparent group-data-disabled/autocomplete:focus-visible:ring-0',
|
||||
'group-data-readonly/autocomplete:hidden',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'mr-1 size-4',
|
||||
medium: 'mr-1.5 size-5',
|
||||
large: 'mr-2 size-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AutocompleteControlProps
|
||||
= Omit<BaseAutocomplete.Trigger.Props, 'className'>
|
||||
& VariantProps<typeof autocompleteControlVariants>
|
||||
& { className?: string }
|
||||
|
||||
export function AutocompleteTrigger({
|
||||
className,
|
||||
children,
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
...props
|
||||
}: AutocompleteControlProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Trigger
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open autocomplete suggestions')}
|
||||
className={cn(autocompleteControlVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
|
||||
</BaseAutocomplete.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type AutocompleteClearProps
|
||||
= Omit<BaseAutocomplete.Clear.Props, 'className'>
|
||||
& VariantProps<typeof autocompleteControlVariants>
|
||||
& { className?: string }
|
||||
|
||||
export function AutocompleteClear({
|
||||
className,
|
||||
children,
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
...props
|
||||
}: AutocompleteClearProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Clear
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear autocomplete')}
|
||||
className={cn(
|
||||
autocompleteControlVariants({ size }),
|
||||
'data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-close-line size-4" aria-hidden="true" />}
|
||||
</BaseAutocomplete.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteIcon({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BaseAutocomplete.Icon.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.Icon
|
||||
className={cn('flex shrink-0 items-center text-text-tertiary', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
|
||||
</BaseAutocomplete.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
type AutocompleteContentProps = {
|
||||
children: ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
portalProps?: Omit<BaseAutocomplete.Portal.Props, 'children'>
|
||||
positionerProps?: Omit<
|
||||
BaseAutocomplete.Positioner.Props,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
BaseAutocomplete.Popup.Props,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function AutocompleteContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
portalProps,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: AutocompleteContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseAutocomplete.Portal {...portalProps}>
|
||||
<BaseAutocomplete.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-1002 outline-hidden', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseAutocomplete.Popup
|
||||
className={cn(
|
||||
autocompletePopupClassName,
|
||||
overlayPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BaseAutocomplete.Popup>
|
||||
</BaseAutocomplete.Positioner>
|
||||
</BaseAutocomplete.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteList({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.List.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.List
|
||||
className={cn(autocompleteListClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.Item.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.Item
|
||||
className={cn(autocompleteItemClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type AutocompleteItemTextProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export function AutocompleteItemText({
|
||||
className,
|
||||
...props
|
||||
}: AutocompleteItemTextProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteLabel({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.GroupLabel.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.GroupLabel
|
||||
className={cn(overlayLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.Separator.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.Separator
|
||||
className={cn(overlaySeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.Empty.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.Empty
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: BaseAutocomplete.Status.Props) {
|
||||
return (
|
||||
<BaseAutocomplete.Status
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutocompleteItemIndicator({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(overlayIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
363
packages/dify-ui/src/combobox/__tests__/index.spec.tsx
Normal file
363
packages/dify-ui/src/combobox/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,363 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChipRemove,
|
||||
ComboboxChips,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInputTrigger,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxItemText,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
} from '../index'
|
||||
|
||||
const renderWithSafeViewport = (ui: ReactNode) => render(
|
||||
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
|
||||
{ui}
|
||||
</div>,
|
||||
)
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
const renderSelectLikeCombobox = ({
|
||||
children,
|
||||
open = false,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
} = {}) => renderWithSafeViewport(
|
||||
<Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}>
|
||||
{children ?? (
|
||||
<>
|
||||
<ComboboxLabel data-testid="label">Resource type</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Resource type" data-testid="trigger">
|
||||
<ComboboxValue placeholder="Select resource" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'combobox positioner',
|
||||
}}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'combobox popup',
|
||||
}}
|
||||
>
|
||||
<ComboboxStatus data-testid="status">2 options</ComboboxStatus>
|
||||
<ComboboxList role="listbox" aria-label="combobox list" data-testid="list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
<ComboboxItem value="dataset">
|
||||
<ComboboxItemText>Dataset</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty data-testid="empty">No options</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</>
|
||||
)}
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
const renderInputCombobox = ({
|
||||
children,
|
||||
open = false,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
} = {}) => renderWithSafeViewport(
|
||||
<Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}>
|
||||
{children ?? (
|
||||
<>
|
||||
<ComboboxInputGroup data-testid="input-group">
|
||||
<ComboboxInput aria-label="Search resources" data-testid="input" />
|
||||
<ComboboxClear data-testid="clear" />
|
||||
<ComboboxInputTrigger data-testid="input-trigger" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}>
|
||||
<ComboboxList role="listbox" aria-label="combobox list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</>
|
||||
)}
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
describe('Combobox wrappers', () => {
|
||||
describe('Select-like trigger', () => {
|
||||
it('should render label and apply medium trigger classes by default', async () => {
|
||||
const screen = await renderSelectLikeCombobox()
|
||||
|
||||
await expect.element(screen.getByText('Resource type')).toHaveClass('system-sm-medium')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Resource type' })).toHaveClass('rounded-lg')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Resource type' })).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should apply small and large trigger size variants', async () => {
|
||||
const smallScreen = await renderSelectLikeCombobox({
|
||||
children: (
|
||||
<ComboboxTrigger aria-label="Small resource type" size="small">
|
||||
<ComboboxValue placeholder="Select resource" />
|
||||
</ComboboxTrigger>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(smallScreen.getByRole('combobox', { name: 'Small resource type' })).toHaveClass('rounded-md')
|
||||
await expect.element(smallScreen.getByRole('combobox', { name: 'Small resource type' })).toHaveClass('system-xs-regular')
|
||||
|
||||
const largeScreen = await renderSelectLikeCombobox({
|
||||
children: (
|
||||
<ComboboxTrigger aria-label="Large resource type" size="large">
|
||||
<ComboboxValue placeholder="Select resource" />
|
||||
</ComboboxTrigger>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(largeScreen.getByRole('combobox', { name: 'Large resource type' })).toHaveClass('rounded-[10px]')
|
||||
await expect.element(largeScreen.getByRole('combobox', { name: 'Large resource type' })).toHaveClass('system-md-regular')
|
||||
})
|
||||
|
||||
it('should render default trigger icon and support hiding it', async () => {
|
||||
const withIcon = await renderSelectLikeCombobox()
|
||||
|
||||
expect(withIcon.getByTestId('trigger').element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
|
||||
const withoutIcon = await renderSelectLikeCombobox({
|
||||
children: (
|
||||
<ComboboxTrigger aria-label="Resource type without icon" icon={false}>
|
||||
<ComboboxValue placeholder="Select resource" />
|
||||
</ComboboxTrigger>
|
||||
),
|
||||
})
|
||||
|
||||
expect(withoutIcon.getByRole('combobox', { name: 'Resource type without icon' }).element().querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input group and controls', () => {
|
||||
it('should apply medium input group and input classes by default', async () => {
|
||||
const screen = await renderInputCombobox()
|
||||
|
||||
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-lg')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('px-3')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should apply large input group and input classes when large size is provided', async () => {
|
||||
const screen = await renderInputCombobox({
|
||||
children: (
|
||||
<ComboboxInputGroup size="large" data-testid="input-group">
|
||||
<ComboboxInput size="large" aria-label="Search resources" />
|
||||
</ComboboxInputGroup>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByTestId('input-group')).toHaveClass('rounded-[10px]')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('px-4')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('system-md-regular')
|
||||
})
|
||||
|
||||
it('should set input defaults and forward passthrough props', async () => {
|
||||
const screen = await renderInputCombobox({
|
||||
children: (
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput
|
||||
aria-label="Search resources"
|
||||
className="custom-input"
|
||||
placeholder="Find a resource"
|
||||
required
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('autocomplete', 'off')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('type', 'text')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('placeholder', 'Find a resource')
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toBeRequired()
|
||||
await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('custom-input')
|
||||
})
|
||||
|
||||
it('should provide fallback aria labels and decorative icons for input controls', async () => {
|
||||
const screen = await renderInputCombobox()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Clear combobox' })).toHaveAttribute('type', 'button')
|
||||
await expect.element(screen.getByRole('button', { name: 'Open combobox options' })).toHaveAttribute('type', 'button')
|
||||
expect(screen.getByRole('button', { name: 'Clear combobox' }).element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Open combobox options' }).element().querySelector('.i-ri-arrow-down-s-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => {
|
||||
const screen = await renderInputCombobox({
|
||||
children: (
|
||||
<>
|
||||
<span id="clear-label">Clear from label</span>
|
||||
<span id="trigger-label">Trigger from label</span>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput aria-label="Search resources" />
|
||||
<ComboboxClear aria-labelledby="clear-label" />
|
||||
<ComboboxInputTrigger aria-labelledby="trigger-label" />
|
||||
</ComboboxInputGroup>
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label')
|
||||
await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content and options', () => {
|
||||
it('should use default overlay placement and Dify popup classes', async () => {
|
||||
const screen = await renderSelectLikeCombobox({ open: true })
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'bottom')
|
||||
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-align', 'start')
|
||||
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-1002')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('rounded-xl')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('w-(--anchor-width)')
|
||||
await expect.element(screen.getByRole('listbox', { name: 'combobox list' })).toHaveClass('scroll-py-1')
|
||||
})
|
||||
|
||||
it('should apply custom placement side and passthrough popup props', async () => {
|
||||
const onPopupClick = vi.fn()
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox open defaultValue="workflow" items={['workflow']}>
|
||||
<ComboboxTrigger aria-label="Resource type">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={6}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'combobox positioner' }}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'combobox popup',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
>
|
||||
<ComboboxList role="listbox" aria-label="combobox list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('dialog', { name: 'combobox popup' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'top')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render item text indicator status and empty wrappers with design classes', async () => {
|
||||
const screen = await renderSelectLikeCombobox({ open: true })
|
||||
|
||||
await expect.element(screen.getByTestId('list').getByText('Workflow')).toHaveClass('system-sm-medium')
|
||||
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
|
||||
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
|
||||
expect(screen.getByTestId('list').getByText('Workflow').element().parentElement?.querySelector('.i-ri-check-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should forward custom classes to group label separator item text and indicator', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox open defaultValue="workflow" items={['workflow']}>
|
||||
<ComboboxTrigger aria-label="Resource type">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}>
|
||||
<ComboboxList role="listbox" aria-label="combobox list" data-testid="custom-list">
|
||||
<ComboboxGroup items={['workflow']}>
|
||||
<ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel>
|
||||
<ComboboxSeparator className="custom-separator" data-testid="separator" />
|
||||
<ComboboxItem value="workflow" className="custom-item">
|
||||
<ComboboxItemText className="custom-text">Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator className="custom-indicator" data-testid="indicator" />
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByText('Resources')).toHaveClass('custom-label')
|
||||
await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator')
|
||||
await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item')
|
||||
await expect.element(screen.getByTestId('custom-list').getByText('Workflow')).toHaveClass('custom-text')
|
||||
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple selection chips', () => {
|
||||
it('should render chip wrappers and default remove button label', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox multiple defaultValue={['maya']} items={['maya', 'nora']}>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxValue>
|
||||
{(selectedValue: string[]) => (
|
||||
<ComboboxChips className="custom-chips" data-testid="chips">
|
||||
{selectedValue.map(item => (
|
||||
<ComboboxChip key={item} className="custom-chip">
|
||||
<span>{item}</span>
|
||||
<ComboboxChipRemove data-testid="remove-chip" />
|
||||
</ComboboxChip>
|
||||
))}
|
||||
</ComboboxChips>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxInput aria-label="Reviewers" />
|
||||
</ComboboxInputGroup>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByTestId('chips')).toHaveClass('custom-chips')
|
||||
await expect.element(screen.getByText('maya').element().parentElement!).toHaveClass('custom-chip')
|
||||
await expect.element(screen.getByRole('button', { name: 'Remove selected item' })).toHaveAttribute('type', 'button')
|
||||
expect(screen.getByTestId('remove-chip').element().querySelector('.i-ri-close-line')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should preserve chip remove aria-labelledby over fallback label', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox multiple defaultValue={['maya']} items={['maya']}>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxValue>
|
||||
{(selectedValue: string[]) => (
|
||||
<ComboboxChips>
|
||||
{selectedValue.map(item => (
|
||||
<ComboboxChip key={item}>
|
||||
<span id="remove-maya">Remove Maya</span>
|
||||
<ComboboxChipRemove aria-labelledby="remove-maya" />
|
||||
</ComboboxChip>
|
||||
))}
|
||||
</ComboboxChips>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxInput aria-label="Reviewers" />
|
||||
</ComboboxInputGroup>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Remove Maya' })).not.toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
})
|
||||
618
packages/dify-ui/src/combobox/index.stories.tsx
Normal file
618
packages/dify-ui/src/combobox/index.stories.tsx
Normal file
@ -0,0 +1,618 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { RefObject } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChipRemove,
|
||||
ComboboxChips,
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInputTrigger,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxItemText,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxFilteredItems,
|
||||
} from '.'
|
||||
import { cn } from '../cn'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
label: string
|
||||
meta?: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type OptionGroup = {
|
||||
label: string
|
||||
items: Option[]
|
||||
}
|
||||
|
||||
const fieldWidth = 'w-80'
|
||||
const wideFieldWidth = 'w-[520px]'
|
||||
const nativeFieldLabelClassName = 'mb-1 block text-text-secondary system-sm-medium'
|
||||
|
||||
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
|
||||
|
||||
const scrollHighlightedVirtualItem = (
|
||||
item: unknown,
|
||||
{
|
||||
reason,
|
||||
index,
|
||||
}: {
|
||||
reason: 'keyboard' | 'pointer' | 'none'
|
||||
index: number
|
||||
},
|
||||
virtualizer: StoryVirtualizer | null,
|
||||
) => {
|
||||
if (!item || !virtualizer)
|
||||
return
|
||||
|
||||
const isStart = index === 0
|
||||
const isEnd = index === virtualizer.options.count - 1
|
||||
const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd))
|
||||
|
||||
if (shouldScroll) {
|
||||
queueMicrotask(() => {
|
||||
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const providerOptions: Option[] = [
|
||||
{ value: 'openai', label: 'OpenAI', meta: 'GPT-5, GPT-4.1', icon: 'i-ri-openai-fill' },
|
||||
{ value: 'anthropic', label: 'Anthropic', meta: 'Claude Opus, Sonnet', icon: 'i-ri-sparkling-2-line' },
|
||||
{ value: 'google', label: 'Google', meta: 'Gemini 2.5', icon: 'i-ri-google-fill' },
|
||||
{ value: 'azure-openai', label: 'Azure OpenAI', meta: 'Enterprise workspace', icon: 'i-ri-microsoft-fill' },
|
||||
{ value: 'localai', label: 'LocalAI', meta: 'Self-hosted endpoint', icon: 'i-ri-server-line', disabled: true },
|
||||
]
|
||||
|
||||
const dataSourceOptions: Option[] = [
|
||||
{ value: 'knowledge-base', label: 'Knowledge Base', meta: 'Vector index', icon: 'i-ri-database-2-line' },
|
||||
{ value: 'notion', label: 'Notion', meta: 'Synced pages', icon: 'i-ri-notion-fill' },
|
||||
{ value: 'website', label: 'Website crawler', meta: 'Public URLs', icon: 'i-ri-global-line' },
|
||||
{ value: 's3', label: 'S3 bucket', meta: 'Private files', icon: 'i-ri-cloud-line' },
|
||||
{ value: 'slack', label: 'Slack', meta: 'Channel history', icon: 'i-ri-slack-fill' },
|
||||
]
|
||||
|
||||
const reviewerOptions: Option[] = [
|
||||
{ value: 'maya', label: 'Maya Chen', meta: 'Product owner' },
|
||||
{ value: 'liam', label: 'Liam Brooks', meta: 'Prompt engineer' },
|
||||
{ value: 'nora', label: 'Nora Park', meta: 'Data steward' },
|
||||
{ value: 'owen', label: 'Owen Reed', meta: 'Security reviewer' },
|
||||
{ value: 'yuki', label: 'Yuki Tanaka', meta: 'ML engineer' },
|
||||
]
|
||||
|
||||
const toolGroups: OptionGroup[] = [
|
||||
{
|
||||
label: 'Retrieval',
|
||||
items: [
|
||||
{ value: 'dataset-search', label: 'Dataset search', meta: 'Search workspace knowledge', icon: 'i-ri-search-eye-line' },
|
||||
{ value: 'web-scraper', label: 'Web scraper', meta: 'Fetch public pages', icon: 'i-ri-global-line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
items: [
|
||||
{ value: 'http-request', label: 'HTTP request', meta: 'Call external APIs', icon: 'i-ri-terminal-box-line' },
|
||||
{ value: 'code-runner', label: 'Code runner', meta: 'Execute sandboxed scripts', icon: 'i-ri-code-s-slash-line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ value: 'human-review', label: 'Human review', meta: 'Assign approval task', icon: 'i-ri-user-voice-line' },
|
||||
{ value: 'audit-log', label: 'Audit log', meta: 'Record workflow events', icon: 'i-ri-file-list-3-line' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const tagOptions: Option[] = [
|
||||
{ value: 'rag', label: 'RAG' },
|
||||
{ value: 'agent', label: 'Agent' },
|
||||
{ value: 'production', label: 'Production' },
|
||||
{ value: 'evaluation', label: 'Evaluation' },
|
||||
{ value: 'finance', label: 'Finance' },
|
||||
{ value: 'support', label: 'Support' },
|
||||
]
|
||||
|
||||
const directoryOptions: Option[] = [
|
||||
{ value: 'maya-chen', label: 'Maya Chen', meta: 'Product owner · maya@example.com', icon: 'i-ri-user-3-line' },
|
||||
{ value: 'liam-brooks', label: 'Liam Brooks', meta: 'Prompt engineer · liam@example.com', icon: 'i-ri-user-3-line' },
|
||||
{ value: 'nora-park', label: 'Nora Park', meta: 'Data steward · nora@example.com', icon: 'i-ri-user-3-line' },
|
||||
{ value: 'owen-reed', label: 'Owen Reed', meta: 'Security reviewer · owen@example.com', icon: 'i-ri-shield-user-line' },
|
||||
{ value: 'yuki-tanaka', label: 'Yuki Tanaka', meta: 'ML engineer · yuki@example.com', icon: 'i-ri-user-3-line' },
|
||||
{ value: 'ava-martin', label: 'Ava Martin', meta: 'Support lead · ava@example.com', icon: 'i-ri-customer-service-2-line' },
|
||||
]
|
||||
|
||||
const emptyOptions: Option[] = [
|
||||
{ value: 'billing', label: 'Billing connector' },
|
||||
{ value: 'zendesk', label: 'Zendesk' },
|
||||
{ value: 'github', label: 'GitHub issues' },
|
||||
]
|
||||
|
||||
const modelCatalogOptions: Option[] = Array.from({ length: 1000 }, (_, index) => {
|
||||
const provider = ['OpenAI', 'Anthropic', 'Google', 'Mistral', 'DeepSeek'][index % 5]!
|
||||
const family = ['chat', 'reasoning', 'vision', 'embedding'][index % 4]!
|
||||
const number = new Intl.NumberFormat('en-US', {
|
||||
minimumIntegerDigits: 4,
|
||||
}).format(index + 1)
|
||||
|
||||
return {
|
||||
value: `model-${index + 1}`,
|
||||
label: `${provider} ${family} ${number}`,
|
||||
meta: `${provider} provider · ${family}`,
|
||||
icon: family === 'embedding'
|
||||
? 'i-ri-vector-triangle'
|
||||
: family === 'vision'
|
||||
? 'i-ri-image-circle-line'
|
||||
: family === 'reasoning'
|
||||
? 'i-ri-brain-line'
|
||||
: 'i-ri-chat-1-line',
|
||||
}
|
||||
})
|
||||
|
||||
const sizeOptions: Option[] = providerOptions.slice(0, 3)
|
||||
const defaultProvider = providerOptions[0]!
|
||||
const disabledProvider = providerOptions[1]!
|
||||
const defaultDataSource = dataSourceOptions[0]!
|
||||
const defaultPopupDataSource = dataSourceOptions[1]!
|
||||
const readOnlyDataSource = dataSourceOptions[2]!
|
||||
const defaultTool = toolGroups[0]!.items[0]!
|
||||
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[2]!]
|
||||
const defaultTag = tagOptions[2]!
|
||||
|
||||
const renderOptionItem = (option: Option, index?: number) => (
|
||||
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled}>
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0">
|
||||
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-text-secondary system-sm-medium">{option.label}</span>
|
||||
{option.meta && <span className="block truncate text-text-tertiary system-xs-regular">{option.meta}</span>}
|
||||
</span>
|
||||
</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
)
|
||||
|
||||
const renderSimpleOptionItem = (option: Option, index?: number) => (
|
||||
<ComboboxItem key={option.value} value={option} index={index}>
|
||||
<ComboboxItemText>{option.label}</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
)
|
||||
|
||||
const PopupSearchInput = ({
|
||||
label,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
placeholder: string
|
||||
}) => (
|
||||
<ComboboxInputGroup className="mb-1 border-divider-subtle bg-components-input-bg-normal">
|
||||
<span aria-hidden className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
|
||||
<ComboboxInput aria-label={label} placeholder={`${placeholder}…`} className="pl-2" />
|
||||
<ComboboxClear />
|
||||
</ComboboxInputGroup>
|
||||
)
|
||||
|
||||
const GroupedToolList = () => {
|
||||
const groups = useComboboxFilteredItems<OptionGroup>()
|
||||
|
||||
return (
|
||||
<ComboboxList className="p-0">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<ComboboxGroup key={group.label} items={group.items}>
|
||||
{groupIndex > 0 && <ComboboxSeparator />}
|
||||
<ComboboxGroupLabel>{group.label}</ComboboxGroupLabel>
|
||||
<ComboboxCollection>
|
||||
{(option: Option) => renderOptionItem(option)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
))}
|
||||
</ComboboxList>
|
||||
)
|
||||
}
|
||||
|
||||
const VirtualizedModelList = ({
|
||||
virtualizerRef,
|
||||
}: {
|
||||
virtualizerRef: RefObject<StoryVirtualizer | null>
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const filteredItems = useComboboxFilteredItems<Option>()
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 42,
|
||||
overscan: 6,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
virtualizerRef.current = virtualizer
|
||||
|
||||
return () => {
|
||||
virtualizerRef.current = null
|
||||
}
|
||||
}, [virtualizer, virtualizerRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[min(22rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain outline-hidden"
|
||||
>
|
||||
<ComboboxList
|
||||
className="relative max-h-none overflow-visible p-0"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const option = filteredItems[virtualItem.index]
|
||||
|
||||
if (!option)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderOptionItem(option, virtualItem.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ComboboxList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FilteredModelStatus = () => {
|
||||
const filteredItems = useComboboxFilteredItems<Option>()
|
||||
|
||||
return (
|
||||
<ComboboxStatus className="border-y border-divider-subtle px-2 py-1 text-text-quaternary tabular-nums">
|
||||
{filteredItems.length}
|
||||
{' '}
|
||||
matching models
|
||||
</ComboboxStatus>
|
||||
)
|
||||
}
|
||||
|
||||
const VirtualizedLongListDemo = () => {
|
||||
const [value, setValue] = useState<Option | null>(modelCatalogOptions[137]!)
|
||||
const virtualizerRef = useRef<StoryVirtualizer | null>(null)
|
||||
|
||||
return (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox
|
||||
items={modelCatalogOptions}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
virtualized
|
||||
autoHighlight
|
||||
onItemHighlighted={(item, details) => {
|
||||
scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
|
||||
}}
|
||||
>
|
||||
<ComboboxLabel>Model catalog</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Model catalog">
|
||||
<ComboboxValue placeholder="Select model" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="w-[440px] p-1">
|
||||
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
|
||||
<FilteredModelStatus />
|
||||
<VirtualizedModelList virtualizerRef={virtualizerRef} />
|
||||
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AsyncDirectoryDemo = () => {
|
||||
const [inputValue, setInputValue] = useState('ma')
|
||||
const [value, setValue] = useState<Option | null>(null)
|
||||
const [items, setItems] = useState(directoryOptions.slice(0, 3))
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const timeout = window.setTimeout(() => {
|
||||
const query = inputValue.trim().toLowerCase()
|
||||
setItems(
|
||||
query
|
||||
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
|
||||
: directoryOptions.slice(0, 5),
|
||||
)
|
||||
setLoading(false)
|
||||
}, 450)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [inputValue])
|
||||
|
||||
return (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox
|
||||
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
autoHighlight
|
||||
>
|
||||
<label className={nativeFieldLabelClassName}>
|
||||
Owner
|
||||
<ComboboxInputGroup className="mt-1">
|
||||
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
|
||||
<ComboboxInput placeholder="Search owners…" className="pl-2" />
|
||||
<ComboboxClear />
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
</label>
|
||||
<ComboboxContent popupClassName="w-[420px]">
|
||||
<ComboboxStatus className="border-b border-divider-subtle">
|
||||
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
|
||||
</ComboboxStatus>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Combobox',
|
||||
component: Combobox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound combobox built on Base UI Combobox for searchable predefined selections. Compose triggers, inputs, lists, groups, status, empty states, and chips without importing Base UI primitives directly.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Combobox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const SelectLikeDefault: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={providerOptions} defaultValue={defaultProvider} autoHighlight>
|
||||
<ComboboxLabel>Model provider</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Model provider">
|
||||
<ComboboxValue placeholder="Select provider" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label="Search model providers" placeholder="Search providers" />
|
||||
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const PopupInputSearchableSelect: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource} autoHighlight>
|
||||
<ComboboxLabel>Data source</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Data source">
|
||||
<ComboboxValue placeholder="Choose source" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
|
||||
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const AsyncSearchSingle: Story = {
|
||||
render: () => <AsyncDirectoryDemo />,
|
||||
}
|
||||
|
||||
export const InputGroupSearchable: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
|
||||
<label className={nativeFieldLabelClassName}>
|
||||
Connect source
|
||||
<ComboboxInputGroup className="mt-1">
|
||||
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
|
||||
<ComboboxInput placeholder="Search data sources…" className="pl-2" />
|
||||
<ComboboxClear />
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
</label>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
{(['small', 'medium', 'large'] as const).map(size => (
|
||||
<Combobox key={size} items={sizeOptions} defaultValue={defaultProvider} autoHighlight>
|
||||
<ComboboxTrigger aria-label={`${size} model provider`} size={size}>
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label={`Search ${size} model providers`} placeholder="Search providers" />
|
||||
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Grouped: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={toolGroups} defaultValue={defaultTool} autoHighlight>
|
||||
<ComboboxLabel>Workflow tool</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Workflow tool">
|
||||
<ComboboxValue placeholder="Select tool" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
|
||||
<GroupedToolList />
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const MultipleChipsDemo = () => {
|
||||
const [value, setValue] = useState<Option[]>(defaultReviewers)
|
||||
|
||||
return (
|
||||
<div className={wideFieldWidth}>
|
||||
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue} autoHighlight>
|
||||
<label className={nativeFieldLabelClassName}>
|
||||
Reviewers
|
||||
<ComboboxInputGroup className="mt-1 h-auto min-h-8 flex-nowrap py-1">
|
||||
<ComboboxValue>
|
||||
{(selectedValue: Option[]) => (
|
||||
<>
|
||||
<ComboboxChips className="flex-nowrap">
|
||||
{selectedValue.map(item => (
|
||||
<ComboboxChip key={item.value}>
|
||||
<span className="max-w-32 truncate">{item.label}</span>
|
||||
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
|
||||
</ComboboxChip>
|
||||
))}
|
||||
</ComboboxChips>
|
||||
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-16 px-2" />
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxClear />
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
</label>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleChips: Story = {
|
||||
render: () => <MultipleChipsDemo />,
|
||||
}
|
||||
|
||||
export const VirtualizedLongList: Story = {
|
||||
render: () => <VirtualizedLongListDemo />,
|
||||
}
|
||||
|
||||
export const EmptyAndStatus: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={emptyOptions} defaultInputValue="salesforce" autoHighlight>
|
||||
<label className={nativeFieldLabelClassName}>
|
||||
Connector
|
||||
<ComboboxInputGroup className="mt-1">
|
||||
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
|
||||
<ComboboxInput placeholder="Search connectors…" className="pl-2" />
|
||||
<ComboboxClear />
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
</label>
|
||||
<ComboboxContent>
|
||||
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
|
||||
<ComboboxEmpty>No connectors found</ComboboxEmpty>
|
||||
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DisabledAndReadOnly: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
|
||||
<ComboboxLabel>Disabled provider</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Disabled model provider">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
|
||||
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
|
||||
<label className={nativeFieldLabelClassName}>
|
||||
Read-only source
|
||||
<ComboboxInputGroup className="mt-1">
|
||||
<ComboboxInput placeholder="Read-only data source…" />
|
||||
<ComboboxClear />
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
</label>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const ControlledDemo = () => {
|
||||
const [value, setValue] = useState<Option | null>(defaultTag)
|
||||
|
||||
return (
|
||||
<div className="flex w-80 flex-col items-start gap-3">
|
||||
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
|
||||
<ComboboxLabel>Default app tag</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Default app tag">
|
||||
<ComboboxValue placeholder="Select tag" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="p-1">
|
||||
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
|
||||
<ComboboxList className="p-0">{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 text-text-tertiary system-xs-regular">
|
||||
Selected:
|
||||
{' '}
|
||||
{value?.label ?? 'None'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => <ControlledDemo />,
|
||||
}
|
||||
497
packages/dify-ui/src/combobox/index.tsx
Normal file
497
packages/dify-ui/src/combobox/index.tsx
Normal file
@ -0,0 +1,497 @@
|
||||
'use client'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import type { Placement } from '../placement'
|
||||
import { Combobox as BaseCombobox } from '@base-ui/react/combobox'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
import {
|
||||
overlayIndicatorClassName,
|
||||
overlayLabelClassName,
|
||||
overlayPopupAnimationClassName,
|
||||
overlaySeparatorClassName,
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
export const Combobox = BaseCombobox.Root
|
||||
export const ComboboxValue = BaseCombobox.Value
|
||||
export const ComboboxGroup = BaseCombobox.Group
|
||||
export const ComboboxCollection = BaseCombobox.Collection
|
||||
export const ComboboxRow = BaseCombobox.Row
|
||||
export const useComboboxFilter = BaseCombobox.useFilter
|
||||
export const useComboboxFilteredItems = BaseCombobox.useFilteredItems
|
||||
|
||||
export type ComboboxRootProps<Value, Multiple extends boolean | undefined = false>
|
||||
= BaseCombobox.Root.Props<Value, Multiple>
|
||||
export type ComboboxRootChangeEventDetails = BaseCombobox.Root.ChangeEventDetails
|
||||
export type ComboboxRootHighlightEventDetails = BaseCombobox.Root.HighlightEventDetails
|
||||
|
||||
const comboboxPopupClassName = [
|
||||
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
|
||||
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
|
||||
]
|
||||
|
||||
const comboboxListClassName = [
|
||||
'max-h-[min(20rem,var(--available-height))] overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden scroll-py-1',
|
||||
'data-empty:max-h-none data-empty:p-0',
|
||||
]
|
||||
|
||||
const comboboxItemClassName = [
|
||||
'mx-1 grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt hover:text-text-primary',
|
||||
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
|
||||
'data-selected:text-text-primary',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
const comboboxTriggerVariants = cva(
|
||||
[
|
||||
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
'data-disabled:data-placeholder:text-components-input-text-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'h-6 gap-px rounded-md px-2 py-1 system-xs-regular',
|
||||
medium: 'h-8 gap-0.5 rounded-lg px-3 py-2 system-sm-regular',
|
||||
large: 'h-9 gap-0.5 rounded-[10px] px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ComboboxSize = NonNullable<VariantProps<typeof comboboxTriggerVariants>['size']>
|
||||
|
||||
type ComboboxTriggerProps
|
||||
= Omit<BaseCombobox.Trigger.Props, 'className'>
|
||||
& VariantProps<typeof comboboxTriggerVariants>
|
||||
& {
|
||||
className?: string
|
||||
icon?: ReactNode | false
|
||||
}
|
||||
|
||||
export function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
icon,
|
||||
size,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ComboboxTriggerProps) {
|
||||
return (
|
||||
<BaseCombobox.Trigger
|
||||
type={type}
|
||||
className={cn(comboboxTriggerVariants({ size, className }))}
|
||||
{...props}
|
||||
>
|
||||
<span className="min-w-0 grow truncate">
|
||||
{children}
|
||||
</span>
|
||||
{icon !== false && (
|
||||
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
|
||||
{icon ?? <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />}
|
||||
</BaseCombobox.Icon>
|
||||
)}
|
||||
</BaseCombobox.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const comboboxInputGroupVariants = cva(
|
||||
[
|
||||
'group/combobox flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
|
||||
'data-open:border-components-input-border-active data-open:bg-components-input-bg-active',
|
||||
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
|
||||
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
|
||||
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'min-h-6 rounded-md',
|
||||
medium: 'min-h-8 rounded-lg',
|
||||
large: 'min-h-9 rounded-[10px]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ComboboxInputGroupProps
|
||||
= BaseCombobox.InputGroup.Props
|
||||
& VariantProps<typeof comboboxInputGroupVariants>
|
||||
|
||||
export function ComboboxInputGroup({
|
||||
className,
|
||||
size = 'medium',
|
||||
...props
|
||||
}: ComboboxInputGroupProps) {
|
||||
return (
|
||||
<BaseCombobox.InputGroup
|
||||
className={cn(comboboxInputGroupVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const comboboxInputVariants = cva(
|
||||
[
|
||||
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-hidden',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
|
||||
'data-readonly:cursor-default',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'px-2 py-1 system-xs-regular',
|
||||
medium: 'px-3 py-[7px] system-sm-regular',
|
||||
large: 'px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ComboboxInputProps
|
||||
= Omit<BaseCombobox.Input.Props, 'size'>
|
||||
& VariantProps<typeof comboboxInputVariants>
|
||||
|
||||
export function ComboboxInput({
|
||||
className,
|
||||
size = 'medium',
|
||||
type = 'text',
|
||||
autoComplete = 'off',
|
||||
...props
|
||||
}: ComboboxInputProps) {
|
||||
return (
|
||||
<BaseCombobox.Input
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
className={cn(comboboxInputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const comboboxControlVariants = cva(
|
||||
[
|
||||
'flex shrink-0 touch-manipulation items-center justify-center rounded-md text-text-tertiary outline-hidden transition-colors',
|
||||
'hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
|
||||
'group-data-disabled/combobox:cursor-not-allowed group-data-disabled/combobox:hover:bg-transparent group-data-disabled/combobox:focus-visible:bg-transparent group-data-disabled/combobox:focus-visible:ring-0',
|
||||
'group-data-readonly/combobox:hidden',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'mr-1 size-4',
|
||||
medium: 'mr-1.5 size-5',
|
||||
large: 'mr-2 size-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ComboboxClearProps
|
||||
= Omit<BaseCombobox.Clear.Props, 'className'>
|
||||
& VariantProps<typeof comboboxControlVariants>
|
||||
& { className?: string }
|
||||
|
||||
export function ComboboxClear({
|
||||
className,
|
||||
children,
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
...props
|
||||
}: ComboboxClearProps) {
|
||||
return (
|
||||
<BaseCombobox.Clear
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear combobox')}
|
||||
className={cn(
|
||||
comboboxControlVariants({ size }),
|
||||
'data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-close-line size-4" aria-hidden="true" />}
|
||||
</BaseCombobox.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
export type ComboboxInputTriggerProps
|
||||
= Omit<BaseCombobox.Trigger.Props, 'className'>
|
||||
& VariantProps<typeof comboboxControlVariants>
|
||||
& { className?: string }
|
||||
|
||||
export function ComboboxInputTrigger({
|
||||
className,
|
||||
children,
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
...props
|
||||
}: ComboboxInputTriggerProps) {
|
||||
return (
|
||||
<BaseCombobox.Trigger
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open combobox options')}
|
||||
className={cn(comboboxControlVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
|
||||
</BaseCombobox.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxIcon({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BaseCombobox.Icon.Props) {
|
||||
return (
|
||||
<BaseCombobox.Icon
|
||||
className={cn('flex shrink-0 items-center text-text-tertiary', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
|
||||
</BaseCombobox.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
type ComboboxContentProps = {
|
||||
children: ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
portalProps?: Omit<BaseCombobox.Portal.Props, 'children'>
|
||||
positionerProps?: Omit<
|
||||
BaseCombobox.Positioner.Props,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
BaseCombobox.Popup.Props,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function ComboboxContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
portalProps,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ComboboxContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseCombobox.Portal {...portalProps}>
|
||||
<BaseCombobox.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-1002 outline-hidden', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseCombobox.Popup
|
||||
className={cn(
|
||||
comboboxPopupClassName,
|
||||
overlayPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Popup>
|
||||
</BaseCombobox.Positioner>
|
||||
</BaseCombobox.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxList({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.List.Props) {
|
||||
return (
|
||||
<BaseCombobox.List
|
||||
className={cn(comboboxListClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxItem({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Item.Props) {
|
||||
return (
|
||||
<BaseCombobox.Item
|
||||
className={cn(comboboxItemClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ComboboxItemTextProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export function ComboboxItemText({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxItemTextProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxItemIndicator({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Omit<BaseCombobox.ItemIndicator.Props, 'children'> & { children?: ReactNode }) {
|
||||
return (
|
||||
<BaseCombobox.ItemIndicator
|
||||
className={cn(overlayIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-check-line h-4 w-4" aria-hidden="true" />}
|
||||
</BaseCombobox.ItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Label.Props) {
|
||||
return (
|
||||
<BaseCombobox.Label
|
||||
className={cn('mb-1 block text-text-secondary system-sm-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.GroupLabel.Props) {
|
||||
return (
|
||||
<BaseCombobox.GroupLabel
|
||||
className={cn(overlayLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Separator.Props) {
|
||||
return (
|
||||
<BaseCombobox.Separator
|
||||
className={cn(overlaySeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxEmpty({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Empty.Props) {
|
||||
return (
|
||||
<BaseCombobox.Empty
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxStatus({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Status.Props) {
|
||||
return (
|
||||
<BaseCombobox.Status
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Chips.Props) {
|
||||
return (
|
||||
<BaseCombobox.Chips
|
||||
className={cn('flex w-full min-w-0 flex-wrap items-center gap-1 px-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxChip({
|
||||
className,
|
||||
...props
|
||||
}: BaseCombobox.Chip.Props) {
|
||||
return (
|
||||
<BaseCombobox.Chip
|
||||
className={cn('inline-flex max-w-full min-w-0 items-center gap-1 rounded-md bg-state-base-hover px-1.5 py-0.5 text-text-secondary system-xs-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxChipRemove({
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: BaseCombobox.ChipRemove.Props) {
|
||||
return (
|
||||
<BaseCombobox.ChipRemove
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Remove selected item')}
|
||||
className={cn('flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="i-ri-close-line size-3" aria-hidden="true" />}
|
||||
</BaseCombobox.ChipRemove>
|
||||
)
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -18,6 +18,8 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
|
||||
- `@langgenius/dify-ui/popover`
|
||||
- `@langgenius/dify-ui/dialog`
|
||||
- `@langgenius/dify-ui/alert-dialog`
|
||||
- `@langgenius/dify-ui/autocomplete`
|
||||
- `@langgenius/dify-ui/combobox`
|
||||
- `@langgenius/dify-ui/select`
|
||||
- `@langgenius/dify-ui/toast`
|
||||
- Tracking issue: <https://github.com/langgenius/dify/issues/32767>
|
||||
@ -56,13 +58,13 @@ All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index val
|
||||
During the migration period, legacy and new overlays coexist. Legacy overlays
|
||||
portal to `document.body` with explicit z-index values:
|
||||
|
||||
| Layer | z-index | Components |
|
||||
| --------------------------------- | -------------- | -------------------------------------------------------- |
|
||||
| Legacy Drawer | `z-30` | `base/drawer` |
|
||||
| Legacy Modal | `z-60` | `base/modal` (default) |
|
||||
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
|
||||
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Tooltip, etc.) |
|
||||
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
|
||||
| Layer | z-index | Components |
|
||||
| --------------------------------- | -------------- | -------------------------------------------------------------------------------- |
|
||||
| Legacy Drawer | `z-30` | `base/drawer` |
|
||||
| Legacy Modal | `z-60` | `base/modal` (default) |
|
||||
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
|
||||
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
|
||||
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
|
||||
|
||||
`z-1002` sits above all common legacy overlays, so new primitives always
|
||||
render on top without needing per-call-site z-index hacks. Among themselves,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user