diff --git a/.changeset/multiselect-toggle-reopen.md b/.changeset/multiselect-toggle-reopen.md new file mode 100644 index 0000000000..18abb11dd6 --- /dev/null +++ b/.changeset/multiselect-toggle-reopen.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/fuselage': patch +--- + +fix(fuselage): `MultiSelect` dropdown reopening when its toggle is clicked while open diff --git a/packages/fuselage/src/components/MultiSelect/MultiSelect.spec.tsx b/packages/fuselage/src/components/MultiSelect/MultiSelect.spec.tsx index 2bc3cd7261..0af6dac382 100644 --- a/packages/fuselage/src/components/MultiSelect/MultiSelect.spec.tsx +++ b/packages/fuselage/src/components/MultiSelect/MultiSelect.spec.tsx @@ -1,5 +1,5 @@ import { composeStories } from '@storybook/react-webpack5'; -import { screen } from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import { axe } from 'jest-axe'; import { withResizeObserverMock } from 'testing-utils/mocks/withResizeObserverMock'; @@ -32,6 +32,44 @@ test.each(testCases)( }, ); +test('stays closed when the toggle is clicked while open (regression for #1869)', () => { + // The visibility state is debounced (10ms). When the open dropdown is + // clicked, the anchor blurs first (`onBlur: hide`) and that debounced hide + // flushes before the trailing `click` fires. Previously `handleClick` read + // the already-HIDDEN `visible` state and re-opened the dropdown. Reproduce + // that exact ordering with controlled timers. + jest.useFakeTimers(); + + try { + const { Default } = composeStories(stories); + render(); + + const combobox = screen.getByRole('combobox'); + const container = combobox.closest('.rcx-select') as HTMLElement; + + // Open the dropdown. + fireEvent.click(container); + act(() => { + jest.advanceTimersByTime(10); + }); + expect(combobox).toHaveAttribute('aria-expanded', 'true'); + + // Clicking the toggle: blur closes it, then the click must keep it closed. + fireEvent.blur(combobox); + act(() => { + jest.advanceTimersByTime(10); + }); + fireEvent.click(container); + act(() => { + jest.advanceTimersByTime(10); + }); + + expect(combobox).toHaveAttribute('aria-expanded', 'false'); + } finally { + jest.useRealTimers(); + } +}); + test('MultiSelectFiltered prevents Chrome autocomplete overlay (regression)', () => { const { WithFilter } = composeStories(stories); diff --git a/packages/fuselage/src/components/MultiSelect/MultiSelect.tsx b/packages/fuselage/src/components/MultiSelect/MultiSelect.tsx index aa429d01ca..023ffb240c 100644 --- a/packages/fuselage/src/components/MultiSelect/MultiSelect.tsx +++ b/packages/fuselage/src/components/MultiSelect/MultiSelect.tsx @@ -142,9 +142,16 @@ const MultiSelect = forwardRef( }; const handleClick = useStableCallback(() => { - if (visible === AnimatedVisibility.VISIBLE) { + // Toggle based on the self-managed `focus-visible` class rather than the + // `visible` state. The anchor's `onBlur` fires `hide()` during the + // mousedown that precedes this click, so reading `visible` here would + // already see HIDDEN and re-open the dropdown. This mirrors SelectLegacy. + if (innerRef.current?.classList.contains('focus-visible')) { + removeFocusClass(); return hide(); } + + innerRef.current?.classList.add('focus-visible'); innerRef.current?.focus(); return show(); });