mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
chore: upgrade base ui to 1.5.0 (#36442)
This commit is contained in:
parent
d646bcf257
commit
7d0d9019d8
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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])
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user