Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
125 changes: 110 additions & 15 deletions src/components/select/select-atom.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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( {} );
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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 (
<div style={ { width: '300px' } }>
<button type="button" onClick={ () => setMultiple( ( v ) => ! v ) }>
{ multiple ? 'Disable multiple' : 'Enable multiple' }
</button>
<Select
size={ size }
multiple={ multiple }
disabled={ disabled }
onChange={ ( value ) => value }
>
<Select.Button
label="Select a Color"
placeholder="Select an option"
render={ ( selected ) =>
( selected as Record<string, string> )?.name
}
/>
<Select.Portal>
<Select.Options>
{ options.map( ( option ) => (
<Select.Option key={ option.id } value={ option }>
{ option.name }
</Select.Option>
) ) }
</Select.Options>
</Select.Portal>
</Select>
</div>
);
};
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,
Expand Down
2 changes: 0 additions & 2 deletions src/components/select/select-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ export type SelectOnClose = ( event: React.MouseEvent ) => void;

export type SelectFunctionChildren = ( {
value,
onClose,
}: {
value: SelectOptionValue;
onClose?: ( event: React.MouseEvent<HTMLElement> ) => void;
} ) => React.JSX.Element;

export type MultiTypeChildren =
Expand Down
91 changes: 55 additions & 36 deletions src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -156,20 +156,27 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
}, [ icon ] );
Comment thread
jaieds marked this conversation as resolved.

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 ) => (
<Badge
className="cursor-default"
icon={ optionIcon }
type="rounded"
key={ index }
key={ String(
valueItem !== null &&
typeof valueItem === 'object'
? ( valueItem as Record<string, unknown> )[
by
]
: valueItem
) }
size={ badgeSize as SelectSizes }
onMouseDown={ handleOnCloseItem( valueItem ) }
label={
Expand All @@ -184,6 +191,15 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
);
Comment thread
jaieds marked this conversation as resolved.
}

// 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 : '';

Expand All @@ -197,13 +213,6 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
) {
const childProps = {
value: selectedValue as SelectOptionValue,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct removal since the multiple branch returns earlier, but SelectFunctionChildren in select-types.ts still declares onClose. Drop it there too so the type matches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped from SelectFunctionChildren. 73564a8

...( multiple
? {
onClose: handleOnCloseItem(
selectedValue as SelectOptionValue
),
}
: {} ),
};
renderValue = children( childProps );
}
Expand All @@ -227,17 +236,15 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
{ renderValue as React.ReactNode }
</span>
);
}, [ getValues, disabled ] );
}, [ getValues, disabled, multiple, render, children, by ] );

const handleOnCloseItem =
( value: SelectOptionValue ) =>
( event?: React.MouseEvent<HTMLElement> ) => {
event?.preventDefault();
event?.stopPropagation();

const selectedValues = [
...( ( getValues() as SelectOptionValue[] ) ?? [] ),
];
const selectedValues = [ ...toValuesArray( getValues() ) ];
const selectedIndex = selectedValues.findIndex( ( val ) => {
if (
val !== null &&
Expand Down Expand Up @@ -276,7 +283,7 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
}

const showPlaceholder = multiple
? ! ( getValues() as SelectOptionValue[] )?.length
? ! toValuesArray( getValues() ).length
: ! getValues() && ! searchKeyword;

return (
Expand Down Expand Up @@ -384,9 +391,9 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(
multiple
) {
e.preventDefault();
const arr =
( getValues() as SelectOptionValue[] ) ??
[];
const arr = toValuesArray(
getValues()
);
if ( arr.length ) {
handleOnCloseItem(
arr[ arr.length - 1 ]
Expand Down Expand Up @@ -456,7 +463,7 @@ export const SelectButton = forwardRef<HTMLElement, SelectButtonProps>(

{ /* Placeholder */ }
{ ( multiple
? ! ( getValues() as SelectOptionValue[] )?.length
? ! toValuesArray( getValues() ).length
: ! getValues() ) && (
<div
className={ cn(
Expand Down Expand Up @@ -1047,7 +1054,7 @@ export function SelectItem( {
if ( ! currentValue ) {
return false;
}
return ( currentValue as SelectOptionValue[] ).some( ( val ) => {
return toValuesArray( currentValue ).some( ( val ) => {
if ( val !== null && value !== null && typeof val === 'object' ) {
return (
( val as Record<string, unknown> )[ by ] ===
Expand All @@ -1056,7 +1063,7 @@ export function SelectItem( {
}
return val === value;
} );
}, [ value, getValues ] );
}, [ value, getValues, multiple, by ] );

const isChecked = useMemo( () => {
if ( typeof selected === 'boolean' ) {
Expand All @@ -1068,7 +1075,7 @@ export function SelectItem( {
}

return indx === selectedIndex;
}, [ multipleChecked, selectedIndex, selected ] );
}, [ multipleChecked, selectedIndex, selected, multiple, indx ] );

let itemTabIndex: number | undefined;
if ( ! inlineSearch ) {
Expand Down Expand Up @@ -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 &&
Expand All @@ -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 );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setSelectedIndex is not updated on deselect, so useListNavigation reopens highlighting an option that is no longer selected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleared when deselected option was highlighted. 73564a8

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( '' );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: dropping the refocus breaks inlineSearch + multiple. Options get no tabIndex there and the branch has no FloatingFocusManager, so clicking one sends focus to document.body and type-to-filter, arrow nav and Backspace-removes-badge all stop working. Restore refs.domReference.current?.focus() behind if ( inlineSearch ), and assert typing still filters right after a pick.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored behind inlineSearch guard, typing-after-pick asserted. 73564a8

if ( typeof onChange === 'function' ) {
onChange( selectedValues );
Expand Down
Loading
Loading