diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 428ce43229d..f3a13f000aa 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -6,7 +6,6 @@ import { ComboboxChipRemove, ComboboxChips, ComboboxClear, - ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, @@ -18,6 +17,9 @@ import { ComboboxItemText, ComboboxLabel, ComboboxList, + ComboboxPopup, + ComboboxPortal, + ComboboxPositioner, ComboboxSeparator, ComboboxStatus, ComboboxTrigger, @@ -41,31 +43,27 @@ const renderSelectLikeCombobox = ({ {children ?? ( Resource type - + - - 2 options - - - Workflow - - - - Dataset - - - No options - + + + + + 2 options + + + Workflow + + + + Dataset + + + No options + + + )} , @@ -87,14 +85,18 @@ const renderInputCombobox = ({ - - - - Workflow - - - - + + + + + + Workflow + + + + + + )} , @@ -188,16 +190,19 @@ describe('Combobox wrappers', () => { }) }) - describe('Content and options', () => { + describe('Popup anatomy and options', () => { it('should use default overlay placement', async () => { const screen = await renderSelectLikeCombobox({ open: true }) await expect - .element(screen.getByRole('group', { name: 'combobox positioner' })) + .element(screen.getByTestId('combobox-positioner')) .toHaveAttribute('data-side', 'bottom') await expect - .element(screen.getByRole('group', { name: 'combobox positioner' })) + .element(screen.getByTestId('combobox-positioner')) .toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('dialog', { name: 'Choose a resource' })) + .toBeInTheDocument() }) it('should apply custom placement side and passthrough popup props', async () => { @@ -207,52 +212,100 @@ describe('Combobox wrappers', () => { - - - - Workflow - - - + + + + + + + Workflow + + + + + , ) - await screen.getByRole('dialog', { name: 'combobox popup' }).click() + await screen.getByTestId('combobox-popup').click() await expect - .element(screen.getByRole('group', { name: 'combobox positioner' })) + .element(screen.getByTestId('combobox-positioner')) .toHaveAttribute('data-side', 'top') expect(onPopupClick).toHaveBeenCalledTimes(1) }) + it('names the dialog popup when the input is composed inside it', async () => { + const screen = await renderWithSafeViewport( + + Resource type + Choose resource + + + + + + Workflow + + + + + , + ) + + await expect + .element(screen.getByRole('dialog', { name: 'Choose a resource' })) + .toBeInTheDocument() + await expect + .element(screen.getByRole('combobox', { name: 'Filter resources' })) + .toBeInTheDocument() + }) + + it('keeps an empty live region mounted without visible spacing', async () => { + const screen = await renderWithSafeViewport( + + + {null} + , + ) + + const status = screen.getByRole('status') + + expect(status.element().getBoundingClientRect().height).toBe(0) + }) + it('should forward custom classes to group label separator item text and indicator', async () => { const screen = await renderWithSafeViewport( - - - - Resources - - - Workflow - - - - - + + + + + + + Resources + + + Workflow + + + + + + + , ) @@ -273,16 +326,20 @@ describe('Combobox wrappers', () => { - - > - {(item) => ( - - {item} - - - )} - - + + + + > + {(item) => ( + + {item} + + + )} + + + + , ) diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index c7e9c590105..de295b9d1a2 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual' import * as React from 'react' -import { expect } from 'storybook/test' +import { expect, waitFor, within } from 'storybook/test' import { Combobox, ComboboxChip, @@ -10,7 +10,6 @@ import { ComboboxChips, ComboboxClear, ComboboxCollection, - ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, @@ -22,6 +21,9 @@ import { ComboboxItemText, ComboboxLabel, ComboboxList, + ComboboxPopup, + ComboboxPortal, + ComboboxPositioner, ComboboxSeparator, ComboboxStatus, ComboboxTrigger, @@ -29,8 +31,17 @@ import { useComboboxFilter, useComboboxFilteredItems, } from '.' +import { Button } from '../button' import { cn } from '../cn' import { Field, FieldDescription, FieldLabel } from '../field' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '../popover' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '../scroll-area' type Option = { value: string @@ -256,7 +267,6 @@ const defaultPopupDataSource = dataSourceOptions[1]! const readOnlyDataSource = dataSourceOptions[2]! const defaultTool = toolGroups[0]!.items[0]! const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!] -const defaultAsyncReviewers = [reviewerOptions[1]!] const defaultTag = tagOptions[2]! const getOptionLabel = (option: Option) => option.label @@ -313,11 +323,13 @@ const renderSimpleOptionItem = (option: Option) => ( ) // Only virtualized items receive an explicit index; ordinary lists must let Base UI register items by DOM order for keyboard navigation. -const renderVirtualizedOptionItem = (option: Option, index: number) => ( +const renderVirtualizedOptionItem = (option: Option, index: number, itemCount: number) => ( @@ -348,7 +360,6 @@ const PopupSearchInput = ({ label, placeholder }: { label: string; placeholder: placeholder={`${placeholder}…`} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> - ) @@ -392,36 +403,46 @@ const VirtualizedModelList = ({ }, [virtualizer, virtualizerRef]) return ( -
- + - {virtualizer.getVirtualItems().map((virtualItem) => { - const option = filteredItems[virtualItem.index] + + + {virtualizer.getVirtualItems().map((virtualItem) => { + const option = filteredItems[virtualItem.index] - if (!option) return null + if (!option) return null - return ( -
- {renderVirtualizedOptionItem(option, virtualItem.index)} -
- ) - })} -
-
+ return ( +
+ {renderVirtualizedOptionItem(option, virtualItem.index, filteredItems.length)} +
+ ) + })} + + + + + + + ) } @@ -451,15 +472,19 @@ const VirtualizedLongListDemo = () => { }} > Model catalog - + - - - - - No model matches this filter - + + + + + + + No model matches this filter + + + ) @@ -555,153 +580,70 @@ const AsyncDirectoryDemo = () => { - - {status} - >{renderOptionItem} - {emptyMessage} - + + + + {status} + >{renderOptionItem} + {emptyMessage} + + + ) } -const AsyncReviewerDemo = () => { - const [searchResults, setSearchResults] = React.useState([]) - const [selectedValues, setSelectedValues] = React.useState(defaultAsyncReviewers) - const [searchValue, setSearchValue] = React.useState('') - const [error, setError] = React.useState(null) - const [blockStartStatus, setBlockStartStatus] = React.useState(false) - const [isPending, startTransition] = React.useTransition() - const { contains } = useComboboxFilter() - const abortControllerRef = React.useRef(null) - const selectedValuesRef = React.useRef(defaultAsyncReviewers) - const trimmedSearchValue = searchValue.trim() - - const items = React.useMemo(() => { - if (selectedValues.length === 0) return searchResults - - const merged = [...searchResults] - - selectedValues.forEach((selected) => { - if (!searchResults.some((result) => result.value === selected.value)) merged.push(selected) - }) - - return merged - }, [searchResults, selectedValues]) - - const status = (() => { - if (isPending) return 'Searching reviewer matches…' - - if (error) return error - - if (trimmedSearchValue === '' && !blockStartStatus) - return selectedValues.length > 0 ? null : 'Start typing to search reviewers…' - - if (searchResults.length === 0 && !blockStartStatus) - return `No matches for "${trimmedSearchValue}".` - - return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found` - })() - - const emptyMessage = - trimmedSearchValue === '' || isPending || searchResults.length > 0 || error - ? null - : 'Try a different reviewer search.' +const InlinePopoverDemo = () => { + const [open, setOpen] = React.useState(false) + const [value, setValue] = React.useState