mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
fix(dify-ui): align picker stories with Base UI (#36680)
This commit is contained in:
parent
fb07b43107
commit
533929d314
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
],
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user