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