diff --git a/changelog.txt b/changelog.txt
index e499eec9..3f675bff 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,6 @@
-Version 1.8.1 - 3rd August, 2026
-- 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.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.
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.
diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx
index 5220ed97..e2a826bf 100644
--- a/src/components/select/select-atom.stories.tsx
+++ b/src/components/select/select-atom.stories.tsx
@@ -248,17 +248,29 @@ MultiSelect.play = async ( { canvasElement } ) => {
const listBox = await screen.findByRole( 'listbox' );
expect( listBox ).toHaveTextContent( 'Red' );
- // Click on the first option
- 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 ] );
+ // 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(
+ 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(
+ await screen.findByRole( 'option', { name: 'Orange' } )
+ ); // 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 +510,23 @@ 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 );
+ await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) );
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeNull();
+
+ // 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 );
- const allOptions = await screen.findAllByRole( 'option' );
- await userEvent.click( allOptions[ 0 ] ); // Red
- // Re-open and select Orange
- await userEvent.click( triggerWrapper );
- const allOptions2 = await screen.findAllByRole( 'option' );
- await userEvent.click( allOptions2[ 1 ] ); // Orange
+ await userEvent.click(
+ await screen.findByRole( 'option', { name: 'Orange' } )
+ );
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeNull();
// Two badges should be visible inside trigger
const redBadge = await canvas.findByText( 'Red' );
@@ -546,6 +566,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-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 a568101f..a89de0b1 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,20 +156,27 @@ 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(
- ( valueItem: SelectOptionValue, index: number ) => (
+ return toValuesArray( currentValue ).map(
+ ( valueItem: SelectOptionValue ) => (
)[
+ by
+ ]
+ : valueItem
+ ) }
size={ badgeSize as SelectSizes }
onMouseDown={ handleOnCloseItem( valueItem ) }
label={
@@ -184,6 +191,15 @@ export const SelectButton = forwardRef(
);
}
+ // 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 +213,6 @@ export const SelectButton = forwardRef(
) {
const childProps = {
value: selectedValue as SelectOptionValue,
- ...( multiple
- ? {
- onClose: handleOnCloseItem(
- selectedValue as SelectOptionValue
- ),
- }
- : {} ),
};
renderValue = children( childProps );
}
@@ -227,7 +236,7 @@ export const SelectButton = forwardRef(
{ renderValue as React.ReactNode }
);
- }, [ getValues, disabled ] );
+ }, [ getValues, disabled, multiple, render, children, by ] );
const handleOnCloseItem =
( value: SelectOptionValue ) =>
@@ -235,9 +244,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 +283,7 @@ export const SelectButton = forwardRef(
}
const showPlaceholder = multiple
- ? ! ( getValues() as SelectOptionValue[] )?.length
+ ? ! toValuesArray( getValues() ).length
: ! getValues() && ! searchKeyword;
return (
@@ -384,9 +391,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 +463,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 +1063,7 @@ export function SelectItem( {
}
return val === value;
} );
- }, [ value, getValues ] );
+ }, [ value, getValues, multiple, by ] );
const isChecked = useMemo( () => {
if ( typeof selected === 'boolean' ) {
@@ -1068,7 +1075,7 @@ export function SelectItem( {
}
return indx === selectedIndex;
- }, [ multipleChecked, selectedIndex, selected ] );
+ }, [ multipleChecked, selectedIndex, selected, multiple, indx ] );
let itemTabIndex: number | undefined;
if ( ! inlineSearch ) {
@@ -1234,9 +1241,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 &&
@@ -1251,20 +1259,31 @@ 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 );
+ if ( selectedIndex === index ) {
+ setSelectedIndex( null );
+ }
+ } 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 );
+ // 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 );
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