fix(web): align combobox interaction semantics (#40826)

This commit is contained in:
yyh 2026-08-17 02:13:08 +00:00 committed by GitHub
parent 141cbf9832
commit facb60abfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1915 additions and 1455 deletions

View File

@ -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 ?? (
<React.Fragment>
<ComboboxLabel data-testid="label">Resource type</ComboboxLabel>
<ComboboxTrigger aria-label="Resource type" data-testid="trigger">
<ComboboxTrigger 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>
<ComboboxPortal>
<ComboboxPositioner data-testid="combobox-positioner">
<ComboboxPopup aria-label="Choose a resource" data-testid="combobox-popup">
<ComboboxInput aria-label="Filter resources" />
<ComboboxStatus data-testid="status">2 options</ComboboxStatus>
<ComboboxList 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>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</React.Fragment>
)}
</Combobox>,
@ -87,14 +85,18 @@ const renderInputCombobox = ({
<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>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup data-testid="combobox-popup">
<ComboboxList>
<ComboboxItem value="workflow">
<ComboboxItemText>Workflow</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</React.Fragment>
)}
</Combobox>,
@ -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', () => {
<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>
<ComboboxPortal>
<ComboboxPositioner
placement="top-end"
sideOffset={12}
alignOffset={6}
data-testid="combobox-positioner"
>
<ComboboxPopup
aria-label="Choose a resource"
data-testid="combobox-popup"
onClick={onPopupClick}
>
<ComboboxInput aria-label="Filter resources" />
<ComboboxList>
<ComboboxItem value="workflow">
<ComboboxItemText>Workflow</ComboboxItemText>
</ComboboxItem>
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)
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(
<Combobox open items={['workflow']}>
<ComboboxLabel>Resource type</ComboboxLabel>
<ComboboxTrigger>Choose resource</ComboboxTrigger>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Choose a resource">
<ComboboxInput aria-label="Filter resources" />
<ComboboxList>
<ComboboxItem value="workflow">Workflow</ComboboxItem>
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)
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(
<Combobox items={[]}>
<ComboboxInput aria-label="Search resources" />
<ComboboxStatus>{null}</ComboboxStatus>
</Combobox>,
)
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(
<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>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Choose a resource">
<ComboboxInput aria-label="Filter resources" />
<ComboboxList 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>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)
@ -273,16 +326,20 @@ describe('Combobox wrappers', () => {
<ComboboxInputGroup>
<ComboboxInput aria-label="Search resources" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<string>>
{(item) => (
<ComboboxItem key={item} value={item}>
<ComboboxItemText>{item}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxList<string>>
{(item) => (
<ComboboxItem key={item} value={item}>
<ComboboxItemText>{item}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)

View File

@ -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) => (
<ComboboxItem
key={option.value}
value={option}
index={index}
aria-posinset={index + 1}
aria-setsize={itemCount}
disabled={option.disabled}
className="h-auto min-h-8 py-1.5"
>
@ -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"
/>
<ComboboxClear className="mr-0" />
</ComboboxInputGroup>
</div>
)
@ -392,36 +403,46 @@ const VirtualizedModelList = ({
}, [virtualizer, virtualizerRef])
return (
<div
ref={scrollRef}
className="max-h-[min(22rem,var(--available-height))] overflow-x-hidden overflow-y-auto overscroll-contain outline-hidden"
>
<ComboboxList
className="relative max-h-none overflow-visible p-0"
style={{
height: virtualizer.getTotalSize(),
}}
<ScrollArea className="relative overflow-hidden">
<ScrollAreaViewport
ref={scrollRef}
role="region"
aria-label="Model catalog"
className="max-h-[min(22rem,var(--available-height))] overscroll-contain"
style={{ overflowX: 'hidden' }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const option = filteredItems[virtualItem.index]
<ScrollAreaContent style={{ minWidth: 0 }}>
<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
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)`,
}}
>
{renderVirtualizedOptionItem(option, virtualItem.index)}
</div>
)
})}
</ComboboxList>
</div>
return (
<div
key={virtualItem.key}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderVirtualizedOptionItem(option, virtualItem.index, filteredItems.length)}
</div>
)
})}
</ComboboxList>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
)
}
@ -451,15 +472,19 @@ const VirtualizedLongListDemo = () => {
}}
>
<ComboboxLabel>Model catalog</ComboboxLabel>
<ComboboxTrigger aria-label="Model catalog">
<ComboboxTrigger>
<ComboboxValue placeholder="Select model" />
</ComboboxTrigger>
<ComboboxContent popupClassName="w-[440px]">
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
<FilteredModelStatus />
<VirtualizedModelList virtualizerRef={virtualizerRef} />
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup className="w-110" aria-label="Model catalog">
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
<FilteredModelStatus />
<VirtualizedModelList virtualizerRef={virtualizerRef} />
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
)
@ -555,153 +580,70 @@ const AsyncDirectoryDemo = () => {
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent
popupClassName="w-[420px]"
popupProps={{ 'aria-busy': isPending || undefined }}
>
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup className="w-105" aria-busy={isPending || undefined}>
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</Field>
)
}
const AsyncReviewerDemo = () => {
const [searchResults, setSearchResults] = React.useState<Option[]>([])
const [selectedValues, setSelectedValues] = React.useState<Option[]>(defaultAsyncReviewers)
const [searchValue, setSearchValue] = React.useState('')
const [error, setError] = React.useState<string | null>(null)
const [blockStartStatus, setBlockStartStatus] = React.useState(false)
const [isPending, startTransition] = React.useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = React.useRef<AbortController | null>(null)
const selectedValuesRef = React.useRef<Option[]>(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<Option | null>(null)
const [inputValue, setInputValue] = React.useState('')
return (
<Field name="asyncReviewers" className={fieldWidth}>
<FieldLabel>Async reviewers</FieldLabel>
<Combobox
items={items}
itemToStringLabel={getOptionLabel}
multiple
filter={null}
value={selectedValues}
onOpenChangeComplete={(open) => {
if (!open) {
setSearchResults(selectedValuesRef.current)
setBlockStartStatus(false)
}
}}
onValueChange={(nextSelectedValues) => {
selectedValuesRef.current = nextSelectedValues
setSelectedValues(nextSelectedValues)
setSearchValue('')
setError(null)
if (nextSelectedValues.length === 0) {
setSearchResults([])
setBlockStartStatus(false)
} else {
setBlockStartStatus(true)
}
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults(selectedValuesRef.current)
setError(null)
setBlockStartStatus(false)
return
}
if (reason === 'item-press') return
startTransition(async () => {
setError(null)
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
if (controller.signal.aborted) return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
<div className="flex w-80 flex-col items-start gap-3">
<Popover
open={open}
onOpenChange={setOpen}
onOpenChangeComplete={(nextOpen) => {
if (!nextOpen) setInputValue('')
}}
>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips>
<ComboboxValue<Option, true>>
{(selectedValue) => (
<React.Fragment>
{selectedValue?.map((item) => (
<ComboboxChip key={item.value} aria-label={item.label}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
<ComboboxInput
placeholder={selectedValue?.length ? '' : 'Search reviewers…'}
className="min-w-24 px-1 py-0.5"
/>
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
<ComboboxContent
popupClassName="w-[420px]"
popupProps={{ 'aria-busy': isPending || undefined }}
>
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent>
</Combobox>
<FieldDescription>
Selected reviewers stay available while async matches change.
</FieldDescription>
</Field>
<PopoverTrigger render={<Button variant="secondary" />}>Choose reviewer</PopoverTrigger>
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-80 p-0">
<PopoverTitle className="sr-only">Choose reviewer</PopoverTitle>
<Combobox
inline
open={open}
items={reviewerOptions}
value={value}
inputValue={inputValue}
itemToStringLabel={getOptionLabel}
onOpenChange={setOpen}
onValueChange={setValue}
onInputValueChange={setInputValue}
>
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
aria-label="Search reviewers"
placeholder="Search reviewers…"
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
</ComboboxInputGroup>
</div>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No reviewers found</ComboboxEmpty>
</Combobox>
</PopoverContent>
</Popover>
<span className="system-xs-regular text-text-tertiary">
Selected reviewer: {value?.label ?? 'None'}
</span>
</div>
)
}
@ -713,7 +655,7 @@ const meta = {
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.',
'Compound combobox built on Base UI Combobox for searchable predefined selections. Use an input as the trigger, place an input inside a named popup, or set `inline` when an external Popover owns the surface. Keep independent actions outside the listbox, keep Status mounted while changing its children, and use Clear only for selection clearing.',
},
},
},
@ -740,65 +682,97 @@ export const Default: Story = {
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
),
}
export const FormField: Story = {
render: () => (
<Field name="sourceConnector" className={fieldWidth}>
<FieldLabel>Connect source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource}>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
placeholder="Search data sources…"
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
<FieldDescription>Type to filter, then choose a remembered data source.</FieldDescription>
</Field>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const input = canvas.getByRole('combobox', { name: 'Connect source' })
const body = within(canvasElement.ownerDocument.body)
await expect(input).toHaveValue('Knowledge Base')
await userEvent.clear(input)
await userEvent.type(input, 'Notion')
await waitFor(async () => {
await expect(body.getByRole('option', { name: 'Notion' })).toBeVisible()
})
await userEvent.keyboard('{ArrowDown}{Enter}')
await expect(input).toHaveValue('Notion')
},
}
export const CompactTriggerWithPopupSearch: Story = {
export const TriggerWithPopupInput: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource}>
<ComboboxLabel>Data source</ComboboxLabel>
<ComboboxTrigger aria-label="Data source">
<ComboboxTrigger>
<ComboboxValue placeholder="Choose source" />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Data source">
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const trigger = canvas.getByRole('combobox', { name: 'Data source' })
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(trigger)
const popup = await body.findByRole('dialog', { name: 'Data source' })
const input = within(popup).getByRole('combobox', { name: 'Search data sources' })
await userEvent.type(input, 'Website')
await userEvent.click(within(popup).getByRole('option', { name: /Website crawler/ }))
await expect(trigger).toHaveTextContent('Website crawler')
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Data source' })).not.toBeInTheDocument()
})
},
}
export const AsyncSearchSingle: Story = {
export const InlineInPopover: Story = {
render: () => <InlinePopoverDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Choose reviewer' }))
const popover = await body.findByRole('dialog', { name: 'Choose reviewer' })
const input = within(popover).getByRole('combobox', { name: 'Search reviewers' })
await userEvent.type(input, 'Nora')
await userEvent.click(within(popover).getByRole('option', { name: /Nora Park/ }))
await expect(canvas.getByText('Selected reviewer: Nora Park')).toBeVisible()
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Choose reviewer' })).not.toBeInTheDocument()
})
await userEvent.click(canvas.getByRole('button', { name: 'Choose reviewer' }))
const reopenedPopover = await body.findByRole('dialog', { name: 'Choose reviewer' })
await expect(
within(reopenedPopover).getByRole('combobox', { name: 'Search reviewers' }),
).toHaveValue('')
},
}
export const AsyncSearch: Story = {
render: () => <AsyncDirectoryDemo />,
}
export const AsyncSearchMultiple: Story = {
render: () => <AsyncReviewerDemo />,
}
export const Sizes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
@ -815,9 +789,13 @@ export const Sizes: Story = {
<ComboboxClear size={size} className="mr-0.5" />
<ComboboxInputTrigger size={size} className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</Field>
))}
@ -830,13 +808,17 @@ export const Grouped: Story = {
<div className={fieldWidth}>
<Combobox items={toolGroups} defaultValue={defaultTool}>
<ComboboxLabel>Workflow tool</ComboboxLabel>
<ComboboxTrigger aria-label="Workflow tool">
<ComboboxTrigger>
<ComboboxValue placeholder="Select tool" />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
<GroupedToolList />
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Workflow tool">
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
<GroupedToolList />
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
),
@ -869,9 +851,13 @@ const MultipleChipsDemo = () => {
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
<FieldDescription>
Selected reviewers wrap inside the input instead of scrolling horizontally.
@ -897,7 +883,7 @@ export const VirtualizedLongList: Story = {
render: () => <VirtualizedLongListDemo />,
}
export const EmptyAndStatus: Story = {
export const Empty: Story = {
render: () => (
<Field name="connector" className={fieldWidth}>
<FieldLabel>Connector</FieldLabel>
@ -911,54 +897,66 @@ export const EmptyAndStatus: Story = {
placeholder="Search connectors…"
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
<ComboboxEmpty>No connectors found</ComboboxEmpty>
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxEmpty>No connectors found</ComboboxEmpty>
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</Field>
),
}
export const DisabledAndReadOnly: Story = {
export const Disabled: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Field name="disabledProvider" disabled>
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
<Field name="readOnlySource">
<FieldLabel>Read-only source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<ComboboxInput
placeholder="Read-only data source…"
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
<div className={fieldWidth}>
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger>
<ComboboxValue />
</ComboboxTrigger>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Disabled provider">
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
),
}
export const ReadOnly: Story = {
render: () => (
<Field name="readOnlySource" className={fieldWidth}>
<FieldLabel>Read-only source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<ComboboxInput
placeholder="Read-only data source…"
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup>
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</Field>
),
}
const ControlledDemo = () => {
const [value, setValue] = React.useState<Option | null>(defaultTag)
@ -967,13 +965,17 @@ const ControlledDemo = () => {
<div className="w-full">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxTrigger>
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Default app tag">
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 system-xs-regular text-text-tertiary">
@ -985,4 +987,18 @@ const ControlledDemo = () => {
export const Controlled: Story = {
render: () => <ControlledDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const trigger = canvas.getByRole('combobox', { name: 'Default app tag' })
const body = within(canvasElement.ownerDocument.body)
await expect(canvas.getByText('Selected: Production')).toBeVisible()
await userEvent.click(trigger)
await userEvent.click(await body.findByRole('option', { name: 'Finance' }))
await expect(trigger).toHaveTextContent('Finance')
await expect(canvas.getByText('Selected: Finance')).toBeVisible()
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Default app tag' })).not.toBeInTheDocument()
})
},
}

View File

@ -307,52 +307,46 @@ function ComboboxIcon({ className, children, ...props }: ComboboxIconProps) {
)
}
type ComboboxContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
const ComboboxPortal = BaseCombobox.Portal
type ComboboxPortalProps = BaseCombobox.Portal.Props
type ComboboxPositionerProps = Omit<
BaseCombobox.Positioner.Props,
'className' | 'side' | 'align'
> & {
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'>
placement?: Placement
}
function ComboboxContent({
children,
function ComboboxPositioner({
className,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
portalProps,
positionerProps,
popupProps,
}: ComboboxContentProps) {
...props
}: ComboboxPositionerProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseCombobox.Portal {...portalProps}>
<BaseCombobox.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BaseCombobox.Popup
className={cn(comboboxPopupClassName, floatingPopupAnimationClassName, popupClassName)}
{...popupProps}
>
{children}
</BaseCombobox.Popup>
</BaseCombobox.Positioner>
</BaseCombobox.Portal>
<BaseCombobox.Positioner
side={side}
align={align}
sideOffset={sideOffset}
className={cn('z-50 outline-hidden', className)}
{...props}
/>
)
}
type ComboboxPopupProps = Omit<BaseCombobox.Popup.Props, 'className'> & {
className?: string
}
function ComboboxPopup({ className, ...props }: ComboboxPopupProps) {
return (
<BaseCombobox.Popup
className={cn(comboboxPopupClassName, floatingPopupAnimationClassName, className)}
{...props}
/>
)
}
@ -453,7 +447,7 @@ type ComboboxStatusProps = Omit<BaseCombobox.Status.Props, 'className'> & {
function ComboboxStatus({ className, ...props }: ComboboxStatusProps) {
return (
<BaseCombobox.Status
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
className={cn('px-3 py-2 system-sm-regular text-text-tertiary empty:p-0', className)}
{...props}
/>
)
@ -522,7 +516,6 @@ export {
ComboboxChips,
ComboboxClear,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
@ -535,6 +528,9 @@ export {
ComboboxItemText,
ComboboxLabel,
ComboboxList,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxRow,
ComboboxSeparator,
ComboboxStatus,
@ -551,7 +547,6 @@ export type {
ComboboxChipsProps,
ComboboxClearProps,
ComboboxCollectionProps,
ComboboxContentProps,
ComboboxEmptyProps,
ComboboxGroupLabelProps,
ComboboxGroupProps,
@ -564,6 +559,9 @@ export type {
ComboboxItemTextProps,
ComboboxLabelProps,
ComboboxListProps,
ComboboxPopupProps,
ComboboxPortalProps,
ComboboxPositionerProps,
ComboboxProps,
ComboboxRowProps,
ComboboxSeparatorProps,

View File

@ -97,15 +97,30 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(screen.getByText('common.operation.add'))
expect(
screen.getByPlaceholderText(
'app.accessControlDialog.operateGroupAndMember.searchPlaceholder',
),
).toBeInTheDocument()
const searchLabel = 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder'
expect(screen.getByRole('dialog', { name: searchLabel })).toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: searchLabel })).toHaveFocus()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should keep group selection and expansion as separate keyboard actions', async () => {
const user = userEvent.setup()
render(<ControlledDialog />)
await user.click(screen.getByText('common.operation.add'))
const groupToggle = screen.getByRole('button', { name: /Group One/ })
const expandButton = screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.expand',
})
groupToggle.focus()
await user.tab()
expect(expandButton).toHaveFocus()
})
it('should allow expanding groups and report selected members', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
@ -119,8 +134,12 @@ describe('AddMemberOrGroupDialog', () => {
true,
)
await user.click(screen.getByRole('option', { name: /Member One/ }))
const memberToggle = screen.getByRole('button', { name: /Member One/ })
expect(memberToggle).toHaveAttribute('aria-pressed', 'false')
await user.click(memberToggle)
expect(onChange).toHaveBeenCalledWith({ groups: [], members: [baseMember] })
expect(memberToggle).toHaveAttribute('aria-pressed', 'true')
})
it('should show the empty state when no candidates are returned', async () => {

View File

@ -1,28 +1,26 @@
'use client'
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlSubjects } from '../specific-groups-or-members'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SearchInput } from '@/app/components/base/search-input'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import { SelectedGroupsBreadcrumb } from './breadcrumb'
@ -45,23 +43,23 @@ export default function AddMemberOrGroupDialog({
>([])
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const { groups: specificGroups, members: specificMembers } = selectedAccessSubjects
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates(
{ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 },
open,
)
const pages = data?.pages ?? []
const subjects = pages.flatMap((page) => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const candidates = pages.flatMap((page) => page.subjects ?? [])
const hasResults = pages.length > 0 && candidates.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
const searchLabel = t(($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], {
ns: 'app',
})
const noResultLabel = t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], {
ns: 'app',
})
useEffect(() => {
let observer: IntersectionObserver | undefined
@ -78,85 +76,86 @@ export default function AddMemberOrGroupDialog({
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setKeyword('')
if (!nextOpen) {
setKeyword('')
setSelectedGroupsForBreadcrumb([])
}
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setKeyword(inputValue)
}
const handleSubjectToggle = (subject: Subject) => {
const { groups, members } = selectedAccessSubjects
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else nextMembers.push((subject as SubjectAccount).accountData)
if (subject.subjectType === SubjectType.GROUP) {
const group = (subject as SubjectGroup).groupData
const selected = groups.some((candidate) => candidate.id === group.id)
onChange({
groups: selected
? groups.filter((candidate) => candidate.id !== group.id)
: [...groups, group],
members,
})
return
}
onChange({ groups: nextGroups, members: nextMembers })
const member = (subject as SubjectAccount).accountData
const selected = members.some((candidate) => candidate.id === member.id)
onChange({
groups,
members: selected
? members.filter((candidate) => candidate.id !== member.id)
: [...members, member],
})
}
const isSubjectSelected = (subject: Subject) =>
subject.subjectType === SubjectType.GROUP
? selectedAccessSubjects.groups.some((group) => group.id === subject.subjectId)
: selectedAccessSubjects.members.some((member) => member.id === subject.subjectId)
const statusText = isLoading
? t(($) => $.loading, { ns: 'common' })
: hasResults
? null
: noResultLabel
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
icon={false}
size="small"
className="h-6 w-auto min-w-13 shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
variant="ghost-accent"
size="small"
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
className="min-w-13 shrink-0 gap-x-0.5 px-2 data-popup-open:bg-state-accent-hover"
>
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
</Button>
}
/>
<PopoverContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
placeholder={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading ? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
) : (
<>
<PopoverTitle className="sr-only">{searchLabel}</PopoverTitle>
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
<ScrollAreaViewport
ref={scrollRootRef}
role="region"
aria-label={searchLabel}
style={{ overflowX: 'hidden' }}
>
<ScrollAreaContent style={{ minWidth: 0 }}>
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<SearchInput
aria-label={searchLabel}
placeholder={searchLabel}
value={keyword}
onValueChange={setKeyword}
/>
</div>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadcrumb
@ -165,62 +164,49 @@ export default function AddMemberOrGroupDialog({
/>
</div>
)}
{hasResults ? (
<>
<ComboboxList<Subject> className="max-h-none p-1">
{(subject) => (
<SubjectItem
key={getSubjectValue(subject)}
subject={subject}
selectedGroups={specificGroups}
onExpandGroup={(group) =>
setSelectedGroupsForBreadcrumb((groups) => [...groups, group])
}
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
) : (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })}
</ComboboxEmpty>
{hasResults && (
<ul className="p-1">
{candidates.map((subject) => (
<SubjectItem
key={`${subject.subjectType}:${subject.subjectId}`}
subject={subject}
selected={isSubjectSelected(subject)}
onToggle={() => handleSubjectToggle(subject)}
onExpandGroup={(group) =>
setSelectedGroupsForBreadcrumb((groups) => [...groups, group])
}
/>
))}
</ul>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={
statusText ? 'flex min-h-7 items-center justify-center px-2 py-0.5' : 'h-0'
}
>
{isLoading ? (
<>
<span className="sr-only">{statusText}</span>
<div className="w-full" aria-hidden="true">
<Loading />
</div>
</>
) : (
statusText
)}
</div>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</PopoverContent>
</Popover>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}

View File

@ -11,7 +11,7 @@ import type {
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxItem, ComboboxItemText } from '@langgenius/dify-ui/combobox'
import { Toggle } from '@langgenius/dify-ui/toggle'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@ -19,62 +19,65 @@ import { SubjectType } from '@/models/access-control'
type SubjectItemProps = {
subject: Subject
selectedGroups: AccessControlGroup[]
selected: boolean
onToggle: () => void
onExpandGroup: (group: AccessControlGroup) => void
}
export function SubjectItem({ subject, selectedGroups, onExpandGroup }: SubjectItemProps) {
export function SubjectItem({ subject, selected, onToggle, onExpandGroup }: SubjectItemProps) {
if (subject.subjectType === SubjectType.GROUP)
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
subject={subject}
selectedGroups={selectedGroups}
selected={selected}
onToggle={onToggle}
onExpand={onExpandGroup}
/>
)
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
return (
<MemberItem
member={(subject as SubjectAccount).accountData}
selected={selected}
onToggle={onToggle}
/>
)
}
type GroupItemProps = {
function GroupItem({
group,
selected,
onToggle,
onExpand,
}: {
group: AccessControlGroup
subject: Subject
selectedGroups: AccessControlGroup[]
selected: boolean
onToggle: () => void
onExpand: (group: AccessControlGroup) => void
}
function GroupItem({ group, subject, selectedGroups, onExpand }: GroupItemProps) {
}) {
const { t } = useTranslation()
const isChecked = selectedGroups.some((selectedGroup) => selectedGroup.id === group.id)
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<span
aria-hidden="true"
className="i-ri-organization-chart h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
/>
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</>
)}
</BaseItem>
<li className="flex min-w-0 items-center gap-2 rounded-lg hover:bg-state-base-hover">
<SubjectToggleButton selected={selected} onToggle={onToggle}>
<div className="mr-2 size-5 shrink-0 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<span
aria-hidden="true"
className="i-ri-organization-chart size-3.5 text-components-avatar-shape-fill-stop-0"
/>
</div>
</div>
<span className="mr-1 min-w-0 truncate system-sm-medium text-text-secondary">
{group.name}
</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{group.groupSize}</span>
</SubjectToggleButton>
<Button
size="small"
disabled={isChecked}
disabled={selected}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between py-1"
onPointerDown={(event) => event.preventDefault()}
onClick={() => onExpand(group)}
>
<span>
@ -82,11 +85,19 @@ function GroupItem({ group, subject, selectedGroups, onExpand }: GroupItemProps)
</span>
<span aria-hidden="true" className="i-ri-arrow-right-s-line size-4" />
</Button>
</div>
</li>
)
}
function MemberItem({ member, subject }: { member: AccessControlAccount; subject: Subject }) {
function MemberItem({
member,
selected,
onToggle,
}: {
member: AccessControlAccount
selected: boolean
onToggle: () => void
}) {
const { data: currentUser } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile,
@ -94,52 +105,58 @@ function MemberItem({ member, subject }: { member: AccessControlAccount; subject
const { t } = useTranslation()
return (
<BaseItem subject={subject} className="pr-3">
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</>
)}
</BaseItem>
<li>
<SubjectToggleButton selected={selected} onToggle={onToggle} className="pr-3">
<div className="mr-2 size-5 shrink-0 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 min-w-0 truncate system-sm-medium text-text-secondary">
{member.name}
</span>
{currentUser.email === member.email && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
<span className="ml-auto min-w-0 truncate system-xs-regular text-text-quaternary">
{member.email}
</span>
</SubjectToggleButton>
</li>
)
}
function BaseItem({
function SubjectToggleButton({
children,
selected,
onToggle,
className,
subject,
}: {
children: ReactNode
selected: boolean
onToggle: () => void
className?: string
subject: Subject
children: (selected: boolean) => ReactNode
}) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
render={(props, state) => (
<div {...props} className={props.className}>
{children(state.selected)}
</div>
)}
/>
<Toggle
pressed={selected}
onPressedChange={onToggle}
render={
<Button
variant="ghost"
size="medium"
className={cn(
'min-h-8 min-w-0 grow justify-start gap-2 p-1 pl-2 text-left whitespace-normal',
className,
)}
/>
}
>
<SelectionBox checked={selected} />
{children}
</Toggle>
)
}

View File

@ -2,6 +2,7 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { Infotip } from '../../base/infotip'
@ -201,9 +202,9 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
<IconButton
size="xs"
className="shrink-0"
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
onClick={onRemove}
>
@ -211,7 +212,7 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
aria-hidden="true"
className="i-ri-close-circle-fill h-3.5 w-3.5 text-text-quaternary"
/>
</button>
</IconButton>
</div>
)
}

View File

@ -211,6 +211,8 @@ describe('DocumentPicker', () => {
await user.click(screen.getByRole('combobox', { name: 'Document 1' }))
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
expect(
(await screen.findByText('common.noData')).closest('[role="status"]'),
).toBeInTheDocument()
})
})

View File

@ -4,19 +4,19 @@ import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import Loading from '@/app/components/base/loading'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
@ -65,8 +65,6 @@ function DocumentPickerTriggerValue({
const isGeneralMode = document?.doc_form === ChunkingMode.text
const isParentChild = document?.doc_form === ChunkingMode.parentChild
const isQAMode = document?.doc_form === ChunkingMode.qa
const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk
const ArrowIcon = RiArrowDownSLine
const parentModeLabel = (() => {
if (!parentMode) return '--'
return parentMode === 'paragraph'
@ -82,10 +80,21 @@ function DocumentPickerTriggerValue({
<span className="max-w-70 min-w-0 truncate system-md-semibold text-text-primary">
{document?.name || '--'}
</span>
<ArrowIcon className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
<span
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-primary"
aria-hidden="true"
/>
</span>
<span className="flex h-3 max-w-75 items-center gap-0.5 text-text-tertiary">
<TypeIcon className="size-3 shrink-0" />
<span
className={cn(
'size-3 shrink-0',
isParentChild
? 'i-custom-vender-knowledge-parent-child-chunk'
: 'i-custom-vender-knowledge-general-chunk',
)}
aria-hidden="true"
/>
<span className={cn('truncate system-2xs-medium-uppercase', isParentChild && 'mt-0.5')}>
{isGeneralMode && t(($) => $['chunkingMode.general'], { ns: 'dataset' })}
{isQAMode && t(($) => $['chunkingMode.qa'], { ns: 'dataset' })}
@ -152,35 +161,46 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props
{(document) => <DocumentPickerTriggerValue document={document} parentMode={parentMode} />}
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={0}
popupClassName="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]"
>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
<ComboboxPortal>
<ComboboxPositioner placement="bottom-start" sideOffset={0}>
<ComboboxPopup
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
<DocumentList className="mt-2 data-empty:mt-0" />
{data ? (
<ComboboxEmpty className="p-0">
<div className="mt-2 flex h-25 w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary">
{t(($) => $.noData, { ns: 'common' })}
</div>
</ComboboxEmpty>
) : (
<ComboboxStatus className="mt-2 flex h-25 w-full items-center justify-center">
<Loading />
</ComboboxStatus>
)}
</ComboboxContent>
className="w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]"
>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
<DocumentList className="mt-2 data-empty:mt-0" />
<ComboboxEmpty className="p-0">
{data && (
<div className="mt-2 flex h-25 w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary">
{t(($) => $.noData, { ns: 'common' })}
</div>
)}
</ComboboxEmpty>
<ComboboxStatus
className={cn(!data ? 'mt-2 flex h-25 w-full items-center justify-center' : 'h-0')}
>
{!data && (
<>
<span className="sr-only">{t(($) => $.loading, { ns: 'common' })}</span>
<div className="w-full" aria-hidden="true">
<Loading />
</div>
</>
)}
</ComboboxStatus>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
)
}

View File

@ -61,6 +61,16 @@ describe('DatasetMetadataPicker', () => {
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
expect(
screen.getByRole('dialog', { name: 'dataset.metadata.addMetadata' }),
).toBeInTheDocument()
const searchInput = screen.getByRole('combobox', {
name: 'dataset.metadata.selectMetadata.search',
})
const listbox = screen.getByRole('listbox')
expect(searchInput).toHaveFocus()
expect(searchInput).toHaveAttribute('aria-expanded', 'true')
expect(searchInput).toHaveAttribute('aria-controls', listbox.id)
expect(await screen.findByRole('option', { name: /field_one/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_two/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
@ -95,6 +105,50 @@ describe('DatasetMetadataPicker', () => {
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
})
it('should clear only the search query and keep focus in the input', async () => {
const user = userEvent.setup()
const onSelectMetadata = vi.fn()
renderDatasetMetadataPicker({ onSelectMetadata })
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
const searchInput = screen.getByRole('combobox', {
name: 'dataset.metadata.selectMetadata.search',
})
await user.type(searchInput, 'two')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
clearButton.focus()
await user.keyboard('{Enter}')
expect(searchInput).toHaveValue('')
expect(searchInput).toHaveFocus()
expect(onSelectMetadata).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /field_one/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
})
it('should reset the search query after the picker finishes closing', async () => {
const user = userEvent.setup()
renderDatasetMetadataPicker()
const trigger = screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })
await user.click(trigger)
await user.type(
screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }),
'two',
)
await user.keyboard('{Escape}')
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
await user.click(trigger)
expect(
screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }),
).toHaveValue('')
expect(screen.getByRole('option', { name: /field_one/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
})
})
describe('Selection', () => {
@ -120,6 +174,30 @@ describe('DatasetMetadataPicker', () => {
})
describe('Actions', () => {
it('should keep footer actions interactive until their click completes', async () => {
const user = userEvent.setup()
const onOpenMetadataManagement = vi.fn()
renderDatasetMetadataPicker({ onOpenMetadataManagement })
const trigger = screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })
await user.click(trigger)
const manageButton = screen.getByRole('button', {
name: 'dataset.metadata.selectMetadata.manageAction',
})
await user.pointer({ keys: '[MouseLeft>]', target: manageButton })
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(onOpenMetadataManagement).not.toHaveBeenCalled()
await user.pointer({ keys: '[/MouseLeft]', target: manageButton })
expect(onOpenMetadataManagement).toHaveBeenCalledOnce()
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})
it('should switch to create view and save a new metadata item', async () => {
const user = userEvent.setup()
const onCreateMetadata = vi.fn().mockResolvedValue(undefined)
@ -204,6 +282,9 @@ describe('DatasetMetadataPicker', () => {
expect(
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }),
).toBeInTheDocument()
expect(screen.getByRole('listbox')).not.toContainElement(
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
)
})
})
})

View File

@ -1,11 +1,11 @@
'use client'
import type { ComboboxChangeEventDetails, Placement } from '@langgenius/dify-ui/combobox'
import type { PopoverContentProps } from '@langgenius/dify-ui/popover'
import type { BuiltInMetadataItem, MetadataItem } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxClear,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
@ -14,8 +14,9 @@ import {
ComboboxList,
ComboboxSeparator,
} from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDatasetMetaData } from '@/service/knowledge/use-metadata'
import { getIconClassName } from '../utils/get-icon'
@ -30,11 +31,11 @@ const PickerView = {
type PickerView = (typeof PickerView)[keyof typeof PickerView]
export type DatasetMetadataPickerProps = {
export type DatasetMetadataPickerProps = Pick<
PopoverContentProps,
'placement' | 'sideOffset' | 'alignOffset'
> & {
datasetId: string
placement?: Placement
sideOffset?: number
alignOffset?: number
onSelectMetadata: (metadata: MetadataItem) => void
onCreateMetadata: (metadata: BuiltInMetadataItem) => void | Promise<void>
onOpenMetadataManagement: () => void
@ -44,14 +45,6 @@ function getMetadataLabel(metadata: MetadataItem) {
return metadata.name
}
function getMetadataValue(metadata: MetadataItem) {
return metadata.id
}
function isSameMetadata(item: MetadataItem, value: MetadataItem) {
return item.id === value.id
}
function metadataFilter(metadata: MetadataItem, query: string) {
return metadata.name.toLowerCase().includes(query.toLowerCase())
}
@ -72,21 +65,21 @@ export function DatasetMetadataPicker({
const [view, setView] = useState<PickerView>(PickerView.select)
const [query, setQuery] = useState('')
const resetPicker = () => {
const resetPickerState = () => {
setView(PickerView.select)
setQuery('')
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) resetPicker()
const handleOpenChangeComplete = (nextOpen: boolean) => {
if (!nextOpen) resetPickerState()
}
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setQuery(inputValue)
const handleStartMetadataCreation = () => {
setView(PickerView.create)
setQuery('')
}
const handleMetadataChange = (metadata: MetadataItem | null) => {
const handleSelectMetadata = (metadata: MetadataItem | null) => {
if (!metadata) return
onSelectMetadata({
@ -94,34 +87,31 @@ export function DatasetMetadataPicker({
name: metadata.name,
type: metadata.type,
})
setOpen(false)
resetPicker()
}
const handleCreateMetadata = async (metadata: BuiltInMetadataItem) => {
try {
await onCreateMetadata(metadata)
resetPicker()
resetPickerState()
} catch {
// Keep the create view open so callers can surface validation feedback and the user can correct the input.
}
}
const handleOpenManagement = () => {
const handleOpenMetadataManagement = () => {
setOpen(false)
resetPicker()
onOpenMetadataManagement()
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen} onOpenChangeComplete={handleOpenChangeComplete}>
<PopoverTrigger
render={
<button
type="button"
<Button
variant="tertiary"
size="small"
aria-label={t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
aria-expanded={open}
className="flex h-6 w-full cursor-pointer items-center justify-center rounded-md border-0 bg-components-button-tertiary-bg px-2 py-0 text-xs font-medium text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover focus-visible:bg-components-button-tertiary-bg-hover"
className="w-full px-2 py-0"
>
<span className="flex min-w-0 items-center justify-center gap-1">
<span
@ -132,7 +122,7 @@ export function DatasetMetadataPicker({
{t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
</span>
</span>
</button>
</Button>
}
/>
<PopoverContent
@ -141,33 +131,35 @@ export function DatasetMetadataPicker({
alignOffset={alignOffset}
popupClassName="w-[320px] bg-components-panel-bg-blur backdrop-blur-[5px]"
>
<PopoverTitle className="sr-only">
{t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
</PopoverTitle>
{view === PickerView.select ? (
<Combobox<MetadataItem>
inline
open={open}
onOpenChange={setOpen}
value={null}
items={metadataItems}
inputValue={query}
onInputValueChange={handleInputValueChange}
onValueChange={handleMetadataChange}
onInputValueChange={setQuery}
onValueChange={handleSelectMetadata}
itemToStringLabel={getMetadataLabel}
itemToStringValue={getMetadataValue}
isItemEqualToValue={isSameMetadata}
filter={metadataFilter}
>
<MetadataPickerSelectPanel
query={query}
onNewMetadata={() => {
setView(PickerView.create)
setQuery('')
}}
onOpenMetadataManagement={handleOpenManagement}
onClearQuery={() => setQuery('')}
onStartMetadataCreation={handleStartMetadataCreation}
onOpenMetadataManagement={handleOpenMetadataManagement}
/>
</Combobox>
) : (
<CreateContent
onSave={handleCreateMetadata}
hasBack
onBack={resetPicker}
onClose={resetPicker}
onBack={resetPickerState}
onClose={resetPickerState}
/>
)}
</PopoverContent>
@ -177,14 +169,22 @@ export function DatasetMetadataPicker({
function MetadataPickerSelectPanel({
query,
onNewMetadata,
onClearQuery,
onStartMetadataCreation,
onOpenMetadataManagement,
}: {
query: string
onNewMetadata: () => void
onClearQuery: () => void
onStartMetadataCreation: () => void
onOpenMetadataManagement: () => void
}) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const handleClearQuery = () => {
onClearQuery()
inputRef.current?.focus()
}
return (
<>
@ -195,11 +195,22 @@ function MetadataPickerSelectPanel({
aria-hidden="true"
/>
<ComboboxInput
ref={inputRef}
aria-label={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })}
placeholder={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })}
className="pl-2"
/>
{query && <ComboboxClear aria-label={t(($) => $['operation.clear'], { ns: 'common' })} />}
{query && (
<IconButton
size="sm"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="mr-1.5 shrink-0 hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover"
onClick={handleClearQuery}
onMouseDown={(event) => event.preventDefault()}
>
<span className="i-ri-close-line size-4" aria-hidden="true" />
</IconButton>
)}
</ComboboxInputGroup>
</div>
<ComboboxList<MetadataItem>>
@ -208,7 +219,7 @@ function MetadataPickerSelectPanel({
<ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty>
<ComboboxSeparator />
<MetadataPickerActions
onNewMetadata={onNewMetadata}
onStartMetadataCreation={onStartMetadataCreation}
onOpenMetadataManagement={onOpenMetadataManagement}
/>
</>
@ -230,37 +241,33 @@ function MetadataOption({ metadata }: { metadata: MetadataItem }) {
}
function MetadataPickerActions({
onNewMetadata,
onStartMetadataCreation,
onOpenMetadataManagement,
}: {
onNewMetadata: () => void
onStartMetadataCreation: () => void
onOpenMetadataManagement: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between p-1">
<button
type="button"
className={cn(
'flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
)}
onClick={onNewMetadata}
<Button
variant="ghost"
size="medium"
className="min-w-0 justify-start gap-1 px-2 text-left text-text-secondary"
onClick={onStartMetadataCreation}
>
<span className="i-ri-add-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<span className="truncate system-sm-medium">
{t(($) => $[`${i18nPrefix}.newAction`], { ns: 'dataset' })}
</span>
</button>
</Button>
<div className="flex h-8 shrink-0 items-center text-text-secondary">
<div className="mx-1 h-3 w-px bg-divider-regular" />
<button
type="button"
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
)}
<Button
variant="ghost"
size="medium"
className="justify-start gap-1 px-2 text-left text-text-secondary"
onClick={onOpenMetadataManagement}
>
<span className="system-sm-medium">
@ -270,7 +277,7 @@ function MetadataPickerActions({
className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
</button>
</Button>
</div>
</div>
)

View File

@ -7,6 +7,17 @@ import { createConsoleQueryClient } from '@/test/console/query-data'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations'
import { ModelSelector, SplitModelSelector } from '../index'
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const mockModelProviders = vi.hoisted(() => ({ current: [] as Model[] }))
const mockSetSettingsDestination = vi.hoisted(() => vi.fn())
@ -34,23 +45,23 @@ vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
}),
}))
vi.mock('../popup', async () => {
const { ComboboxItem } = await vi.importActual<typeof import('@langgenius/dify-ui/combobox')>(
'@langgenius/dify-ui/combobox',
)
vi.mock('../popup', () => {
return {
default: ({
onConfigureEmptyState,
onHide,
onOpenProviderSettings,
onSelect,
}: {
onConfigureEmptyState?: () => void
onHide: () => void
onOpenProviderSettings?: () => void
onSelect: (provider: string, model: ModelItem) => void
}) => (
<>
<ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}>select</ComboboxItem>
<button type="button" onClick={() => onSelect('openai', makeModelItem())}>
select
</button>
<button type="button" onClick={onHide}>
hide
</button>
@ -69,17 +80,6 @@ vi.mock('../popup', async () => {
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const makeModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
@ -103,10 +103,13 @@ describe('ModelSelector', () => {
it('should toggle popup and close it after selecting a model', () => {
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
const triggerButton = screen.getByRole('combobox')
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
expect(
screen.getByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
).toBeInTheDocument()
expect(screen.getByText('select')).toBeInTheDocument()
fireEvent.click(screen.getByText('select'))
@ -117,7 +120,7 @@ describe('ModelSelector', () => {
const onValueChange = vi.fn()
renderWithQueryClient(<ModelSelector models={[makeModel()]} onValueChange={onValueChange} />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' }))
fireEvent.click(screen.getByText('select'))
expect(onValueChange).toHaveBeenCalledWith({
@ -130,7 +133,7 @@ describe('ModelSelector', () => {
it('should close popup when popup requests hide', () => {
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
const triggerButton = screen.getByRole('combobox')
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('hide')).toBeInTheDocument()
@ -145,7 +148,7 @@ describe('ModelSelector', () => {
<ModelSelector models={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />,
)
const triggerButton = screen.getByRole('combobox')
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
@ -160,7 +163,7 @@ describe('ModelSelector', () => {
const onHide = vi.fn()
renderWithQueryClient(<ModelSelector models={[makeModel()]} onHide={onHide} />)
const triggerButton = screen.getByRole('combobox')
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
await user.click(triggerButton)
await user.click(screen.getByRole('button', { name: 'provider-settings' }))
@ -172,14 +175,14 @@ describe('ModelSelector', () => {
it('should not open popup when disabled', () => {
renderWithQueryClient(<ModelSelector models={[makeModel()]} disabled />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' }))
expect(screen.queryByText('select')).not.toBeInTheDocument()
})
it('should let the split trigger own the combobox interaction', () => {
renderWithQueryClient(<SplitModelSelector models={[makeModel()]} />)
const trigger = screen.getByRole('combobox')
const trigger = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
expect(trigger).toHaveAttribute('data-shape', 'split')
})

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { Model, ModelItem } from '../../declarations'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { Popover } from '@langgenius/dify-ui/popover'
import { render as renderComponent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
@ -11,7 +11,8 @@ import {
} from '../../declarations'
import { ModelSelectorTrigger } from '../model-selector-trigger'
const render = (node: ReactNode) => renderComponent(<Combobox>{node}</Combobox>)
const render = (node: ReactNode) => renderComponent(<Popover>{node}</Popover>)
const getTrigger = () => screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
@ -71,7 +72,7 @@ describe('ModelSelectorTrigger', () => {
render(<ModelSelectorTrigger />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeEnabled()
expect(getTrigger()).toBeEnabled()
})
it('should render selected model details when model is active', () => {
@ -81,7 +82,7 @@ describe('ModelSelectorTrigger', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeEnabled()
expect(getTrigger()).toBeEnabled()
})
it('should render deprecated default model and disabled style when selection is missing', () => {
@ -102,7 +103,7 @@ describe('ModelSelectorTrigger', () => {
/>,
)
expect(screen.getByRole('combobox')).toBeDisabled()
expect(getTrigger()).toBeDisabled()
})
})
@ -118,11 +119,8 @@ describe('ModelSelectorTrigger', () => {
expect(
screen.getByText('common.modelProvider.selector.configureRequired'),
).toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveAttribute(
'data-model-status',
'configure-required',
)
expect(screen.getByRole('combobox')).toHaveClass(
expect(getTrigger()).toHaveAttribute('data-model-status', 'configure-required')
expect(getTrigger()).toHaveClass(
'data-[model-status=configure-required]:bg-components-input-bg-disabled',
)
})
@ -167,7 +165,7 @@ describe('ModelSelectorTrigger', () => {
screen.getByText('common.modelProvider.selector.apiKeyUnavailable'),
).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveClass(
expect(getTrigger()).toHaveClass(
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled',
)
})
@ -208,7 +206,7 @@ describe('ModelSelectorTrigger', () => {
)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toHaveClass(
expect(getTrigger()).toHaveClass(
'data-[model-status=incompatible]:bg-components-input-bg-disabled',
)
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))

View File

@ -1,7 +1,6 @@
import type { ReactElement, ReactNode } from 'react'
import type { DefaultModel, Model, ModelItem } from '../../declarations'
import type { ModelSelectorPreviewPayload } from '../popup-item'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -142,20 +141,17 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
const previewCardProps = () => ({
previewCardHandle: createPreviewCardHandle<ModelSelectorPreviewPayload>(),
onPreviewCardClose: vi.fn(),
onSelect: vi.fn(),
})
const createComboboxNode = (node: ReactElement, onValueChange = vi.fn()) => (
<Combobox filter={null} open onValueChange={onValueChange}>
{node}
</Combobox>
)
const createPopupItemNode = (node: ReactElement) => node
const renderWithCombobox = (node: ReactElement, onValueChange = vi.fn()) => {
const renderPopupItem = (node: ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [makeProvider()],
})
return renderWithConsoleQuery(createComboboxNode(node, onValueChange), { queryClient })
return renderWithConsoleQuery(createPopupItemNode(node), { queryClient })
}
describe('PopupItem', () => {
@ -186,34 +182,36 @@ describe('PopupItem', () => {
modelProviders: [],
})
const { container } = renderWithCombobox(
const { container } = renderPopupItem(
<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />,
)
expect(container.textContent).toBe('')
})
it('should select the combobox value when clicking an active model', () => {
const onValueChange = vi.fn()
renderWithCombobox(
<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />,
onValueChange,
it('should select the model when clicking an active model', () => {
const onSelect = vi.fn()
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel()}
onSelect={onSelect}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('GPT-4'))
expect(onValueChange).toHaveBeenCalledWith(
{ provider: 'openai', model: 'gpt-4' },
expect.objectContaining({ reason: 'item-press' }),
)
expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' }))
})
it('should close the shared preview before pressing an active model', () => {
const onPreviewCardClose = vi.fn()
renderWithCombobox(
renderPopupItem(
<PopupItem
previewCardHandle={createPreviewCardHandle<ModelSelectorPreviewPayload>()}
onPreviewCardClose={onPreviewCardClose}
onSelect={vi.fn()}
model={makeModel()}
onHide={vi.fn()}
/>,
@ -225,23 +223,23 @@ describe('PopupItem', () => {
})
it('should not select the combobox value when model is not active', () => {
const onValueChange = vi.fn()
renderWithCombobox(
const onSelect = vi.fn()
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
onSelect={onSelect}
onHide={vi.fn()}
/>,
onValueChange,
)
fireEvent.click(screen.getByText('GPT-4'))
expect(onValueChange).not.toHaveBeenCalled()
expect(onSelect).not.toHaveBeenCalled()
})
it('should strike through deprecated model name', () => {
renderWithCombobox(
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({ models: [makeModelItem({ deprecated: true })] })}
@ -253,7 +251,7 @@ describe('PopupItem', () => {
})
it('should render incompatible model names with tertiary text color', () => {
renderWithCombobox(
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({
@ -273,7 +271,7 @@ describe('PopupItem', () => {
})
it('should render suggestion icon with tooltip for suggested models', async () => {
renderWithCombobox(
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({
@ -301,14 +299,14 @@ describe('PopupItem', () => {
})
it('should open model modal when clicking add on unconfigured model', async () => {
const onValueChange = vi.fn()
const { rerender } = renderWithCombobox(
const onSelect = vi.fn()
const { rerender } = renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
onSelect={onSelect}
onHide={vi.fn()}
/>,
onValueChange,
)
fireEvent.click(screen.getByText('GPT-4'))
@ -316,7 +314,7 @@ describe('PopupItem', () => {
expect(addButton.closest('[aria-disabled="true"]')).toBeNull()
fireEvent.click(addButton)
expect(onValueChange).not.toHaveBeenCalled()
expect(onSelect).not.toHaveBeenCalled()
await waitFor(() => {
expect(mockSetShowModelModal).toHaveBeenCalled()
})
@ -328,7 +326,7 @@ describe('PopupItem', () => {
expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
rerender(
createComboboxNode(
createPopupItemNode(
<PopupItem
{...previewCardProps()}
model={makeModel({
@ -359,7 +357,7 @@ describe('PopupItem', () => {
it('should show selected state when defaultModel matches', () => {
const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' }
renderWithCombobox(
renderPopupItem(
<PopupItem
{...previewCardProps()}
defaultModel={defaultModel}
@ -368,13 +366,13 @@ describe('PopupItem', () => {
/>,
)
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /GPT-4/ })).toHaveAttribute('aria-current', 'true')
})
it('should fall back to english labels when the current language is unavailable', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
renderWithCombobox(
renderPopupItem(
<PopupItem
{...previewCardProps()}
model={makeModel({
@ -390,21 +388,27 @@ describe('PopupItem', () => {
})
it('should toggle collapsed state when clicking provider header', () => {
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
const providerTrigger = screen.getByRole('button', { name: 'OpenAI' })
const modelButton = screen.getByRole('button', { name: /GPT-4/ })
fireEvent.click(screen.getByText('OpenAI'))
expect(providerTrigger).toHaveAttribute('aria-expanded', 'true')
expect(modelButton).toBeVisible()
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
fireEvent.click(providerTrigger)
fireEvent.click(screen.getByText('OpenAI'))
expect(providerTrigger).toHaveAttribute('aria-expanded', 'false')
expect(modelButton).not.toBeVisible()
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
fireEvent.click(providerTrigger)
expect(providerTrigger).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: /GPT-4/ })).toBeVisible()
})
it('should show credential name when using custom provider', () => {
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('my-api-key'))!.toBeInTheDocument()
})
@ -421,7 +425,7 @@ describe('PopupItem', () => {
credits: 200,
})
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key'))!.toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
@ -449,7 +453,7 @@ describe('PopupItem', () => {
credits: 0,
})
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument()
})
@ -473,7 +477,7 @@ describe('PopupItem', () => {
credits: 200,
})
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument()
})
@ -500,7 +504,7 @@ describe('PopupItem', () => {
credits: 0,
})
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
})
@ -508,7 +512,7 @@ describe('PopupItem', () => {
it('should close the dropdown through dropdown content callbacks', async () => {
const onHide = vi.fn()
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
fireEvent.click(await screen.findByRole('button', { name: 'close dropdown' }))
@ -519,7 +523,7 @@ describe('PopupItem', () => {
it('should keep the credential dropdown enabled for manage-only users', async () => {
mockWorkspacePermissionKeys.value = ['credential.manage']
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /my-api-key/ })

View File

@ -2,7 +2,7 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/w
import type { ReactElement } from 'react'
import type { Model, ModelItem } from '../../declarations'
import type { PopupProps } from '../popup'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
@ -74,22 +74,18 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange'>
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange' | 'onSelect'>
function PopupHarness(props: PopupTestProps) {
const [inputValue, setInputValue] = useState('')
return (
<Combobox
filter={null}
<Popup
{...props}
inputValue={inputValue}
open
onInputValueChange={(newInputValue, details) => {
if (details.reason !== 'item-press') setInputValue(newInputValue)
}}
>
<Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} />
</Combobox>
onInputValueChange={setInputValue}
onSelect={vi.fn()}
/>
)
}
@ -97,12 +93,18 @@ function PopupContentHarness(props: PopupTestProps) {
const [inputValue, setInputValue] = useState('')
return (
<Combobox filter={null} inputValue={inputValue} open>
<ComboboxTrigger aria-label="Selected model">Selected model</ComboboxTrigger>
<ComboboxContent popupProps={{ 'aria-label': 'Model selector' }}>
<Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} />
</ComboboxContent>
</Combobox>
<Popover open>
<PopoverTrigger>Selected model</PopoverTrigger>
<PopoverContent>
<PopoverTitle>Model selector</PopoverTitle>
<Popup
{...props}
inputValue={inputValue}
onInputValueChange={setInputValue}
onSelect={vi.fn()}
/>
</PopoverContent>
</Popover>
)
}
@ -1104,7 +1106,7 @@ describe('Popup', () => {
).not.toBeInTheDocument()
})
it('should keep popup actions outside the model listbox and reachable by Tab', async () => {
it('should expose popup actions in the dialog tab sequence without a listbox', async () => {
const user = userEvent.setup()
const onConfigureEmptyState = vi.fn()
const onOpenProviderSettings = vi.fn()
@ -1123,7 +1125,7 @@ describe('Popup', () => {
expect(await screen.findByRole('dialog', { name: 'Model selector' })).toBeInTheDocument()
const searchInput = screen.getByRole('combobox', {
const searchInput = screen.getByRole('searchbox', {
name: 'datasetSettings.form.searchModel',
})
const configureButton = screen.getByRole('button', {
@ -1132,10 +1134,7 @@ describe('Popup', () => {
const providerSettingsButton = screen.getByRole('button', {
name: /common\.modelProvider\.selector\.modelProviderSettings/,
})
const listbox = screen.getByRole('listbox')
expect(listbox).not.toContainElement(configureButton)
expect(listbox).not.toContainElement(providerSettingsButton)
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
await user.click(searchInput)
await user.tab()

View File

@ -1,4 +1,3 @@
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type {
ModelSelectorModel,
ModelSelectorModelPredicate,
@ -6,18 +5,17 @@ import type {
ModelSelectorValue,
} from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTitle } from '@langgenius/dify-ui/popover'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { ModelStatusEnum } from '../declarations'
import { getCurrentProviderAndModel } from '../hooks'
import { ModelSelectorTrigger } from './model-selector-trigger'
import Popup from './popup'
import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types'
const getModelProviderPluginId = (provider: string) => {
const [organization, pluginName] = provider.split('/').filter(Boolean)
@ -76,6 +74,7 @@ function ModelSelectorRoot({
surface: 'default' | 'workflow'
shape: 'standalone' | 'split'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [settingsDestination, setSettingsDestination] = useQueryState(
@ -83,14 +82,6 @@ function ModelSelectorRoot({
settingsQueryParser,
)
const { currentProvider, currentModel } = getCurrentProviderAndModel(models, value)
const currentValue = useMemo<ModelSelectorValue | null>(() => {
if (!currentProvider || !currentModel) return null
return {
provider: currentProvider.provider,
model: currentModel.model,
}
}, [currentModel, currentProvider])
const handleOpenChange = useCallback(
(newOpen: boolean) => {
@ -129,28 +120,6 @@ function ModelSelectorRoot({
setSettingsDestination('provider')
}, [handleHide, setSettingsDestination])
const handleValueChange = useCallback(
(value: ModelSelectorValue | null) => {
if (!value) return
const provider = models.find((model) => model.provider === value.provider)
const model = provider?.models.find((model) => model.model === value.model)
if (!provider || !model) return
if (model.status !== ModelStatusEnum.active) return
handleSelect(provider.provider, model)
},
[handleSelect, models],
)
const handleInputValueChange = useCallback(
(inputValue: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setInputValue(inputValue)
},
[],
)
const handleConfigureEmptyState = useCallback(() => {
if (onConfigureEmptyState) {
handleHide()
@ -166,18 +135,7 @@ function ModelSelectorRoot({
}, [handleHide, handleOpenSettings, onConfigureEmptyState, settingsDestination])
return (
<Combobox<ModelSelectorValue>
disabled={disabled}
filter={null}
inputValue={inputValue}
isItemEqualToValue={isSameModelSelectorValue}
itemToStringLabel={getModelSelectorValueLabel}
open={open}
value={currentValue}
onInputValueChange={handleInputValueChange}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
>
<Popover open={open} onOpenChange={handleOpenChange}>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
@ -195,12 +153,16 @@ function ModelSelectorRoot({
: undefined
}
/>
<ComboboxContent
<PopoverContent
placement="bottom-start"
popupClassName={cn(
'flex max-h-[min(624px,var(--available-height,624px))] flex-col',
'flex max-h-[min(624px,var(--available-height,624px))] w-(--anchor-width) max-w-[min(28rem,var(--available-width))] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
popupClassName,
)}
>
<PopoverTitle className="sr-only">
{t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
</PopoverTitle>
<Popup
defaultModel={value}
inputValue={inputValue}
@ -216,10 +178,11 @@ function ModelSelectorRoot({
onConfigureEmptyState={handleConfigureEmptyState}
onOpenMarketplace={onOpenMarketplace}
onInputValueChange={setInputValue}
onSelect={handleSelect}
onHide={handleHide}
/>
</ComboboxContent>
</Combobox>
</PopoverContent>
</Popover>
)
}

View File

@ -1,6 +1,8 @@
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -28,6 +30,7 @@ function MarketplaceSection({
onOpenMarketplace,
}: MarketplaceSectionProps) {
const { t } = useTranslation()
const headingId = useId()
if (marketplaceProviders.length === 0) return null
@ -36,34 +39,37 @@ function MarketplaceSection({
<div className="py-2">
<div className="h-px bg-divider-subtle" />
</div>
<div>
<Collapsible
open={!marketplaceCollapsed}
onOpenChange={(open) => onMarketplaceCollapsedChange(!open)}
render={<section aria-labelledby={headingId} />}
>
<div className="flex h-5.5 items-center pr-2 pl-4">
<button
type="button"
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
<CollapsibleTrigger
id={headingId}
className="group/marketplace min-h-0 flex-1 justify-start gap-0 rounded-none p-0 system-sm-medium text-text-primary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-primary data-panel-open:text-text-primary"
>
{t(($) => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })}
<span
aria-hidden="true"
className={cn(
'i-custom-vender-solid-general-arrow-down-round-fill size-4 text-text-quaternary',
marketplaceCollapsed && '-rotate-90',
'i-custom-vender-solid-general-arrow-down-round-fill size-4 -rotate-90 text-text-quaternary transition-transform group-data-panel-open/marketplace:rotate-0 motion-reduce:transition-none',
)}
/>
</button>
</CollapsibleTrigger>
</div>
{!marketplaceCollapsed && (
<div className="px-1 pb-1">
<CollapsiblePanel>
<ul className="px-1 pb-1">
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
<li
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 focus-within:bg-state-base-hover hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="size-5 shrink-0 rounded-md" />
<Icon aria-hidden="true" className="size-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">
{modelNameMap[key]}
</span>
@ -72,28 +78,36 @@ function MarketplaceSection({
<Button
variant="secondary"
size="small"
aria-busy={isInstalling || undefined}
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
!isInstalling &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100',
)}
disabled={isInstalling}
onClick={() => onInstallPlugin(key)}
>
{isInstalling && (
<span className="i-ri-loader-2-line size-3.5 animate-spin" />
<span
aria-hidden="true"
className="i-ri-loader-2-line size-3.5 animate-spin"
/>
)}
{isInstalling
? t(($) => $['installModal.installing'], { ns: 'plugin' })
: t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
</Button>
)}
</div>
</li>
)
})}
</ul>
<div className="px-1 pb-1">
{onOpenMarketplace ? (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
<Button
variant="ghost"
size="small"
className="h-auto w-full justify-start gap-0.5 px-3 py-1.5 text-left"
onClick={onOpenMarketplace}
>
<span className="flex-1 system-xs-regular text-text-accent">
@ -105,10 +119,10 @@ function MarketplaceSection({
className="i-ri-arrow-right-up-line size-3! text-text-accent"
aria-hidden="true"
/>
</button>
</Button>
) : (
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
className="flex cursor-pointer items-center gap-0.5 rounded-md px-3 py-1.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
target="_blank"
rel="noopener noreferrer"
@ -125,8 +139,8 @@ function MarketplaceSection({
</a>
)}
</div>
)}
</div>
</CollapsiblePanel>
</Collapsible>
</>
)
}

View File

@ -1,6 +1,6 @@
import type { ModelSelectorModel, ModelSelectorProvider, ModelSelectorValue } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@ -12,7 +12,7 @@ import {
} from '../derive-model-status'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import { useCredentialPanelState as useCredentialPanelInfo } from '../provider-added-card/use-credential-panel-state'
type ModelSelectorTriggerProps = {
currentProvider?: ModelSelectorProvider
@ -52,7 +52,7 @@ function ModelSelectorTrigger({
enabled: !!providerId,
select: ({ data }) => data.find((provider) => provider.provider === providerId),
})
const credentialPanel = useCredentialPanelState(resolvedProvider)
const credentialPanel = useCredentialPanelInfo(resolvedProvider)
const status = deriveModelStatus(
isSelected ? currentModel?.model : defaultModel?.model,
@ -93,26 +93,33 @@ function ModelSelectorTrigger({
<TooltipTrigger
disabled={!triggerTooltipLabel || disabled}
render={
<ComboboxTrigger
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
data-deprecated={isDeprecated ? '' : undefined}
data-model-status={status}
data-shape={shape}
data-size={size}
data-surface={surface}
<PopoverTrigger
disabled={disabled}
icon={!disabled && shape !== 'split' && (isActive || isEmpty) ? undefined : false}
size={size}
className={cn(
'data-[size=small]:h-6 data-[size=small]:gap-px data-[size=small]:rounded-md data-[size=small]:p-0.5',
'data-[size=medium]:h-8 data-[size=medium]:gap-0.5 data-[size=medium]:rounded-lg data-[size=medium]:p-1',
'data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg data-[surface=workflow]:data-popup-open:bg-workflow-block-parma-bg',
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled data-[model-status=configure-required]:bg-components-input-bg-disabled data-[model-status=credits-exhausted]:bg-components-input-bg-disabled data-[model-status=disabled]:bg-components-input-bg-disabled data-[model-status=incompatible]:bg-components-input-bg-disabled',
'data-disabled:data-[model-status=active]:bg-components-input-bg-normal! data-disabled:data-[model-status=empty]:bg-components-input-bg-normal! data-disabled:data-[surface=workflow]:bg-workflow-block-parma-bg!',
'data-[shape=split]:relative data-[shape=split]:min-w-0 data-[shape=split]:flex-1 data-[shape=split]:rounded-l-lg! data-[shape=split]:rounded-r-none! data-[shape=split]:focus-visible:z-1',
'data-[surface=workflow]:data-deprecated:[&>span]:opacity-50',
className,
)}
render={
<button
type="button"
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
data-deprecated={isDeprecated ? '' : undefined}
data-model-status={status}
data-shape={shape}
data-size={size}
data-surface={surface}
disabled={disabled}
className={cn(
'group/model-selector-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 focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled motion-reduce:transition-none',
'data-[size=small]:h-6 data-[size=small]:gap-px data-[size=small]:rounded-md data-[size=small]:p-0.5',
'data-[size=medium]:h-8 data-[size=medium]:gap-0.5 data-[size=medium]:rounded-lg data-[size=medium]:p-1',
'data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg data-[surface=workflow]:data-popup-open:bg-workflow-block-parma-bg',
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled data-[model-status=configure-required]:bg-components-input-bg-disabled data-[model-status=credits-exhausted]:bg-components-input-bg-disabled data-[model-status=disabled]:bg-components-input-bg-disabled data-[model-status=incompatible]:bg-components-input-bg-disabled',
'disabled:data-[model-status=active]:bg-components-input-bg-normal! disabled:data-[model-status=empty]:bg-components-input-bg-normal! disabled:data-[surface=workflow]:bg-workflow-block-parma-bg!',
'data-[shape=split]:relative data-[shape=split]:min-w-0 data-[shape=split]:flex-1 data-[shape=split]:rounded-l-lg! data-[shape=split]:rounded-r-none! data-[shape=split]:focus-visible:z-1',
'data-[surface=workflow]:data-deprecated:[&>span]:opacity-50',
className,
)}
/>
}
>
<span className="flex min-w-0 grow items-center gap-0.5">
{isEmpty ? (
@ -123,7 +130,10 @@ function ModelSelectorTrigger({
)}
>
<span className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line size-3.5 text-text-quaternary" />
<span
aria-hidden="true"
className="i-ri-brain-2-line size-3.5 text-text-quaternary"
/>
</span>
</span>
) : (
@ -186,7 +196,13 @@ function ModelSelectorTrigger({
)}
</span>
</span>
</ComboboxTrigger>
{!disabled && shape !== 'split' && (isActive || isEmpty) && (
<span
aria-hidden="true"
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary transition-colors group-hover/model-selector-trigger:text-text-secondary group-data-popup-open/model-selector-trigger:text-text-secondary"
/>
)}
</PopoverTrigger>
}
/>
{triggerTooltipLabel && (

View File

@ -11,7 +11,7 @@ function ModelSelectorEmptyState({ onConfigure }: ModelSelectorEmptyStateProps)
return (
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line size-5 text-text-tertiary" />
<span aria-hidden="true" className="i-ri-brain-2-line size-5 text-text-tertiary" />
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
@ -23,7 +23,7 @@ function ModelSelectorEmptyState({ onConfigure }: ModelSelectorEmptyStateProps)
</div>
<Button variant="primary" className="w-27" onClick={onConfigure}>
{t(($) => $['modelProvider.selector.configure'], { ns: 'common' })}
<span className="i-ri-arrow-right-line size-4" />
<span aria-hidden="true" className="i-ri-arrow-right-line size-4" />
</Button>
</div>
)

View File

@ -6,13 +6,14 @@ import type {
ModelSelectorProvider,
ModelSelectorValue,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -45,8 +46,10 @@ type PopupItemProps = {
modelSuggestionPredicate?: ModelSelectorModelPredicate
previewCardHandle: PreviewCardHandle
onPreviewCardClose: () => void
onSelect: (provider: string, model: ModelSelectorModel) => void
onHide: () => void
}
function PopupItem({
defaultModel,
model,
@ -54,12 +57,15 @@ function PopupItem({
modelSuggestionPredicate,
previewCardHandle,
onPreviewCardClose,
onSelect,
onHide,
}: PopupItemProps) {
const [collapsed, setCollapsed] = useState(false)
const [modelsOpen, setModelsOpen] = useState(true)
const [dropdownOpen, setDropdownOpen] = useState(false)
const providerHeadingId = useId()
const { t } = useTranslation()
const language = useLanguage()
const providerLabel = renderI18nObject(model.label, language)
const suggestionTip = t(($) => $['modelProvider.selector.suggestionTip'], { ns: 'common' })
const { setShowModelModal } = useModalContext()
const { modelProviders } = useProviderContext()
@ -69,10 +75,16 @@ function PopupItem({
const { providerDetail, loadProviderDetail } = useLazyModelProviderDetail(model.provider)
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
const handleOpenModelModal = async () => {
if (!canCreateCredential) return
const state = useCredentialPanelInfo(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const isUsingCredits = state.priority === 'credits'
const hasCredits = !state.isCreditsExhausted
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
const { credentialName } = state
const handleOpenModelModal = async () => {
if (!canCreateCredential || !currentProvider) return
if (!currentProvider) return
const detail = await loadProviderDetail()
if (!detail) return
setShowModelModal({
@ -84,34 +96,16 @@ function PopupItem({
updateModelProviders()
const modelType = model.models[0]!.model_type
if (modelType) updateModelList(modelType as ModelType)
},
})
}
const state = useCredentialPanelInfo(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const groupItems = useMemo(
() =>
model.models
.filter((modelItem) => modelItem.status !== ModelStatusEnum.noConfigure)
.map((modelItem) => ({
provider: model.provider,
model: modelItem.model,
})),
[model.models, model.provider],
)
const isUsingCredits = state.priority === 'credits'
const hasCredits = !state.isCreditsExhausted
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
const { credentialName } = state
const handleCloseDropdown = useCallback(() => {
setDropdownOpen(false)
onHide()
}, [onHide])
const handleDropdownOpenChange = async (nextOpen: boolean) => {
if (!nextOpen) {
setDropdownOpen(false)
@ -124,40 +118,51 @@ function PopupItem({
if (!currentProvider) return null
return (
<ComboboxGroup className="mb-1" items={groupItems}>
<div className="sticky top-0 z-1 flex h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<button
type="button"
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => setCollapsed((prev) => !prev)}
<Collapsible
open={modelsOpen}
onOpenChange={setModelsOpen}
className="mb-1"
render={<section aria-labelledby={providerHeadingId} />}
>
<div className="sticky top-0 z-1 flex min-h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<CollapsibleTrigger
id={providerHeadingId}
className="group/provider min-h-0 w-auto min-w-0 justify-start gap-0 rounded-none p-0 text-xs font-medium text-text-tertiary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-tertiary data-panel-open:text-text-tertiary"
>
<span className="truncate">{renderI18nObject(model.label, language)}</span>
<span className="truncate">{providerLabel}</span>
<span
aria-hidden="true"
className={cn(
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary',
collapsed && '-rotate-90',
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-quaternary transition-transform group-data-panel-open/provider:rotate-0 motion-reduce:transition-none',
)}
/>
</button>
</CollapsibleTrigger>
<Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<PopoverTrigger
disabled={!canOpenCredentialDropdown}
render={
<button
type="button"
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
<Button
variant="ghost"
size="small"
className="max-w-[50%] min-w-0 shrink-0 gap-0 px-1.5 py-1 system-xs-medium text-text-tertiary"
>
{isUsingCredits ? (
hasCredits ? (
<>
<span className="i-custom-vender-line-financeandecommerce-credits-coin size-3" />
<span
aria-hidden="true"
className="i-custom-vender-line-financeandecommerce-credits-coin size-3"
/>
<span className="ml-1 truncate">
{t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })}
</span>
</>
) : (
<>
<span className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" />
<span
aria-hidden="true"
className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary"
/>
<span className="ml-1 truncate text-text-warning">
{t(($) => $['modelProvider.selector.creditsExhausted'], { ns: 'common' })}
</span>
@ -177,12 +182,16 @@ function PopupItem({
</>
)}
{canOpenCredentialDropdown && (
<span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" />
<span
aria-hidden="true"
className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary"
/>
)}
</button>
</Button>
}
/>
<PopoverContent placement="bottom-end">
<PopoverTitle className="sr-only">{providerLabel}</PopoverTitle>
{providerDetail && (
<DropdownContent
provider={providerDetail}
@ -195,99 +204,105 @@ function PopupItem({
</PopoverContent>
</Popover>
</div>
{!collapsed &&
model.models.map((modelItem) => {
const isModelCompatible = modelPredicate?.(model, modelItem) ?? true
const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false
const rowClassName = cn(
'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left',
modelItem.status === ModelStatusEnum.active
? 'cursor-pointer hover:bg-state-base-hover'
: 'cursor-not-allowed hover:bg-state-base-hover-alt',
)
const rowContent = (
<>
<div className="flex min-w-0 items-center gap-2">
<ModelIcon
className={cn('size-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn(
'system-sm-medium text-text-secondary',
!isModelCompatible && 'text-text-quaternary',
modelItem.status !== ModelStatusEnum.active && 'opacity-60',
)}
modelItem={modelItem}
nameClassName={modelItem.deprecated ? 'line-through' : undefined}
>
{isModelSuggested && (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={suggestionTip}
className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary"
/>
}
/>
<TooltipContent placement="top">{suggestionTip}</TooltipContent>
</Tooltip>
)}
</ModelName>
</div>
{defaultModel?.model === modelItem.model &&
defaultModel.provider === currentProvider.provider && (
<ComboboxItemIndicator className="shrink-0 text-text-accent">
<span
className="i-custom-vender-line-general-check size-4"
aria-hidden="true"
/>
</ComboboxItemIndicator>
<CollapsiblePanel>
<ul className="pb-1">
{model.models.map((modelItem) => {
const isModelCompatible = modelPredicate?.(model, modelItem) ?? true
const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false
const isSelected =
defaultModel?.model === modelItem.model &&
defaultModel.provider === currentProvider.provider
const needsConfiguration = modelItem.status === ModelStatusEnum.noConfigure
const isSelectable = modelItem.status === ModelStatusEnum.active
const rowClassName = cn(
'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left',
isSelectable
? 'cursor-pointer hover:bg-state-base-hover'
: 'cursor-not-allowed hover:bg-state-base-hover-alt',
)
const rowContent = (
<>
<div className="flex min-w-0 flex-1 items-center gap-2">
<ModelIcon
className="size-5 shrink-0"
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn(
'system-sm-medium text-text-secondary',
!isModelCompatible && 'text-text-quaternary',
!isSelectable && 'opacity-60',
)}
modelItem={modelItem}
nameClassName={modelItem.deprecated ? 'line-through' : undefined}
>
{isModelSuggested && (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={suggestionTip}
className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary"
/>
}
/>
<TooltipContent placement="top">{suggestionTip}</TooltipContent>
</Tooltip>
)}
</ModelName>
</div>
{isSelected && (
<span
aria-hidden="true"
className="i-custom-vender-line-general-check size-4 shrink-0 text-text-accent"
/>
)}
</>
)
const itemRender =
modelItem.status === ModelStatusEnum.noConfigure ? (
</>
)
const row = needsConfiguration ? (
<div className={rowClassName} onPointerDown={onPreviewCardClose}>
{rowContent}
{canCreateCredential && (
<button
type="button"
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
<Button
variant="ghost-accent"
size="small"
className="h-auto shrink-0 p-0 text-xs opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-transparent focus-visible:opacity-100"
onClick={() => void handleOpenModelModal()}
>
{t(($) => $['operation.add'], { ns: 'common' }).toLocaleUpperCase()}
</button>
</Button>
)}
</div>
) : (
<ComboboxItem
value={{
provider: model.provider,
model: modelItem.model,
}}
disabled={modelItem.status !== ModelStatusEnum.active}
className={rowClassName}
<Button
variant="ghost"
size="medium"
aria-current={isSelected ? 'true' : undefined}
disabled={!isSelectable}
className={cn(rowClassName, 'w-[calc(100%-0.5rem)] justify-start')}
onPointerDown={onPreviewCardClose}
onClick={() => onSelect(model.provider, modelItem)}
>
{rowContent}
</ComboboxItem>
</Button>
)
return (
<PreviewCardTrigger
key={modelItem.model}
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider: model, modelItem }}
render={itemRender}
/>
)
})}
</ComboboxGroup>
return (
<li key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
handle={previewCardHandle}
payload={{ provider: model, modelItem }}
render={row}
/>
</li>
)
})}
</ul>
</CollapsiblePanel>
</Collapsible>
)
}

View File

@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxInput, ComboboxInputGroup } from '@langgenius/dify-ui/combobox'
import { Button } from '@langgenius/dify-ui/button'
import {
ScrollArea,
ScrollAreaContent,
@ -9,6 +8,7 @@ import {
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
type ModelSelectorSearchHeaderProps = {
inputValue: string
@ -22,37 +22,13 @@ export function ModelSelectorSearchHeader({
const { t } = useTranslation()
return (
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
<ComboboxInputGroup
className={cn(
'h-8 min-h-8 px-2',
inputValue
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-transparent bg-components-input-bg-normal',
)}
>
<span
className={`mr-0.5 i-ri-search-line size-4 shrink-0 ${inputValue ? 'text-text-tertiary' : 'text-text-quaternary'} `}
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
placeholder={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
/>
{inputValue && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' }) || 'Clear'}
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => onInputValueChange('')}
onPointerDown={(event) => event.preventDefault()}
>
<span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" />
</button>
)}
</ComboboxInputGroup>
</div>
<SearchInput
aria-label={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
className="mx-2 mt-2 mb-1 shrink-0"
placeholder={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
value={inputValue}
onValueChange={onInputValueChange}
/>
)
}
@ -101,9 +77,10 @@ export function ShowIncompatibleModelsButton({
const { t } = useTranslation()
return (
<button
type="button"
className="flex h-10 w-full cursor-pointer items-center px-4 text-left system-xs-regular text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
<Button
variant="ghost"
size="medium"
className="h-10 w-full justify-start rounded-none px-4 text-left system-xs-regular text-text-tertiary"
onClick={onClick}
>
<span className="min-w-0 truncate">
@ -111,7 +88,7 @@ export function ShowIncompatibleModelsButton({
? t(($) => $['modelProvider.selector.hideIncompatibleModels'], { ns: 'common' })
: t(($) => $['modelProvider.selector.showIncompatibleModels'], { ns: 'common' })}
</span>
</button>
</Button>
)
}
@ -124,16 +101,17 @@ export function ModelProviderSettingsFooter({ onOpenSettings }: ModelProviderSet
return (
<div className="shrink-0 border-t border-divider-subtle p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
<Button
variant="ghost"
size="medium"
className="w-full justify-start gap-2 px-3 py-1 text-text-tertiary"
onClick={onOpenSettings}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4 shrink-0" />
<span className="system-xs-medium">
{t(($) => $['modelProvider.selector.modelProviderSettings'], { ns: 'common' })}
</span>
</button>
</Button>
</div>
)
}

View File

@ -1,11 +1,11 @@
import type { ModelSelectorPreviewPayload } from './popup-item'
import type {
ModelSelectorModel,
ModelSelectorModelPredicate,
ModelSelectorProvider,
ModelSelectorValue,
} from './types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import {
createPreviewCardHandle,
PreviewCard,
@ -61,6 +61,7 @@ export type PopupProps = {
modelSuggestionPredicate?: ModelSelectorModelPredicate
onConfigureEmptyState?: () => void
onInputValueChange: (value: string) => void
onSelect: (provider: string, model: ModelSelectorModel) => void
onOpenMarketplace?: () => void
onHide: () => void
}
@ -74,6 +75,7 @@ function Popup({
modelSuggestionPredicate,
onConfigureEmptyState,
onInputValueChange,
onSelect,
onOpenMarketplace,
onHide,
}: PopupProps) {
@ -239,6 +241,13 @@ function Popup({
)
}, [enableMarketplace, modelProviderPlugins])
const searchStatus =
!filteredModelList.length && installedModelList.length > 0
? t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
ns: 'common',
query: inputValue,
})
: null
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
@ -251,34 +260,37 @@ function Popup({
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ComboboxList className="max-h-none overflow-visible p-0">
<div className="pb-1">
{filteredModelList.map((model) => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onHide={onHide}
/>
))}
</div>
</ComboboxList>
<div className="pb-1">
{filteredModelList.map((model) => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onSelect={onSelect}
onHide={onHide}
/>
))}
</div>
<div className="pb-1">
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState onConfigure={onConfigureEmptyState ?? onHide} />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary">
{t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
ns: 'common',
query: inputValue,
})}
</div>
)}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={
searchStatus
? 'px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary'
: 'h-0'
}
>
{searchStatus}
</div>
{scopeFeatures.length > 0 && <CompatibleModelsNotice />}
{shouldShowModelPredicateReveal && (
<ShowIncompatibleModelsButton

View File

@ -35,10 +35,3 @@ export type ModelSelectorModelPredicate = (
provider: ModelSelectorProvider,
modelItem: ModelSelectorModel,
) => boolean
export const isSameModelSelectorValue = (
itemValue: ModelSelectorValue,
value: ModelSelectorValue,
) => itemValue.provider === value.provider && itemValue.model === value.model
export const getModelSelectorValueLabel = (value: ModelSelectorValue) => value.model

View File

@ -0,0 +1,126 @@
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { AppModeEnum } from '@/types/app'
import { AppPicker } from '../app-picker'
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <span aria-hidden="true" data-testid="app-icon" />,
}))
const app = {
id: 'app-1',
name: 'Workflow App',
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
icon: 'W',
icon_background: '#FFFFFF',
icon_url: null,
} satisfies AppPartial
function renderAppPicker(
overrides: Partial<Omit<React.ComponentProps<typeof AppPicker>, 'isShow' | 'onShowChange'>> = {},
) {
const props = {
disabled: false,
trigger: <span>Choose app</span>,
onSelect: vi.fn(),
apps: [app],
isLoading: false,
hasMore: false,
onLoadMore: vi.fn(),
searchText: '',
onSearchChange: vi.fn(),
...overrides,
}
function AppPickerHarness() {
const [open, setOpen] = useState(false)
return <AppPicker {...props} isShow={open} onShowChange={setOpen} />
}
return { props, ...render(<AppPickerHarness />) }
}
describe('AppPicker', () => {
it('should expose a named dialog and keep app choices in the listbox', async () => {
const user = userEvent.setup()
renderAppPicker()
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
const dialog = screen.getByRole('dialog', { name: 'app.appSelector.label' })
const listbox = screen.getByRole('listbox')
const option = screen.getByRole('option', { name: /Workflow App/ })
expect(dialog).toBeInTheDocument()
expect(listbox).toContainElement(option)
})
it('should not show the empty state while loading', async () => {
const user = userEvent.setup()
renderAppPicker({ apps: [], isLoading: true })
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
expect(
screen
.getAllByRole('status')
.some((status) => status.textContent?.includes('common.loading')),
).toBe(true)
expect(screen.queryByText('common.noData')).not.toBeInTheDocument()
})
it('should keep load more outside the listbox and inside the named scroll region', async () => {
const user = userEvent.setup()
renderAppPicker({ hasMore: true })
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
const listbox = screen.getByRole('listbox')
const scrollRegion = screen.getByRole('region', { name: 'app.appSelector.label' })
const loadMoreButton = screen.getByRole('button', { name: 'workflow.common.loadMore' })
expect(listbox).not.toContainElement(loadMoreButton)
expect(scrollRegion).toContainElement(listbox)
expect(scrollRegion).toContainElement(loadMoreButton)
})
it('should clear only the search query and keep focus in the input', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
function AppPickerHarness() {
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
return (
<AppPicker
disabled={false}
trigger={<span>Choose app</span>}
isShow={open}
onShowChange={setOpen}
onSelect={onSelect}
apps={[app]}
isLoading={false}
hasMore={false}
onLoadMore={vi.fn()}
searchText={searchText}
onSearchChange={setSearchText}
/>
)
}
render(<AppPickerHarness />)
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
const searchInput = screen.getByRole('combobox', { name: 'app.appSelector.placeholder' })
await user.type(searchInput, 'workflow')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
clearButton.focus()
await user.keyboard('{Enter}')
expect(searchInput).toHaveValue('')
expect(searchInput).toHaveFocus()
expect(onSelect).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Workflow App/ })).toBeInTheDocument()
})
})

View File

@ -7,17 +7,27 @@ import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useCallback } from 'react'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { AppModeEnum } from '@/types/app'
@ -105,6 +115,7 @@ export function AppPicker({
onSearchChange,
}: AppPickerProps) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const handleValueChange = useCallback(
(app: AppPartial | null) => {
@ -116,6 +127,11 @@ export function AppPicker({
[onSelect, onShowChange],
)
const handleClearSearch = () => {
onSearchChange('')
inputRef.current?.focus()
}
return (
<Combobox<AppPartial>
items={apps}
@ -136,56 +152,76 @@ export function AppPicker({
>
{trigger}
</ComboboxTrigger>
<ComboboxContent
placement={placement}
sideOffset={offset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
placeholder={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
{searchText && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => onSearchChange('')}
>
<ComboboxPortal>
<ComboboxPositioner placement={placement} sideOffset={offset}>
<ComboboxPopup
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
className="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="i-custom-vender-solid-general-x-circle size-3.5"
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
</button>
)}
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{isLoading && <ComboboxStatus>{t(($) => $.loading, { ns: 'common' })}</ComboboxStatus>}
<ComboboxList<AppPartial> className="max-h-none p-0">
{(app) => <AppPickerOption key={app.id} app={app} />}
</ComboboxList>
<ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty>
{hasMore && (
<div className="flex justify-center px-3 py-2">
<Button size="small" disabled={isLoading} onClick={() => onLoadMore()}>
{isLoading
? t(($) => $.loading, { ns: 'common' })
: t(($) => $['common.loadMore'], { ns: 'workflow' })}
</Button>
<ComboboxInput
ref={inputRef}
aria-label={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
placeholder={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
{searchText && (
<IconButton
size="xs"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="ml-1.5 size-3.5 shrink-0 rounded-none text-text-quaternary hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={handleClearSearch}
onMouseDown={(event) => event.preventDefault()}
>
<span
className="i-custom-vender-solid-general-x-circle size-3.5"
aria-hidden="true"
/>
</IconButton>
)}
</ComboboxInputGroup>
</div>
)}
</div>
</div>
</ComboboxContent>
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
<ScrollAreaViewport
role="region"
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
style={{ overflowX: 'hidden' }}
>
<ScrollAreaContent className="p-1" style={{ minWidth: 0 }}>
<ComboboxStatus>
{isLoading ? t(($) => $.loading, { ns: 'common' }) : null}
</ComboboxStatus>
<ComboboxList<AppPartial> className="max-h-none overflow-visible p-0">
{(app) => <AppPickerOption key={app.id} app={app} />}
</ComboboxList>
<ComboboxEmpty>
{!isLoading ? t(($) => $.noData, { ns: 'common' }) : null}
</ComboboxEmpty>
{hasMore && (
<div className="flex justify-center px-3 py-2">
<Button size="small" disabled={isLoading} onClick={() => onLoadMore()}>
{isLoading
? t(($) => $.loading, { ns: 'common' })
: t(($) => $['common.loadMore'], { ns: 'workflow' })}
</Button>
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</div>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
)
}

View File

@ -130,7 +130,10 @@ export function AppSelector({
<button
{...props}
type="button"
className={cn('block w-full border-0 bg-transparent p-0 text-left', props.className)}
className={cn(
'block w-full rounded-lg border-0 bg-transparent p-0 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
props.className,
)}
>
<AppTrigger open={state.open} appDetail={currentAppInfo} />
</button>

View File

@ -177,7 +177,6 @@ const SnippetCard = ({
<div className="flex w-0 grow items-center gap-1">
<div className="mr-10.25 min-w-0 grow">
<TagSelector
placement="bottom-start"
type="snippet"
targetId={snippet.id}
value={snippet.tags}

View File

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentSelectorContent } from '../agent-selector'
const mocks = vi.hoisted(() => ({
@ -33,14 +34,20 @@ vi.mock('@/service/client', () => ({
const manageInConsoleLabel = /manageInAgentConsole/
const startFromScratchLabel = /startFromScratch/
const renderSelector = async ({ onStartFromScratch }: { onStartFromScratch?: () => void } = {}) => {
const renderSelector = async ({
onOpenChange = vi.fn(),
onStartFromScratch,
}: {
onOpenChange?: (open: boolean) => void
onStartFromScratch?: () => void
} = {}) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<AgentSelectorContent
open
onOpenChange={vi.fn()}
onOpenChange={onOpenChange}
onSelect={vi.fn()}
onStartFromScratch={onStartFromScratch}
/>
@ -57,9 +64,39 @@ describe('AgentSelectorContent', () => {
})
it('offers the Agent Console link with agent.manage', async () => {
await renderSelector({ onStartFromScratch: vi.fn() })
const listbox = screen.getByRole('listbox')
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
const manageLink = screen.getByRole('link', { name: manageInConsoleLabel })
expect(manageLink).toHaveAttribute('href', '/agents')
expect(listbox).not.toContainElement(manageLink)
expect(startButton).toHaveClass(
'h-7',
'rounded-md',
'px-2',
'py-1.5',
'system-sm-regular',
'text-text-secondary',
)
expect(manageLink).toHaveClass(
'h-7',
'rounded-md',
'px-2',
'py-1.5',
'system-sm-regular',
'text-text-secondary',
)
})
it('should keep the listbox as the only scroll owner for agent options', async () => {
await renderSelector()
expect(screen.getByText(manageInConsoleLabel)).toBeInTheDocument()
const listbox = screen.getByRole('listbox')
expect(listbox).toHaveClass('max-h-54', 'overflow-y-auto', 'outline-hidden')
expect(listbox.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
})
it('hides the Agent Console link without agent.manage', async () => {
@ -75,10 +112,45 @@ describe('AgentSelectorContent', () => {
await renderSelector({ onStartFromScratch: vi.fn() })
expect(screen.getByText(startFromScratchLabel)).toBeInTheDocument()
const listbox = screen.getByRole('listbox')
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
expect(listbox).not.toContainElement(startButton)
expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument()
})
it('should move focus from the combobox to actions outside the listbox', async () => {
const user = userEvent.setup()
await renderSelector({ onStartFromScratch: vi.fn() })
const input = screen.getByRole('combobox')
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
const listbox = screen.getByRole('listbox')
expect(listbox).not.toContainElement(startButton)
input.focus()
await user.tab()
expect(startButton).toHaveFocus()
})
it('does not dismiss the combobox when pressing a footer action', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
const onStartFromScratch = vi.fn()
await renderSelector({ onOpenChange, onStartFromScratch })
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
await user.pointer({ keys: '[MouseLeft>]', target: startButton })
expect(onOpenChange).not.toHaveBeenCalled()
await user.pointer({ keys: '[/MouseLeft]', target: startButton })
expect(onStartFromScratch).toHaveBeenCalledOnce()
})
it('renders no action row when neither action is available', async () => {
mocks.canManageAgents = false

View File

@ -450,14 +450,14 @@ describe('Blocks', () => {
)
await user.click(screen.getByRole('button', { name: /Agent/ }))
const consoleLink = await screen.findByRole('option', {
const consoleLink = await screen.findByRole('link', {
name: 'agentV2.roster.nodeSelector.manageInAgentConsole',
})
expect(consoleLink).toHaveAttribute('href', '/agents')
expect(consoleLink).toHaveAttribute('target', '_blank')
expect(consoleLink).toHaveAttribute('rel', 'noopener noreferrer')
await user.click(
await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.startFromScratch' }),
await screen.findByRole('button', { name: 'agentV2.roster.nodeSelector.startFromScratch' }),
)
expect(onSelect).toHaveBeenCalledWith(BlockEnum.AgentV2, {

View File

@ -2,6 +2,7 @@ import type { AgentInviteOptionResponse } from '@dify/contracts/api/console/agen
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { NodeDefault } from '../types'
import type { AgentRosterNodeData } from './types'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
@ -28,10 +29,6 @@ import BlockIcon from '../block-icon'
const AGENT_SELECTOR_PAGE_SIZE = 8
type AgentSelectorOption = AgentInviteOptionResponse | AgentSelectorActionOption
type AgentSelectorActionOption = 'start-from-scratch' | 'manage-in-agent-console'
export function AgentSelectorContent({
open,
onOpenChange,
@ -62,55 +59,42 @@ export function AgentSelectorContent({
})
const agents = agentsQuery.data?.data ?? []
const canManageAgents = useCanManageAgents()
const actionOptions: AgentSelectorActionOption[] = [
// Start from scratch stays available to everyone: it only writes the node's
// own inline draft and never reaches the Agent Console.
...(onStartFromScratch ? (['start-from-scratch'] as const) : []),
...(canManageAgents ? (['manage-in-agent-console'] as const) : []),
]
const options: AgentSelectorOption[] = [...agents, ...actionOptions]
const getOptionLabel = (option: AgentSelectorOption) => {
if (isAgentSelectorActionOption(option)) {
if (option === 'start-from-scratch')
return t(($) => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' })
return t(($) => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' })
}
return option.name
}
const handleInputValueChange = (nextSearchText: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setSearchText(nextSearchText)
}
const handleValueChange = (option: AgentSelectorOption | null) => {
if (!option) return
if (isAgentSelectorActionOption(option)) {
if (option === 'start-from-scratch') onStartFromScratch?.()
return
}
if (!option.active_config_snapshot_id) {
const handleValueChange = (agent: AgentInviteOptionResponse | null) => {
if (!agent) return
if (!agent.active_config_snapshot_id) {
toast.error(t(($) => $['nodes.agent.modelNotSelected'], { ns: 'workflow' }))
return
}
onSelect(toAgentRosterNodeData(option))
onSelect(toAgentRosterNodeData(agent))
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) onOpenChange(false)
}
const isLoading = agentsQuery.isPending
const statusText = isLoading
? t(($) => $.loading, { ns: 'common' })
: agentsQuery.isError
? t(($) => $['roster.loadingError'], { ns: 'agentV2' })
: agents.length === 0
? debouncedSearchText
? t(($) => $['roster.emptySearch'], { ns: 'agentV2' })
: t(($) => $['roster.empty'], { ns: 'agentV2' })
: null
const hasActions = !!onStartFromScratch || canManageAgents
return (
<div className="w-60 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<Combobox<AgentSelectorOption>
<Combobox<AgentInviteOptionResponse>
filter={null}
inline
inputValue={searchText}
items={options}
itemToStringLabel={getOptionLabel}
itemToStringValue={getAgentSelectorOptionValue}
items={agents}
itemToStringLabel={(agent) => agent.name}
itemToStringValue={(agent) => agent.id}
open={open}
value={null}
onInputValueChange={handleInputValueChange}
@ -130,84 +114,84 @@ export function AgentSelectorContent({
/>
</ComboboxInputGroup>
</div>
<ComboboxList className="max-h-none overflow-visible p-0">
<div role="presentation" className="max-h-54 overflow-y-auto p-1">
{isLoading && (
<AgentSelectorLoadingSkeleton label={t(($) => $.loading, { ns: 'common' })} />
<ComboboxStatus className="system-xs-regular">{statusText}</ComboboxStatus>
{isLoading ? (
<div className="max-h-54 overflow-hidden p-1">
<AgentSelectorLoadingSkeleton />
</div>
) : (
<ComboboxList className="max-h-54 p-1 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-state-accent-solid focus-visible:outline-solid">
{!agentsQuery.isError &&
agents.map((agent) => <AgentSelectorItem key={agent.id} agent={agent} />)}
</ComboboxList>
)}
{hasActions && (
<div className="border-t border-divider-subtle p-1">
{onStartFromScratch && (
<Button
variant="ghost"
size="medium"
className="h-7 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary"
onClick={onStartFromScratch}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate">
{t(($) => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' })}
</span>
</Button>
)}
{!isLoading && agentsQuery.isError && (
<ComboboxStatus className="px-3 py-2 system-xs-regular">
{t(($) => $['roster.loadingError'], { ns: 'agentV2' })}
</ComboboxStatus>
)}
{!isLoading && !agentsQuery.isError && (
<>
{agents.length === 0 && (
<ComboboxStatus className="px-3 py-2 system-xs-regular">
{debouncedSearchText
? t(($) => $['roster.emptySearch'], { ns: 'agentV2' })
: t(($) => $['roster.empty'], { ns: 'agentV2' })}
</ComboboxStatus>
{canManageAgents && (
<Link
href="/agents"
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: 'ghost', size: 'medium' }),
'h-7 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary',
)}
{agents.map((agent) => (
<AgentSelectorItem key={agent.id} agent={agent} />
))}
</>
onClick={() => onOpenChange(false)}
>
<span
aria-hidden
className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary"
/>
<span className="min-w-0 flex-1 truncate">
{t(($) => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' })}
</span>
</Link>
)}
</div>
{actionOptions.length > 0 && (
<div role="presentation" className="border-t border-divider-subtle p-1">
{actionOptions.map((option) => (
<AgentSelectorActionItem key={option} option={option} />
))}
</div>
)}
</ComboboxList>
)}
</Combobox>
</div>
)
}
function AgentSelectorLoadingSkeleton({ label }: { label: string }) {
function AgentSelectorLoadingSkeleton() {
return (
<ComboboxStatus className="p-0">
<span className="sr-only">{label}</span>
<div className="relative overflow-hidden" aria-hidden>
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-2 py-1.5 pr-3 pl-2 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="size-8 shrink-0 rounded-full bg-text-quaternary" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-2 w-20 rounded-xs bg-text-quaternary" />
<div className="h-2 w-28 rounded-xs bg-text-quaternary" />
</div>
<div className="relative overflow-hidden" aria-hidden>
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-2 py-1.5 pr-3 pl-2 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="size-8 shrink-0 rounded-full bg-text-quaternary" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-2 w-20 rounded-xs bg-text-quaternary" />
<div className="h-2 w-28 rounded-xs bg-text-quaternary" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
))}
</div>
</ComboboxStatus>
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
)
}
function getAgentSelectorOptionValue(option: AgentSelectorOption) {
if (isAgentSelectorActionOption(option)) return option
return option.id
}
function isAgentSelectorActionOption(
option: AgentSelectorOption,
): option is AgentSelectorActionOption {
return typeof option === 'string'
}
function toAgentRosterNodeData(agent: AgentInviteOptionResponse): AgentRosterNodeData {
return {
description: agent.description,
@ -250,38 +234,6 @@ function AgentSelectorItem({ agent }: { agent: AgentInviteOptionResponse }) {
)
}
function AgentSelectorActionItem({ option }: { option: AgentSelectorActionOption }) {
const { t } = useTranslation('agentV2')
const isStartFromScratch = option === 'start-from-scratch'
return (
<ComboboxItem
value={option}
render={
isStartFromScratch ? undefined : (
<Link href="/agents" target="_blank" rel="noopener noreferrer" />
)
}
className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary"
>
<ComboboxItemText className="flex items-center gap-2 px-0 system-sm-regular text-text-secondary">
<span
aria-hidden
className={cn(
'size-4 shrink-0 text-text-tertiary',
isStartFromScratch ? 'i-ri-add-line' : 'i-ri-arrow-right-up-line',
)}
/>
<span className="min-w-0 flex-1 truncate">
{isStartFromScratch
? t(($) => $['roster.nodeSelector.startFromScratch'])
: t(($) => $['roster.nodeSelector.manageInAgentConsole'])}
</span>
</ComboboxItemText>
</ComboboxItem>
)
}
export function AgentBlockItem({
block,
onSelect,
@ -303,9 +255,10 @@ export function AgentBlockItem({
<PopoverTrigger
openOnHover
render={
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
<Button
variant="ghost"
size="medium"
className="w-full justify-start gap-0 px-3 text-left data-popup-open:bg-state-base-hover"
>
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
<span className="min-w-0 grow truncate system-sm-medium text-text-secondary">
@ -321,7 +274,7 @@ export function AgentBlockItem({
aria-hidden
className="i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-tertiary"
/>
</button>
</Button>
}
/>
<PopoverContent

View File

@ -110,4 +110,48 @@ describe('AgentLogSourcePicker', () => {
expect(workflowOption).toHaveAttribute('data-selected')
expect(workflowOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('should show one named popup state and keep retry outside the listbox', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
render(
<AgentLogSourcePicker
value={[]}
groups={[]}
isLoading={false}
isError
onRetry={onRetry}
onChange={vi.fn()}
/>,
)
await user.click(
screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.label',
}),
)
const searchInput = screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.searchLabel',
})
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
expect(
screen.getByRole('dialog', {
name: 'agentV2.agentDetail.logs.filters.source.label',
}),
).toBeInTheDocument()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(
screen.queryByText('agentV2.agentDetail.logs.filters.source.empty'),
).not.toBeInTheDocument()
searchInput.focus()
expect(searchInput).toHaveFocus()
await user.tab()
expect(retryButton).toHaveFocus()
await user.click(retryButton)
expect(onRetry).toHaveBeenCalledTimes(1)
})
})

View File

@ -3,13 +3,11 @@ import type {
AgentLogSourceResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import type { TFunction } from 'i18next'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
@ -18,6 +16,10 @@ import {
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
@ -91,80 +93,84 @@ export function AgentLogSourcePicker({
}}
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent popupClassName="w-80 p-0">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
aria-label={t(($) => $['agentDetail.logs.filters.source.searchLabel'])}
placeholder={t(($) => $['agentDetail.logs.filters.source.searchPlaceholder'])}
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
</ComboboxInputGroup>
</div>
{isLoading && (
<SourcePickerStatus>
{t(($) => $['agentDetail.logs.filters.source.loading'])}
</SourcePickerStatus>
)}
{isError && (
<SourcePickerStatus className="flex items-center justify-center gap-2">
<span>{t(($) => $['agentDetail.logs.filters.source.loadFailed'])}</span>
<Button variant="secondary" size="small" onClick={onRetry}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</SourcePickerStatus>
)}
{!isLoading && !isError && (
<>
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
{(group) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
{getSourceGroupLabel(group, t)}
</ComboboxGroupLabel>
<ComboboxCollection<AgentLogSourceResponse>>
{(source) => (
<ComboboxItem
key={source.id}
value={source}
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
render={(props, state) => (
<div {...props} className={props.className}>
<ComboboxItemText className="flex min-w-0 items-center gap-2 px-0 system-sm-regular">
<SourceCheckbox checked={state.selected} />
<LogSourceIcon source={source} />
<span className="min-w-0 flex-1 truncate">{source.app_name}</span>
</ComboboxItemText>
</div>
)}
/>
)}
</ComboboxCollection>
</ComboboxGroup>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup
aria-label={t(($) => $['agentDetail.logs.filters.source.label'])}
className="w-80 p-0"
>
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
aria-label={t(($) => $['agentDetail.logs.filters.source.searchLabel'])}
placeholder={t(($) => $['agentDetail.logs.filters.source.searchPlaceholder'])}
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
</ComboboxInputGroup>
</div>
<div
className={cn(
isLoading || isError
? 'flex items-center justify-center gap-2 px-3 py-3 text-center system-xs-regular text-text-tertiary'
: 'h-0',
)}
</ComboboxList>
>
<ComboboxStatus className="p-0 system-xs-regular">
{isLoading
? t(($) => $['agentDetail.logs.filters.source.loading'])
: isError
? t(($) => $['agentDetail.logs.filters.source.loadFailed'])
: null}
</ComboboxStatus>
{isError && (
<Button variant="secondary" size="small" onClick={onRetry}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
)}
</div>
{!isLoading && !isError && (
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
{(group) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
{getSourceGroupLabel(group, t)}
</ComboboxGroupLabel>
<ComboboxCollection<AgentLogSourceResponse>>
{(source) => (
<ComboboxItem
key={source.id}
value={source}
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
render={(props, state) => (
<div {...props} className={props.className}>
<ComboboxItemText className="flex min-w-0 items-center gap-2 px-0 system-sm-regular">
<SourceCheckbox checked={state.selected} />
<LogSourceIcon source={source} />
<span className="min-w-0 flex-1 truncate">{source.app_name}</span>
</ComboboxItemText>
</div>
)}
/>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
)}
<ComboboxEmpty className="px-3 py-3 text-center system-xs-regular">
{t(($) => $['agentDetail.logs.filters.source.empty'])}
{!isLoading && !isError ? t(($) => $['agentDetail.logs.filters.source.empty']) : null}
</ComboboxEmpty>
</>
)}
</ComboboxContent>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
)
}
function SourcePickerStatus({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('px-3 py-3 text-center system-xs-regular text-text-tertiary', className)}>
{children}
</div>
)
}
function SourceCheckbox({ checked }: { checked: boolean }) {
return (
<span

View File

@ -1,5 +1,5 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { TagFilter } from '../components/tag-filter'
@ -213,6 +213,24 @@ describe('TagFilter', () => {
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
})
it('should expose popup actions after the combobox in keyboard order', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
const manageButton = screen.getByRole('button', { name: i18n.manageTags })
const listbox = screen.getByRole('listbox')
expect(screen.getByRole('dialog', { name: i18n.placeholder })).toBeInTheDocument()
expect(listbox).not.toContainElement(manageButton)
await waitFor(() => expect(input).toHaveFocus())
await user.tab()
expect(manageButton).toHaveFocus()
})
it('should hide tag management action without tag management permission', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = []
@ -282,9 +300,11 @@ describe('TagFilter', () => {
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
const clearButton = screen.getByRole('button', { name: i18n.operationClear })
await user.click(clearButton)
clearButton.focus()
await user.keyboard('{Enter}')
expect(searchInput).toHaveValue('')
expect(searchInput).toHaveFocus()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('Frontend')).toBeInTheDocument()

View File

@ -144,9 +144,13 @@ describe('TagSearchContent', () => {
expect(input).toHaveValue('Back')
vi.clearAllMocks()
await user.click(screen.getByRole('button', { name: i18n.operationClear }))
const clearButton = screen.getByRole('button', { name: i18n.operationClear })
await user.pointer({ keys: '[MouseLeft>]', target: clearButton })
expect(input).toHaveFocus()
await user.pointer({ keys: '[/MouseLeft]', target: clearButton })
expect(input).toHaveValue('')
expect(input).toHaveFocus()
expect(onValueChangeSpy).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute(
'aria-selected',

View File

@ -18,7 +18,6 @@ export const AppCardTags = ({
}: AppCardTagsProps) => {
return (
<TagSelector
placement="bottom-start"
type="app"
targetId={appId}
value={tags}

View File

@ -20,7 +20,6 @@ export const DatasetCardTags = ({
canBindOrUnbindTags,
}: DatasetCardTagsProps) => (
<TagSelector
placement="bottom-start"
type="knowledge"
targetId={datasetId}
value={tags}

View File

@ -1,7 +1,14 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { ComboboxProps } from '@langgenius/dify-ui/combobox'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import {
Combobox,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -115,10 +122,10 @@ export const TagFilter = ({
</span>
</ComboboxTrigger>
{!!value.length && (
<button
type="button"
<IconButton
size="xs"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 rounded-md border-none bg-transparent p-px focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className="group/clear absolute top-1/2 right-2 -translate-y-1/2"
onClick={(event) => {
event.stopPropagation()
onChange([])
@ -128,21 +135,24 @@ export const TagFilter = ({
className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary"
aria-hidden="true"
/>
</button>
</IconButton>
)}
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<TagSearchContent
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
onOpenTagManagement={onOpenTagManagement}
onClose={() => setOpen(false)}
/>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner placement="bottom-start" sideOffset={4}>
<ComboboxPopup
aria-label={triggerLabel}
className="w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<TagSearchContent
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
onOpenTagManagement={onOpenTagManagement}
onClose={() => setOpen(false)}
/>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</div>
</Combobox>
)

View File

@ -1,5 +1,6 @@
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { TagComboboxItem } from './tag-combobox-item'
import { Button } from '@langgenius/dify-ui/button'
import {
ComboboxEmpty,
ComboboxInput,
@ -11,8 +12,9 @@ import {
ComboboxSeparator,
useComboboxFilteredItems,
} from '@langgenius/dify-ui/combobox'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useAtomValue } from 'jotai'
import { Fragment } from 'react'
import { Fragment, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { hasPermission } from '@/utils/permission'
@ -37,12 +39,18 @@ export const TagSearchContent = ({
canBindOrUnbindTags = false,
}: TagSearchContentProps) => {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
const handleClearInput = () => {
onInputValueChange('')
inputRef.current?.focus()
}
return (
<div className="relative w-full">
<div className="p-2 pb-1">
@ -52,21 +60,22 @@ export const TagSearchContent = ({
className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary"
/>
<ComboboxInput
ref={inputRef}
aria-label={placeholder}
name={`tag-search-${type}`}
placeholder={placeholder}
className="pl-2"
/>
{inputValue && (
<button
type="button"
<IconButton
size="sm"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="mr-1.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active"
onClick={() => onInputValueChange('')}
onPointerDown={(event) => event.preventDefault()}
className="mr-1.5 shrink-0 hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover"
onClick={handleClearInput}
onMouseDown={(event) => event.preventDefault()}
>
<span className="i-ri-close-line size-4" aria-hidden="true" />
</button>
</IconButton>
)}
</ComboboxInputGroup>
</div>
@ -119,9 +128,10 @@ export const TagSearchContent = ({
<>
<ComboboxSeparator />
<div className="p-1">
<button
type="button"
className="flex w-full cursor-pointer touch-manipulation items-center gap-x-1 rounded-lg px-2 py-1.5 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
<Button
variant="ghost"
size="medium"
className="w-full justify-start gap-x-1 px-2 py-1.5 text-left"
onClick={() => {
onOpenTagManagement?.()
onClose?.()
@ -134,7 +144,7 @@ export const TagSearchContent = ({
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{t(($) => $['tag.manageTags'], { ns: 'common' })}
</span>
</button>
</Button>
</div>
</>
)}

View File

@ -1,12 +1,14 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type {
ComboboxContentProps,
ComboboxProps,
ComboboxTriggerProps,
} from '@langgenius/dify-ui/combobox'
import type { ComboboxProps, ComboboxTriggerProps } from '@langgenius/dify-ui/combobox'
import type { TagComboboxItem } from './tag-combobox-item'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import {
Combobox,
ComboboxPopup,
ComboboxPortal,
ComboboxPositioner,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
@ -47,18 +49,7 @@ type TagSelectorRootProps = Omit<
| 'onOpenChangeComplete'
| 'children'
>
type TagSelectorContentProps = Pick<
ComboboxContentProps,
| 'placement'
| 'sideOffset'
| 'alignOffset'
| 'portalProps'
| 'positionerProps'
| 'popupProps'
| 'popupClassName'
>
export type TagSelectorProps = TagSelectorRootProps &
TagSelectorContentProps &
Pick<ComboboxTriggerProps, 'className' | 'onClick'> & {
targetId: string
type: TagType
@ -77,13 +68,6 @@ export const TagSelector = ({
onClick,
onOpenTagManagement = () => {},
onTagsChange,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
portalProps,
positionerProps,
popupProps,
popupClassName,
...rootProps
}: TagSelectorProps) => {
const { t } = useTranslation()
@ -274,27 +258,23 @@ export const TagSelector = ({
className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-hover/tag-area:hidden group-focus-visible/tag-area:hidden group-data-popup-open/tag-area:hidden"
/>
</ComboboxTrigger>
<ComboboxContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
portalProps={portalProps}
positionerProps={positionerProps}
popupProps={popupProps}
popupClassName={cn(
'w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]',
popupClassName,
)}
>
<TagSearchContent
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>
</ComboboxContent>
<ComboboxPortal>
<ComboboxPositioner placement="bottom-start" sideOffset={4}>
<ComboboxPopup
aria-label={triggerLabel}
className="w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<TagSearchContent
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
)
}