From ed8e1672b5c595e1b2b424f71a0f1ca9ed2625f8 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:28:05 +0600 Subject: [PATCH 1/4] fix: keep multi-select dropdown open across selections Selecting an option in multiple mode no longer closes the dropdown, and clicking an already-selected option deselects it. Escape and outside click (useDismiss) still close the list; single mode still closes on select. Updated MultiSelect and InlineSearchMulti story play tests to assert the keep-open and toggle behavior. --- changelog.txt | 3 +- src/components/select/select-atom.stories.tsx | 28 ++++++++++++------- src/components/select/select.tsx | 15 +++++----- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/changelog.txt b/changelog.txt index e499eec9..93698dcd 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,5 @@ -Version 1.8.1 - 3rd August, 2026 +Version 1.8.1 - 4th August, 2026 +- Fix: Atom - Select: In `multiple` mode the dropdown now stays open while selecting or deselecting options, closing only on Escape or outside click. - New: Organism - Editor Input: Added optional `triggerRegex` prop that overrides the built-in mention-suggestion matcher, so consumers can allow the trigger without a leading blank space (e.g. directly after a double quote inside JSON string values). The custom regex must expose the same capture groups as the built-in matcher: 1 = leading boundary, 2 = replaceable string (trigger + query), 3 = query string used to filter options. When omitted, the existing trigger-based matcher is used unchanged. Version 1.8.0 - 17th July, 2026 diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index 5220ed97..afca9799 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -248,17 +248,25 @@ MultiSelect.play = async ( { canvasElement } ) => { const listBox = await screen.findByRole( 'listbox' ); expect( listBox ).toHaveTextContent( 'Red' ); - // Click on the first option + // Select two options — the dropdown stays open in multiple mode const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - // Check if the listbox contains the option 'Orange' - await userEvent.click( selectButton ); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); + await userEvent.click( allOptions[ 0 ] ); // Red + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); + await userEvent.click( allOptions[ 1 ] ); // Orange + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); // Check if the button text is updated expect( selectButton ).toHaveTextContent( /Red.*Orange/ ); + + // Clicking an already-selected option deselects it without closing + await userEvent.click( allOptions[ 1 ] ); // Orange (toggle off) + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); + expect( selectButton ).toHaveTextContent( 'Red' ); + expect( selectButton ).not.toHaveTextContent( 'Orange' ); + + // Escape still closes the dropdown + await userEvent.keyboard( '{Escape}' ); + expect( screen.queryByRole( 'listbox' ) ).toBeNull(); }; export const MultiSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); @@ -498,15 +506,15 @@ InlineSearchMulti.play = async ( { canvasElement } ) => { expect( listbox ).toHaveTextContent( 'Orange' ); expect( listbox ).not.toHaveTextContent( 'Cyan' ); - // Clear and select two options + // Clear and select two options — dropdown stays open in multiple mode await userEvent.clear( input ); const allOptions = await screen.findAllByRole( 'option' ); await userEvent.click( allOptions[ 0 ] ); // Red + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); - // Re-open and select Orange - await userEvent.click( triggerWrapper ); const allOptions2 = await screen.findAllByRole( 'option' ); await userEvent.click( allOptions2[ 1 ] ); // Orange + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); // Two badges should be visible inside trigger const redBadge = await canvas.findByText( 'Red' ); diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index a568101f..c79e795b 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -1251,20 +1251,19 @@ const SelectComponent = ( { return selectedValue === newValue; } ); + // Toggle: deselect if already selected, otherwise select. The + // dropdown stays open so multiple options can be picked in one go; + // Escape and outside click (useDismiss) still close it. if ( valueIndex !== -1 ) { - return; + selectedValues.splice( valueIndex, 1 ); + } else { + selectedValues.push( newValue ); + setSelectedIndex( index ); } - selectedValues.push( newValue ); if ( ! isControlled ) { setSelected( selectedValues ); } - setSelectedIndex( index ); - ( - ( refs.domReference.current ?? - refs.reference.current ) as HTMLElement | null - )?.focus(); - setIsOpen( false ); setSearchKeyword( '' ); if ( typeof onChange === 'function' ) { onChange( selectedValues ); From dd13910e620fc01143d3f941200ab92af35b411a Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:50:24 +0600 Subject: [PATCH 2/4] fix: normalize select value when multiple mode is toggled Flipping `multiple` to true while a single value was already selected crashed the component: `SelectItem`'s `multipleChecked` called `.some()` on a non-array value and `handleMultiSelect` spread it, so opening the dropdown or picking a second option threw a TypeError. Add a `toValuesArray()` helper and route every multi-value path through it (badge rendering, toggle/close handlers, placeholder checks, inline search backspace). In single mode a non-empty array now falls back to its first entry so `render()`/children never receive an array; an empty array is passed through unchanged to preserve existing output. Also add the missing `multiple`/`render`/`children`/`by` memo dependencies that kept stale single-mode closures alive after a mode flip. Adds a ToggleMultipleAtRuntime story covering the flip in both directions. --- src/components/select/select-atom.stories.tsx | 75 +++++++++++++++++++ src/components/select/select.tsx | 53 ++++++------- src/components/select/utils.ts | 25 +++++++ 3 files changed, 127 insertions(+), 26 deletions(-) diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index afca9799..f1d87bf7 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -554,6 +554,81 @@ InlineSearchMulti.play = async ( { canvasElement } ) => { expect( screen.queryByRole( 'listbox' ) ).toBeNull(); }; +// Toggling `multiple` at runtime while a selection already exists. +export const ToggleMultipleAtRuntime: Story = ( { size, disabled } ) => { + const [ multiple, setMultiple ] = useState( false ); + return ( +