fix(dify-ui): align picker stories with Base UI (#36680)

This commit is contained in:
yyh 2026-05-26 15:59:59 +08:00 committed by GitHub
parent fb07b43107
commit 533929d314
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 295 additions and 86 deletions

View File

@ -218,6 +218,8 @@ describe('Autocomplete wrappers', () => {
await expect.element(screen.getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
await expect.element(screen.getByTestId('empty')).toHaveClass('empty:p-0')
expect(screen.getByTestId('empty').element().getBoundingClientRect().height).toBe(0)
expect(screen.getByText('Workflow').element().parentElement?.querySelector('.i-ri-arrow-right-line')).toHaveAttribute('aria-hidden', 'true')
})
@ -248,5 +250,34 @@ describe('Autocomplete wrappers', () => {
await expect.element(screen.getByText('Workflow')).toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
it('should navigate function-rendered items with arrow keys', async () => {
const screen = await renderWithSafeViewport(
<Autocomplete open defaultValue="" items={['workflow', 'dataset', 'app']}>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Search resources" />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: string) => (
<AutocompleteItem key={item} value={item}>
<AutocompleteItemText>{item}</AutocompleteItemText>
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>,
)
const input = asHTMLElement(screen.getByRole('combobox', { name: 'Search resources' }).element())
input.focus()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'workflow' })).toHaveAttribute('data-highlighted')
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'dataset' })).toHaveAttribute('data-highlighted')
})
})
})

View File

@ -160,12 +160,36 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in
const getSuggestionLabel = (item: Suggestion) => item.label
const SuggestionItem = ({
item,
dense,
}: {
item: Suggestion
dense?: boolean
}) => (
<AutocompleteItem value={item}>
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
// Only virtualized items receive an explicit index; ordinary lists must let Base UI register items by DOM order for keyboard navigation.
const VirtualizedSuggestionItem = ({
item,
index,
dense,
}: {
item: Suggestion
index?: number
index: number
dense?: boolean
}) => (
<AutocompleteItem value={item} index={index}>
@ -186,12 +210,10 @@ const SuggestionItem = ({
const TagSuggestionItem = ({
item,
index,
}: {
item: Suggestion
index?: number
}) => (
<AutocompleteItem value={item} index={index}>
<AutocompleteItem value={item}>
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{item.description && <span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">{item.description}</span>}
</AutocompleteItem>
@ -215,8 +237,8 @@ const BasicTagAutocomplete = ({
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<TagSuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty>
@ -327,8 +349,8 @@ const AsyncSearchDemo = () => {
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
@ -384,7 +406,7 @@ const VirtualizedSuggestionList = ({
transform: `translateY(${virtualItem.start}px)`,
}}
>
<SuggestionItem item={item} index={virtualItem.index} />
<VirtualizedSuggestionItem item={item} index={virtualItem.index} />
</div>
)
})}
@ -455,8 +477,8 @@ const FuzzyMatchingDemo = () => {
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<AutocompleteItem key={item.value} value={item} index={index}>
{(item: Suggestion) => (
<AutocompleteItem key={item.value} value={item}>
{item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />}
<div className="min-w-0 grow">
<AutocompleteItemText className="block px-0">
@ -528,8 +550,8 @@ export const InlineAutocomplete: Story = {
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} dense />
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} dense />
)}
</AutocompleteList>
<AutocompleteEmpty>No inline completion. Continue typing freely.</AutocompleteEmpty>
@ -586,8 +608,8 @@ export const LimitResults: Story = {
<LimitedStatus total={workflowSuggestions.length} />
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No suggestion. Submit the typed text instead.</AutocompleteEmpty>
@ -674,8 +696,8 @@ export const Empty: Story = {
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<TagSuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. The custom text remains valid.</AutocompleteEmpty>
@ -696,8 +718,8 @@ export const DisabledAndReadOnly: Story = {
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<TagSuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<TagSuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
</AutocompleteContent>
@ -710,8 +732,8 @@ export const DisabledAndReadOnly: Story = {
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteList>
{(item: Suggestion, index: number) => (
<SuggestionItem key={item.value} item={item} index={index} />
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
</AutocompleteContent>

View File

@ -346,7 +346,7 @@ export function AutocompleteEmpty({
}: BaseAutocomplete.Empty.Props) {
return (
<BaseAutocomplete.Empty
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:h-0 empty:p-0', className)}
{...props}
/>
)

View File

@ -277,6 +277,8 @@ describe('Combobox wrappers', () => {
await expect.element(screen.getByTestId('list').getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')
await expect.element(screen.getByTestId('empty')).toHaveClass('empty:p-0')
expect(screen.getByTestId('empty').element().getBoundingClientRect().height).toBe(0)
expect(screen.getByTestId('list').getByText('Workflow').element().parentElement?.querySelector('.i-ri-check-line')).toHaveAttribute('aria-hidden', 'true')
})
@ -307,6 +309,36 @@ describe('Combobox wrappers', () => {
await expect.element(screen.getByTestId('custom-list').getByText('Workflow')).toHaveClass('custom-text')
await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator')
})
it('should navigate function-rendered items with arrow keys', async () => {
const screen = await renderWithSafeViewport(
<Combobox defaultValue="workflow" items={['workflow', 'dataset', 'app']}>
<ComboboxInputGroup>
<ComboboxInput aria-label="Search resources" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
<ComboboxItemText>{item}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>,
)
const input = asHTMLElement(screen.getByRole('combobox', { name: 'Search resources' }).element())
input.focus()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'workflow' })).toHaveAttribute('data-highlighted')
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'dataset' })).toHaveAttribute('data-highlighted')
})
})
describe('Multiple selection chips', () => {
@ -314,19 +346,21 @@ describe('Combobox wrappers', () => {
const screen = await renderWithSafeViewport(
<Combobox multiple defaultValue={['maya']} items={['maya', 'nora']}>
<ComboboxInputGroup>
<ComboboxValue>
{(selectedValue: string[]) => (
<ComboboxChips className="custom-chips" data-testid="chips">
{selectedValue.map(item => (
<ComboboxChip key={item} className="custom-chip">
<span>{item}</span>
<ComboboxChipRemove data-testid="remove-chip" />
</ComboboxChip>
))}
</ComboboxChips>
)}
</ComboboxValue>
<ComboboxInput aria-label="Reviewers" />
<ComboboxChips className="custom-chips" data-testid="chips">
<ComboboxValue>
{(selectedValue: string[]) => (
<>
{selectedValue.map(item => (
<ComboboxChip key={item} className="custom-chip">
<span>{item}</span>
<ComboboxChipRemove data-testid="remove-chip" />
</ComboboxChip>
))}
<ComboboxInput aria-label="Reviewers" />
</>
)}
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
</Combobox>,
)
@ -341,19 +375,21 @@ describe('Combobox wrappers', () => {
const screen = await renderWithSafeViewport(
<Combobox multiple defaultValue={['maya']} items={['maya']}>
<ComboboxInputGroup>
<ComboboxValue>
{(selectedValue: string[]) => (
<ComboboxChips>
{selectedValue.map(item => (
<ComboboxChip key={item}>
<span id="remove-maya">Remove Maya</span>
<ComboboxChipRemove aria-labelledby="remove-maya" />
</ComboboxChip>
))}
</ComboboxChips>
)}
</ComboboxValue>
<ComboboxInput aria-label="Reviewers" />
<ComboboxChips>
<ComboboxValue>
{(selectedValue: string[]) => (
<>
{selectedValue.map(item => (
<ComboboxChip key={item}>
<span id="remove-maya">Remove Maya</span>
<ComboboxChipRemove aria-labelledby="remove-maya" />
</ComboboxChip>
))}
<ComboboxInput aria-label="Reviewers" />
</>
)}
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
</Combobox>,
)

View File

@ -177,11 +177,11 @@ const defaultDataSource = dataSourceOptions[0]!
const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!, reviewerOptions[2]!, reviewerOptions[3]!]
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
const defaultTag = tagOptions[2]!
const renderOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
const renderOptionItem = (option: Option) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
<span className="min-w-0 flex-1">
@ -193,13 +193,27 @@ const renderOptionItem = (option: Option, index?: number) => (
</ComboboxItem>
)
const renderSimpleOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index}>
const renderSimpleOptionItem = (option: Option) => (
<ComboboxItem key={option.value} value={option}>
<ComboboxItemText>{option.label}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)
// 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) => (
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
<span className="min-w-0 flex-1">
<span className="block truncate text-text-secondary system-sm-medium">{option.label}</span>
{option.meta && <span className="block truncate text-text-tertiary system-xs-regular">{option.meta}</span>}
</span>
</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)
const PopupSearchInput = ({
label,
placeholder,
@ -282,7 +296,7 @@ const VirtualizedModelList = ({
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderOptionItem(option, virtualItem.index)}
{renderVirtualizedOptionItem(option, virtualItem.index)}
</div>
)
})}
@ -314,7 +328,6 @@ const VirtualizedLongListDemo = () => {
value={value}
onValueChange={setValue}
virtualized
autoHighlight
onItemHighlighted={(item, details) => {
scrollHighlightedVirtualItem(item, details, virtualizerRef.current)
}}
@ -364,7 +377,6 @@ const AsyncDirectoryDemo = () => {
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
autoHighlight
>
<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" />
@ -384,6 +396,70 @@ const AsyncDirectoryDemo = () => {
)
}
const AsyncReviewerDemo = () => {
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option[]>([reviewerOptions[1]!])
const [items, setItems] = useState(reviewerOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
const matches = query
? reviewerOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: reviewerOptions
setItems(matches)
setLoading(false)
}, 450)
return () => window.clearTimeout(timeout)
}, [inputValue])
const selectedItems = value.filter(selected => !items.some(item => item.value === selected.value))
return (
<FieldRoot name="asyncReviewers" className={fieldWidth}>
<FieldLabel>Async reviewers</FieldLabel>
<Combobox
items={[...selectedItems, ...items]}
multiple
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips>
<ComboboxValue>
{(selectedValue: Option[]) => (
<>
{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" />
</>
)}
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading reviewer matches…' : `${items.length} selectable reviewers`}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No reviewer matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
<FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription>
</FieldRoot>
)
}
const meta = {
title: 'Base/Form/Combobox',
component: Combobox,
@ -403,9 +479,9 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<ComboboxLabel>Connect source</ComboboxLabel>
<FieldRoot name="dataSource" 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" />
@ -416,7 +492,7 @@ export const Default: Story = {
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldRoot>
),
}
@ -424,7 +500,7 @@ export const FormField: Story = {
render: () => (
<FieldRoot name="sourceConnector" className={fieldWidth}>
<FieldLabel>Connect source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<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" />
@ -443,7 +519,7 @@ export const FormField: Story = {
export const CompactTriggerWithPopupSearch: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource} autoHighlight>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource}>
<ComboboxLabel>Data source</ComboboxLabel>
<ComboboxTrigger aria-label="Data source">
<ComboboxValue placeholder="Choose source" />
@ -461,22 +537,28 @@ export const AsyncSearchSingle: Story = {
render: () => <AsyncDirectoryDemo />,
}
export const AsyncSearchMultiple: Story = {
render: () => <AsyncReviewerDemo />,
}
export const Sizes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<Combobox key={size} items={sizeOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxLabel>{`${size[0]!.toUpperCase()}${size.slice(1)}`}</ComboboxLabel>
<ComboboxInputGroup size={size} className="px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput size={size} placeholder="Search providers…" className="px-1" />
<ComboboxClear size={size} className="mr-0.5" />
<ComboboxInputTrigger size={size} className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldRoot key={size} name={`provider-${size}`}>
<FieldLabel>{`${size[0]!.toUpperCase()}${size.slice(1)}`}</FieldLabel>
<Combobox items={sizeOptions} defaultValue={defaultProvider}>
<ComboboxInputGroup size={size} className="px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput size={size} placeholder="Search providers…" className="px-1" />
<ComboboxClear size={size} className="mr-0.5" />
<ComboboxInputTrigger size={size} className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</FieldRoot>
))}
</div>
),
@ -485,7 +567,7 @@ export const Sizes: Story = {
export const Grouped: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={toolGroups} defaultValue={defaultTool} autoHighlight>
<Combobox items={toolGroups} defaultValue={defaultTool}>
<ComboboxLabel>Workflow tool</ComboboxLabel>
<ComboboxTrigger aria-label="Workflow tool">
<ComboboxValue placeholder="Select tool" />
@ -505,8 +587,8 @@ const MultipleChipsDemo = () => {
return (
<FieldRoot name="reviewers" className={fieldWidth}>
<FieldLabel>Reviewers</FieldLabel>
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue} autoHighlight>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1 pr-1">
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue}>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips>
<ComboboxValue>
{(selectedValue: Option[]) => (
@ -522,8 +604,6 @@ const MultipleChipsDemo = () => {
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxClear className="mt-0.5 mr-0.5" />
<ComboboxInputTrigger className="mt-0.5 mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
@ -546,7 +626,7 @@ export const EmptyAndStatus: Story = {
render: () => (
<FieldRoot name="connector" className={fieldWidth}>
<FieldLabel>Connector</FieldLabel>
<Combobox items={emptyOptions} defaultInputValue="salesforce" autoHighlight>
<Combobox items={emptyOptions} defaultInputValue="salesforce">
<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 connectors…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
@ -567,8 +647,8 @@ export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<FieldRoot name="disabledProvider" disabled>
<FieldLabel>Disabled provider</FieldLabel>
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>

View File

@ -436,7 +436,7 @@ export function ComboboxEmpty({
}: BaseCombobox.Empty.Props) {
return (
<BaseCombobox.Empty
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:h-0 empty:p-0', className)}
{...props}
/>
)

View File

@ -173,7 +173,7 @@ describe('Select wrappers', () => {
})
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveAttribute('data-readonly')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-readonly:bg-transparent')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-readonly:bg-components-input-bg-normal')
})
it('should hide arrow icon via CSS when Root is readOnly', async () => {
@ -319,6 +319,41 @@ describe('Select wrappers', () => {
await expect.element(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument()
})
it('should navigate items with arrow keys', async () => {
const screen = await render(
<Select defaultValue="seattle">
<SelectTrigger aria-label="city select">
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="tokyo">
<SelectItemText>Tokyo</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
const trigger = asHTMLElement(screen.getByRole('combobox', { name: 'city select' }).element())
trigger.focus()
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'Seattle' })).toHaveAttribute('data-highlighted')
const highlightedItem = asHTMLElement(screen.getByRole('option', { name: 'Seattle' }).element())
highlightedItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await expect.element(screen.getByRole('option', { name: 'New York' })).toHaveAttribute('data-highlighted')
})
it('should not call onValueChange when disabled item is clicked', async () => {
const onValueChange = vi.fn()

View File

@ -16,6 +16,11 @@ import {
const triggerWidth = 'w-64'
const cityItems = [
{ label: 'Seattle', value: 'seattle' },
{ label: 'New York', value: 'new-york' },
]
const meta = {
title: 'Base/Form/Select',
component: Select,
@ -238,7 +243,7 @@ export const Disabled: Story = {
export const ReadOnly: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle" readOnly>
<Select defaultValue="seattle" items={cityItems} readOnly>
<SelectTrigger aria-label="City">
<SelectValue />
</SelectTrigger>

View File

@ -26,7 +26,7 @@ const selectTriggerVariants = cva(
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-readonly:cursor-default data-readonly:bg-components-input-bg-normal data-readonly:hover:bg-components-input-bg-normal',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
'data-disabled:data-placeholder:text-components-input-text-disabled',
],

View File

@ -104,7 +104,7 @@ function Chip<T extends ItemValue>({
<SelectItem
key={item.value}
value={item.value}
className="mx-1 gap-2 rounded-lg px-2 py-[6px] pl-3 select-none"
className="mx-1 gap-2 rounded-lg px-2 py-1.5 pl-3 select-none"
>
<SelectItemText className="mr-0 px-0">
<span title={item.name} className="block truncate system-sm-medium text-text-secondary">{item.name}</span>