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 ( +
+ + +
+ ); +}; +ToggleMultipleAtRuntime.args = { + size: 'md', + disabled: false, +}; +ToggleMultipleAtRuntime.parameters = { + docs: { + description: { + story: 'The `multiple` prop can be flipped after a selection exists. The existing single value is treated as a one-item list instead of throwing.', + }, + }, +}; +ToggleMultipleAtRuntime.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + + // Single mode: pick Red. + const trigger = await canvas.findByRole( 'combobox' ); + await userEvent.click( trigger ); + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); + expect( trigger ).toHaveTextContent( 'Red' ); + + // Flip to multiple mode — previous single value survives as a badge. + await userEvent.click( + await canvas.findByRole( 'button', { name: 'Enable multiple' } ) + ); + expect( trigger ).toHaveTextContent( 'Red' ); + + // Selecting another option must not crash and must append. + await userEvent.click( trigger ); + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); + expect( trigger ).toHaveTextContent( /Red.*Orange/ ); + + // Flip back to single mode — first value is shown, still no crash. + await userEvent.keyboard( '{Escape}' ); + await userEvent.click( + await canvas.findByRole( 'button', { name: 'Disable multiple' } ) + ); + expect( trigger ).toHaveTextContent( 'Red' ); +}; + const GroupedSelectTemplate: Story = ( { size, multiple, diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index c79e795b..98dc8eeb 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -56,7 +56,7 @@ import type { SelectSizes, SelectOptionGroupProps, } from './select-types'; -import { getTextContent } from './utils'; +import { getTextContent, toValuesArray } from './utils'; import { useDebouncedCallback } from '@/utilities/hooks'; // Context to manage the state of the select component. @@ -156,14 +156,14 @@ export const SelectButton = forwardRef( }, [ icon ] ); const renderSelected = useCallback( () => { - const selectedValue = getValues(); + const currentValue = getValues(); - if ( ! selectedValue ) { + if ( ! currentValue ) { return null; } if ( multiple ) { - return ( selectedValue as SelectOptionValue[] ).map( + return toValuesArray( currentValue ).map( ( valueItem: SelectOptionValue, index: number ) => ( ( ); } + // Single mode can still hold an array when `multiple` is toggled at + // runtime — fall back to the first entry so render()/children get a + // single value. An empty array is passed through untouched to keep + // the value render()/children already received. + const selectedValue = + Array.isArray( currentValue ) && currentValue.length + ? currentValue[ 0 ] + : currentValue; + let renderValue: ReactNode = typeof selectedValue === 'string' ? selectedValue : ''; @@ -197,13 +206,6 @@ export const SelectButton = forwardRef( ) { const childProps = { value: selectedValue as SelectOptionValue, - ...( multiple - ? { - onClose: handleOnCloseItem( - selectedValue as SelectOptionValue - ), - } - : {} ), }; renderValue = children( childProps ); } @@ -227,7 +229,7 @@ export const SelectButton = forwardRef( { renderValue as React.ReactNode } ); - }, [ getValues, disabled ] ); + }, [ getValues, disabled, multiple, render, children ] ); const handleOnCloseItem = ( value: SelectOptionValue ) => @@ -235,9 +237,7 @@ export const SelectButton = forwardRef( event?.preventDefault(); event?.stopPropagation(); - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; + const selectedValues = [ ...toValuesArray( getValues() ) ]; const selectedIndex = selectedValues.findIndex( ( val ) => { if ( val !== null && @@ -276,7 +276,7 @@ export const SelectButton = forwardRef( } const showPlaceholder = multiple - ? ! ( getValues() as SelectOptionValue[] )?.length + ? ! toValuesArray( getValues() ).length : ! getValues() && ! searchKeyword; return ( @@ -384,9 +384,9 @@ export const SelectButton = forwardRef( multiple ) { e.preventDefault(); - const arr = - ( getValues() as SelectOptionValue[] ) ?? - []; + const arr = toValuesArray( + getValues() + ); if ( arr.length ) { handleOnCloseItem( arr[ arr.length - 1 ] @@ -456,7 +456,7 @@ export const SelectButton = forwardRef( { /* Placeholder */ } { ( multiple - ? ! ( getValues() as SelectOptionValue[] )?.length + ? ! toValuesArray( getValues() ).length : ! getValues() ) && (
{ + return toValuesArray( currentValue ).some( ( val ) => { if ( val !== null && value !== null && typeof val === 'object' ) { return ( ( val as Record )[ by ] === @@ -1056,7 +1056,7 @@ export function SelectItem( { } return val === value; } ); - }, [ value, getValues ] ); + }, [ value, getValues, multiple, by ] ); const isChecked = useMemo( () => { if ( typeof selected === 'boolean' ) { @@ -1068,7 +1068,7 @@ export function SelectItem( { } return indx === selectedIndex; - }, [ multipleChecked, selectedIndex, selected ] ); + }, [ multipleChecked, selectedIndex, selected, multiple ] ); let itemTabIndex: number | undefined; if ( ! inlineSearch ) { @@ -1234,9 +1234,10 @@ const SelectComponent = ( { ] ); const handleMultiSelect: OnClick = ( index, newValue ) => { - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; + // getValues() can return a single value here — e.g. `multiple` flipped + // to true while a single selection was already made — so normalize + // before treating it as a list. + const selectedValues = [ ...toValuesArray( getValues() ) ]; const valueIndex = selectedValues.findIndex( ( selectedValue ) => { if ( selectedValue !== null && diff --git a/src/components/select/utils.ts b/src/components/select/utils.ts index 2b18d816..639aa844 100644 --- a/src/components/select/utils.ts +++ b/src/components/select/utils.ts @@ -1,4 +1,29 @@ import { type ReactNode, isValidElement } from 'react'; +import type { SelectOptionValue } from './select-types'; + +/** + * Normalize a select value into an array of selected values. + * + * Multi-select code paths assume an array, but the value can be a single + * value when `multiple` is toggled at runtime or when a single value is + * passed while `multiple` is true. Wrapping instead of assuming keeps those + * paths from throwing on non-iterable values. + * + * @param {SelectOptionValue | SelectOptionValue[] | null | undefined} value - Current select value. + * @return {SelectOptionValue[]} Array of selected values. + * @since x.x.x + */ +export const toValuesArray = ( + value: SelectOptionValue | SelectOptionValue[] | null | undefined +): SelectOptionValue[] => { + if ( Array.isArray( value ) ) { + return value; + } + if ( value === null || typeof value === 'undefined' || value === '' ) { + return []; + } + return [ value ]; +}; /** * Get text content of a node From 73564a86fee93d1f5aab04d3bc2c674defedc6b7 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:56:30 +0600 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20inlin?= =?UTF-8?q?eSearch=20refocus,=20stale=20selectedIndex,=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore trigger refocus behind inlineSearch so type-to-filter, arrow nav and Backspace-removes-badge survive a pick - clear selectedIndex when the deselected option was highlighted - drop onClose from SelectFunctionChildren type - add indx to isChecked deps - stories: re-query options after each click, badge key by `by` value, assert typing filters right after a pick in inlineSearch --- src/components/select/select-atom.stories.tsx | 30 +++++++++++++------ src/components/select/select-types.ts | 2 -- src/components/select/select.tsx | 27 ++++++++++++++--- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index f1d87bf7..e2a826bf 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -248,18 +248,22 @@ MultiSelect.play = async ( { canvasElement } ) => { const listBox = await screen.findByRole( 'listbox' ); expect( listBox ).toHaveTextContent( 'Red' ); - // Select two options — the dropdown stays open in multiple mode - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); // Red + // Select two options — the dropdown stays open in multiple mode. + // Options are re-queried after each click since the list re-renders. + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); - await userEvent.click( allOptions[ 1 ] ); // Orange + await userEvent.click( + await screen.findByRole( 'option', { name: '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) + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); // Orange (toggle off) expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); expect( selectButton ).toHaveTextContent( 'Red' ); expect( selectButton ).not.toHaveTextContent( 'Orange' ); @@ -508,12 +512,20 @@ InlineSearchMulti.play = async ( { canvasElement } ) => { // 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 + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); // Orange + // Focus must return to the input after a pick — typing (no re-click) + // keeps filtering, proving type-to-filter survives the selection. + await userEvent.keyboard( 'oran' ); + const listboxAfterPick = await screen.findByRole( 'listbox' ); + expect( listboxAfterPick ).toHaveTextContent( 'Orange' ); + expect( listboxAfterPick ).not.toHaveTextContent( 'Cyan' ); + await userEvent.clear( input ); + + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); // Two badges should be visible inside trigger diff --git a/src/components/select/select-types.ts b/src/components/select/select-types.ts index f4311c71..865f14f4 100644 --- a/src/components/select/select-types.ts +++ b/src/components/select/select-types.ts @@ -12,10 +12,8 @@ export type SelectOnClose = ( event: React.MouseEvent ) => void; export type SelectFunctionChildren = ( { value, - onClose, }: { value: SelectOptionValue; - onClose?: ( event: React.MouseEvent ) => void; } ) => React.JSX.Element; export type MultiTypeChildren = diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 98dc8eeb..a89de0b1 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -164,12 +164,19 @@ export const SelectButton = forwardRef( if ( multiple ) { return toValuesArray( currentValue ).map( - ( valueItem: SelectOptionValue, index: number ) => ( + ( valueItem: SelectOptionValue ) => ( )[ + by + ] + : valueItem + ) } size={ badgeSize as SelectSizes } onMouseDown={ handleOnCloseItem( valueItem ) } label={ @@ -229,7 +236,7 @@ export const SelectButton = forwardRef( { renderValue as React.ReactNode } ); - }, [ getValues, disabled, multiple, render, children ] ); + }, [ getValues, disabled, multiple, render, children, by ] ); const handleOnCloseItem = ( value: SelectOptionValue ) => @@ -1068,7 +1075,7 @@ export function SelectItem( { } return indx === selectedIndex; - }, [ multipleChecked, selectedIndex, selected, multiple ] ); + }, [ multipleChecked, selectedIndex, selected, multiple, indx ] ); let itemTabIndex: number | undefined; if ( ! inlineSearch ) { @@ -1257,6 +1264,9 @@ const SelectComponent = ( { // Escape and outside click (useDismiss) still close it. if ( valueIndex !== -1 ) { selectedValues.splice( valueIndex, 1 ); + if ( selectedIndex === index ) { + setSelectedIndex( null ); + } } else { selectedValues.push( newValue ); setSelectedIndex( index ); @@ -1265,6 +1275,15 @@ const SelectComponent = ( { if ( ! isControlled ) { setSelected( selectedValues ); } + // inlineSearch options carry no tabIndex and render without a + // FloatingFocusManager, so a click would drop focus to document.body + // and kill type-to-filter, arrow nav and Backspace-removes-badge. + if ( inlineSearch ) { + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + } setSearchKeyword( '' ); if ( typeof onChange === 'function' ) { onChange( selectedValues ); From cd2adf5f1372deaa44ef46ba8394483cd0526300 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:08:30 +0600 Subject: [PATCH 4/4] Update the changelog --- changelog.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.txt b/changelog.txt index 93698dcd..3f675bff 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ -Version 1.8.1 - 4th August, 2026 +Version 1.8.1 - 11th August, 2026 +- Improvement: 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. - 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 - New: Atom - File Picker: Added a standalone file upload component that displays the selected file name and supports a controlled value and an optional clear action.