chore: upgrade base ui to 1.5.0 (#36442)

This commit is contained in:
yyh 2026-05-20 17:58:08 +08:00 committed by GitHub
parent d646bcf257
commit 7d0d9019d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 239 additions and 233 deletions

32
pnpm-lock.yaml generated
View File

@ -16,8 +16,8 @@ catalogs:
specifier: 9.0.0
version: 9.0.0
'@base-ui/react':
specifier: 1.4.1
version: 1.4.1
specifier: 1.5.0
version: 1.5.0
'@chromatic-com/storybook':
specifier: 5.2.1
version: 5.2.1
@ -741,7 +741,7 @@ importers:
devDependencies:
'@base-ui/react':
specifier: 'catalog:'
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@chromatic-com/storybook':
specifier: 'catalog:'
version: 5.2.1(storybook@10.4.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.21(@types/node@25.9.0)(@vitest/coverage-v8@4.1.6(@types/node@25.9.0)(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3)))
@ -900,7 +900,7 @@ importers:
version: 1.30.4(@amplitude/rrweb@2.0.0-alpha.40)
'@base-ui/react':
specifier: 'catalog:'
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@emoji-mart/data':
specifier: 'catalog:'
version: 1.2.1
@ -1652,8 +1652,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@base-ui/react@1.4.1':
resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==}
'@base-ui/react@1.5.0':
resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@date-fns/tz': ^1.2.0
@ -1669,8 +1669,8 @@ packages:
date-fns:
optional: true
'@base-ui/utils@0.2.8':
resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==}
'@base-ui/utils@0.2.9':
resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
@ -7926,8 +7926,8 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
reserved-identifiers@1.2.0:
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
@ -9239,10 +9239,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
'@base-ui/react@1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
'@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@base-ui/utils': 0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@floating-ui/utils': 0.2.11
react: 19.2.6
@ -9251,13 +9251,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
'@base-ui/utils@0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
'@floating-ui/utils': 0.2.11
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
reselect: 5.1.1
reselect: 5.2.0
use-sync-external-store: 1.6.0(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@ -16002,7 +16002,7 @@ snapshots:
require-directory@2.1.1: {}
reselect@5.1.1: {}
reselect@5.2.0: {}
reserved-identifiers@1.2.0: {}
@ -17028,7 +17028,7 @@ time:
'@amplitude/analytics-browser@2.42.3': '2026-05-13T17:32:46.705Z'
'@amplitude/plugin-session-replay-browser@1.30.4': '2026-05-14T00:11:02.360Z'
'@antfu/eslint-config@9.0.0': '2026-05-11T06:18:58.474Z'
'@base-ui/react@1.4.1': '2026-04-20T12:24:35.520Z'
'@base-ui/react@1.5.0': '2026-05-19T13:22:48.843Z'
'@chromatic-com/storybook@5.2.1': '2026-05-14T07:49:29.364Z'
'@cucumber/cucumber@12.9.0': '2026-05-15T16:02:12.674Z'
'@egoist/tailwindcss-icons@1.9.2': '2026-01-31T10:48:44.594Z'

View File

@ -63,7 +63,7 @@ catalog:
'@amplitude/analytics-browser': 2.42.3
'@amplitude/plugin-session-replay-browser': 1.30.4
'@antfu/eslint-config': 9.0.0
'@base-ui/react': 1.4.1
'@base-ui/react': 1.5.0
'@chromatic-com/storybook': 5.2.1
'@cucumber/cucumber': 12.9.0
'@egoist/tailwindcss-icons': 1.9.2

View File

@ -162,18 +162,18 @@ describe('Filter', () => {
const user = userEvent.setup()
const setQueryParams = vi.fn()
const { container } = render(
render(
<Filter
queryParams={createDefaultQueryParams({ status: 'succeeded' })}
setQueryParams={setQueryParams}
/>,
)
// Find the clear icon (div with group/clear class) in the status chip
const clearIcon = container.querySelector('.group\\/clear')
const statusTrigger = screen.getByRole('combobox', { name: 'Success' })
const statusChip = statusTrigger.parentElement!
const clearButton = within(statusChip).getByRole('button', { name: 'common.operation.clear' })
expect(clearIcon)!.toBeInTheDocument()
await user.click(clearIcon!)
await user.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({
status: 'all',
@ -235,6 +235,24 @@ describe('Filter', () => {
})
})
it('should apply period chip sizing classes to trigger and panel', async () => {
const user = userEvent.setup()
render(
<Filter
queryParams={createDefaultQueryParams()}
setQueryParams={defaultSetQueryParams}
/>,
)
const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })
expect(periodTrigger).toHaveClass('min-w-[150px]')
await user.click(periodTrigger)
const listbox = await screen.findByRole('listbox')
expect(listbox.parentElement).toHaveClass('w-[270px]')
})
it('should call setQueryParams when period is selected', async () => {
const user = userEvent.setup()
const setQueryParams = vi.fn()
@ -266,17 +284,15 @@ describe('Filter', () => {
/>,
)
// Find the period chip's clear button
const periodChip = screen.getByText('appLog.filter.period.last7days').closest('div')
const clearButton = periodChip?.querySelector('button[type="button"]')
const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })
const periodChip = periodTrigger.parentElement!
const clearButton = within(periodChip).getByRole('button', { name: 'common.operation.clear' })
if (clearButton) {
await user.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({
status: 'all',
period: '9',
})
}
await user.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({
status: 'all',
period: '9',
})
})
})

View File

@ -1,5 +1,6 @@
import type { Item } from '../index'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import Chip from '../index'
@ -27,27 +28,39 @@ describe('Chip', () => {
// Helper function to render Chip with default props
const renderChip = (props: Partial<React.ComponentProps<typeof Chip>> = {}) => {
return render(
<Chip
value="all"
items={items}
onSelect={onSelect}
onClear={onClear}
{...props}
/>,
)
const user = userEvent.setup()
return {
user,
...render(
<Chip
value="all"
items={items}
onSelect={onSelect}
onClear={onClear}
{...props}
/>,
),
}
}
// Helper function to get the trigger element
const getTrigger = (container: HTMLElement) => {
return container.querySelector('button[aria-haspopup="menu"], [role="button"][aria-haspopup="menu"]') as HTMLElement | null
return container.querySelector('button[role="combobox"]') as HTMLElement | null
}
// Helper function to open dropdown panel
const openPanel = (container: HTMLElement) => {
const openPanel = async (user: ReturnType<typeof userEvent.setup>, container: HTMLElement) => {
const trigger = getTrigger(container)
if (trigger)
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
await user.click(trigger!)
return screen.findByRole('listbox')
}
const expectPanelClosed = async (trigger: HTMLElement | null) => {
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger).not.toHaveAttribute('data-popup-open')
})
}
describe('Rendering', () => {
@ -60,7 +73,7 @@ describe('Chip', () => {
it('should display current selected item name', () => {
renderChip({ value: 'active' })
expect(screen.getByText('Active'))!.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Active' }))!.toBeInTheDocument()
})
it('should display empty content when value does not match any item', () => {
@ -86,15 +99,13 @@ describe('Chip', () => {
onClear={onClear}
/>,
)
expect(screen.getByText('Archived'))!.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Archived' }))!.toBeInTheDocument()
})
it('should show left icon by default', () => {
const { container } = renderChip()
// The filter icon should be visible
const svg = container.querySelector('svg')
expect(svg)!.toBeInTheDocument()
expect(container.querySelector('.i-ri-filter-3-line')).toBeInTheDocument()
})
it('should hide left icon when showLeftIcon is false', () => {
@ -102,10 +113,8 @@ describe('Chip', () => {
// When showLeftIcon is false, there should be no filter icon before the text
const trigger = getTrigger(document.body)
const icons = trigger?.querySelectorAll('svg')
// Should only have the arrow icon, not the filter icon
expect(icons?.length).toBe(1)
expect(trigger?.querySelector('.i-ri-filter-3-line')).not.toBeInTheDocument()
expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should render custom left icon', () => {
@ -125,11 +134,11 @@ describe('Chip', () => {
expect(chipElement)!.toBeInTheDocument()
})
it('should apply custom panelClassName to dropdown panel', () => {
it('should apply custom panelClassName to dropdown panel', async () => {
const customPanelClass = 'custom-panel-class'
const { container } = renderChip({ panelClassName: customPanelClass })
openPanel(container)
const { container, user } = renderChip({ panelClassName: customPanelClass })
await openPanel(user, container)
// Panel is rendered in a portal, so check document.body
const panel = document.body.querySelector(`.${customPanelClass}`)
@ -138,102 +147,90 @@ describe('Chip', () => {
})
describe('State Management', () => {
it('should toggle dropdown panel on trigger click', () => {
const { container } = renderChip()
it('should toggle dropdown panel on trigger click', async () => {
const { container, user } = renderChip()
// Initially closed - check aria-expanded attribute
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger).not.toHaveAttribute('data-popup-open')
// Open panel
openPanel(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Panel items should be visible
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
const listbox = await openPanel(user, container)
expect(trigger).toHaveAttribute('data-popup-open')
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
// Close panel
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
await user.click(trigger)
await expectPanelClosed(trigger)
})
it('should close panel after selecting an item', () => {
const { container } = renderChip()
it('should close panel after selecting an item', async () => {
const { container, user } = renderChip()
openPanel(container)
const listbox = await openPanel(user, container)
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
expect(trigger).toHaveAttribute('data-popup-open')
// Click on an item in the dropdown panel
const activeItems = screen.getAllByText('Active')
// The second one should be in the dropdown
fireEvent.click(activeItems[activeItems.length - 1]!)
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
await expectPanelClosed(trigger)
})
})
describe('Event Handlers', () => {
it('should call onSelect with correct item when item is clicked', () => {
const { container } = renderChip()
it('should call onSelect with correct item when item is clicked', async () => {
const { container, user } = renderChip()
openPanel(container)
// Get all "Active" texts and click the one in the dropdown (should be the last one)
const activeItems = screen.getAllByText('Active')
fireEvent.click(activeItems[activeItems.length - 1]!)
const listbox = await openPanel(user, container)
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(items[1])
})
it('should call onClear when clear button is clicked', () => {
const { container } = renderChip({ value: 'active' })
it('should call onClear when clear button is clicked', async () => {
const { user } = renderChip({ value: 'active' })
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
await user.click(clearButton)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should stop event propagation when clear button is clicked', () => {
const { container } = renderChip({ value: 'active' })
it('should stop event propagation when clear button is clicked', async () => {
const { container, user } = renderChip({ value: 'active' })
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger).not.toHaveAttribute('data-popup-open')
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
if (clearButton)
fireEvent.click(clearButton)
await user.click(clearButton)
// Panel should remain closed
// Panel should remain closed
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should handle multiple rapid clicks on trigger', () => {
const { container } = renderChip()
it('should handle multiple rapid clicks on trigger', async () => {
const { container, user } = renderChip()
const trigger = getTrigger(container)
// Click 1: open
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
await user.click(trigger)
expect(await screen.findByRole('listbox')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-popup-open')
// Click 2: close
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
await user.click(trigger)
await expectPanelClosed(trigger)
// Click 3: open again
if (trigger)
fireEvent.click(trigger)
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
await user.click(trigger)
expect(await screen.findByRole('listbox')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-popup-open')
})
})
@ -241,17 +238,13 @@ describe('Chip', () => {
it('should show arrow down icon when no value is selected', () => {
const { container } = renderChip({ value: '' })
// Should have SVG icons (filter icon and arrow down icon)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should show clear button when value is selected', () => {
const { container } = renderChip({ value: 'active' })
// When value is selected, there should be an icon (the close icon)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
expect(container.querySelector('.i-ri-close-circle-fill')).toBeInTheDocument()
})
it('should not show clear button when no value is selected', () => {
@ -259,57 +252,43 @@ describe('Chip', () => {
const trigger = getTrigger(container)
// When value is empty, the trigger should only have 2 SVGs (filter icon + arrow)
// When value is selected, it would have 2 SVGs (filter icon + close icon)
const svgs = trigger?.querySelectorAll('svg')
// Arrow icon should be present, close icon should not
expect(svgs?.length).toBe(2)
expect(trigger?.querySelector('.i-ri-filter-3-line')).toBeInTheDocument()
expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.querySelector('.i-ri-close-circle-fill')).not.toBeInTheDocument()
// Verify onClear hasn't been called
expect(onClear).not.toHaveBeenCalled()
})
it('should show dropdown content only when panel is open', () => {
const { container } = renderChip()
it('should show dropdown content only when panel is open', async () => {
const { container, user } = renderChip()
const trigger = getTrigger(container)
// Closed by default
// Closed by default
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger).not.toHaveAttribute('data-popup-open')
openPanel(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
// Items should be duplicated (once in trigger, once in panel)
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
const listbox = await openPanel(user, container)
expect(trigger).toHaveAttribute('data-popup-open')
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
})
it('should show check icon on selected item in dropdown', () => {
const { container } = renderChip({ value: 'active' })
it('should show check icon on selected item in dropdown', async () => {
const { container, user } = renderChip({ value: 'active' })
openPanel(container)
const listbox = await openPanel(user, container)
// Find the dropdown panel items
const allActiveTexts = screen.getAllByText('Active')
// The dropdown item should be the last one
const dropdownItem = allActiveTexts[allActiveTexts.length - 1]
const parentContainer = dropdownItem!.parentElement
// The check icon should be a sibling within the parent
const checkIcon = parentContainer?.querySelector('svg')
expect(checkIcon)!.toBeInTheDocument()
expect(within(listbox).getByRole('option', { name: 'Active' })).toHaveAttribute('aria-selected', 'true')
})
it('should render all items in dropdown when open', () => {
const { container } = renderChip()
it('should render all items in dropdown when open', async () => {
const { container, user } = renderChip()
openPanel(container)
const listbox = await openPanel(user, container)
// Each item should appear at least twice (once in potential selected state, once in dropdown)
// Use getAllByText to handle multiple occurrences
expect(screen.getAllByText('All Items').length).toBeGreaterThan(0)
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
expect(screen.getAllByText('Archived').length).toBeGreaterThan(0)
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
expect(within(listbox).getByRole('option', { name: 'Active' })).toBeInTheDocument()
expect(within(listbox).getByRole('option', { name: 'Archived' })).toBeInTheDocument()
})
})
@ -330,56 +309,65 @@ describe('Chip', () => {
// The trigger should not display any item name text
expect(trigger?.textContent?.trim()).toBeFalsy()
expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument()
})
it('should allow selecting already selected item', () => {
const { container } = renderChip({ value: 'active' })
it('should allow selecting already selected item', async () => {
const { container, user } = renderChip({ value: 'active' })
openPanel(container)
const listbox = await openPanel(user, container)
// Click on the already selected item in the dropdown
const activeItems = screen.getAllByText('Active')
fireEvent.click(activeItems[activeItems.length - 1]!)
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(items[1])
})
it('should handle numeric values', () => {
it('should handle numeric values', async () => {
const numericItems: Item[] = [
{ value: 1, name: 'First' },
{ value: 2, name: 'Second' },
{ value: 3, name: 'Third' },
]
const { container } = renderChip({ value: 2, items: numericItems })
const { container, user } = renderChip({ value: 2, items: numericItems })
expect(screen.getByText('Second'))!.toBeInTheDocument()
// Open panel and select Third
openPanel(container)
const listbox = await openPanel(user, container)
const thirdItems = screen.getAllByText('Third')
fireEvent.click(thirdItems[thirdItems.length - 1]!)
await user.click(within(listbox).getByRole('option', { name: 'Third' }))
expect(onSelect).toHaveBeenCalledWith(numericItems[2])
})
it('should handle items with additional properties', () => {
it('should treat numeric zero as a selected value', () => {
const numericItems: Item[] = [
{ value: 0, name: 'Zero' },
{ value: 1, name: 'One' },
]
renderChip({ value: 0, items: numericItems })
expect(screen.getByRole('combobox', { name: 'Zero' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
it('should handle items with additional properties', async () => {
const itemsWithExtra: Item[] = [
{ value: 'a', name: 'Item A', customProp: 'extra1' },
{ value: 'b', name: 'Item B', customProp: 'extra2' },
]
const { container } = renderChip({ value: 'a', items: itemsWithExtra })
const { container, user } = renderChip({ value: 'a', items: itemsWithExtra })
expect(screen.getByText('Item A'))!.toBeInTheDocument()
// Open panel and select Item B
openPanel(container)
const listbox = await openPanel(user, container)
const itemBs = screen.getAllByText('Item B')
fireEvent.click(itemBs[itemBs.length - 1]!)
await user.click(within(listbox).getByRole('option', { name: 'Item B' }))
expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1])
})

View File

@ -1,14 +1,13 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import { useMemo } from 'react'
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
type ItemValue = number | string
@ -40,79 +39,82 @@ function Chip<T extends ItemValue>({
onClear,
}: Props<T>) {
const { t } = useTranslation()
const triggerContent = useMemo(() => {
return items.find(item => item.value === value)?.name || ''
}, [items, value])
const selectedItem = items.find(item => Object.is(item.value, value))
const triggerContent = selectedItem?.name || ''
const hasValue = selectedItem !== undefined && value !== ''
return (
<DropdownMenu>
<div className="relative">
<div
<Select
value={selectedItem?.value ?? null}
itemToStringLabel={(itemValue: T) => items.find(item => Object.is(item.value, itemValue))?.name ?? ''}
itemToStringValue={itemValue => String(itemValue)}
onValueChange={(nextValue) => {
if (nextValue === null)
return
const selected = items.find(item => Object.is(item.value, nextValue))
if (selected)
onSelect(selected)
}}
>
<div className="relative w-fit max-w-full">
<SelectTrigger
aria-label={triggerContent || t('placeholder.select', { ns: 'common' })}
className={cn(
'flex min-h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
!value && 'has-data-popup-open:bg-state-base-hover-alt! has-data-popup-open:hover:bg-state-base-hover-alt',
!!value && 'border-components-button-secondary-border! bg-components-button-secondary-bg! shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! has-data-popup-open:border-components-button-secondary-border-hover! has-data-popup-open:bg-components-button-secondary-bg-hover! has-data-popup-open:hover:border-components-button-secondary-border-hover has-data-popup-open:hover:bg-components-button-secondary-bg-hover!',
'h-auto min-h-8 w-fit max-w-full cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt [&>*:last-child]:hidden',
hasValue && 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!',
className,
)}
>
<DropdownMenuTrigger className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left">
<span className="flex min-w-0 grow items-center gap-0 text-left">
{showLeftIcon && (
<div className="p-0.5">
<span className="p-0.5">
{leftIcon || (
<RiFilter3Line className={cn('size-4 text-text-tertiary', !!value && 'text-text-secondary')} />
<span aria-hidden className={cn('i-ri-filter-3-line block size-4 text-text-tertiary', hasValue && 'text-text-secondary')} />
)}
</div>
</span>
)}
<div className="flex grow items-center gap-0.5 first-line:p-1">
<div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}>
<span className="flex grow items-center gap-0.5 first-line:p-1">
<span className={cn('system-sm-regular text-text-tertiary', hasValue && 'text-text-secondary')}>
{triggerContent}
</div>
</div>
{!value && <RiArrowDownSLine className="size-4 text-text-tertiary" />}
</DropdownMenuTrigger>
{!!value && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear cursor-pointer border-none bg-transparent p-px"
onClick={(e) => {
e.stopPropagation()
onClear()
}}
>
<RiCloseCircleFill className="size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
</button>
)}
</div>
<DropdownMenuContent
</span>
</span>
{!hasValue && <span aria-hidden className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" />}
</span>
</SelectTrigger>
{hasValue && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px"
onClick={onClear}
>
<span aria-hidden className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
</button>
)}
<SelectContent
placement="bottom-start"
sideOffset={4}
popupClassName={cn('relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0', panelClassName)}
popupClassName={cn(
'relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0 text-sm text-text-secondary shadow-lg outline-hidden backdrop-blur-[5px] focus:outline-hidden focus-visible:outline-hidden',
panelClassName,
)}
listClassName="max-h-72 p-1"
>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
const selected = items.find(item => item.value === nextValue)
if (selected)
onSelect(selected)
}}
className="max-h-72 overflow-auto p-1"
>
{items.map(item => (
<DropdownMenuRadioItem
key={item.value}
value={item.value}
closeOnClick
className="gap-2 rounded-lg px-2 py-[6px] pl-3"
>
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
{value === item.value && <RiCheckLine className="size-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
{items.map(item => (
<SelectItem
key={item.value}
value={item.value}
className="mx-1 gap-2 rounded-lg px-2 py-[6px] 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>
</SelectItemText>
<SelectItemIndicator className="text-util-colors-blue-light-blue-light-600" />
</SelectItem>
))}
</SelectContent>
</div>
</DropdownMenu>
</Select>
)
}