diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fd004ad6d..803e1e7f82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 148f3d014b..3f99e464b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 99443ae1e0..91fcdebe14 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -162,18 +162,18 @@ describe('Filter', () => { const user = userEvent.setup() const setQueryParams = vi.fn() - const { container } = render( + render( , ) - // 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( + , + ) + + 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', + }) }) }) diff --git a/web/app/components/base/chip/__tests__/index.spec.tsx b/web/app/components/base/chip/__tests__/index.spec.tsx index 826441779d..59c23c4a01 100644 --- a/web/app/components/base/chip/__tests__/index.spec.tsx +++ b/web/app/components/base/chip/__tests__/index.spec.tsx @@ -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> = {}) => { - return render( - , - ) + const user = userEvent.setup() + return { + user, + ...render( + , + ), + } } // 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, 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]) }) diff --git a/web/app/components/base/chip/index.tsx b/web/app/components/base/chip/index.tsx index 3190fb0ccb..d009159f44 100644 --- a/web/app/components/base/chip/index.tsx +++ b/web/app/components/base/chip/index.tsx @@ -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({ onClear, }: Props) { 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 ( - -
-
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) + }} + > +
+ *: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, )} > - + {showLeftIcon && ( -
+ {leftIcon || ( - + )} -
+
)} -
-
+ + {triggerContent} -
-
- {!value && } -
- {!!value && ( - - )} -
- + + {!hasValue && } + + + {hasValue && ( + + )} + - { - const selected = items.find(item => item.value === nextValue) - if (selected) - onSelect(selected) - }} - className="max-h-72 overflow-auto p-1" - > - {items.map(item => ( - -
{item.name}
- {value === item.value && } -
- ))} -
-
+ {items.map(item => ( + + + {item.name} + + + + ))} +
- + ) }