Skip to content

#490 - Select: multiple mode closes the dropdown after every selection - #491

Merged
jaieds merged 5 commits into
stagingfrom
fix/select-multiselect-keep-open
Aug 11, 2026
Merged

#490 - Select: multiple mode closes the dropdown after every selection#491
jaieds merged 5 commits into
stagingfrom
fix/select-multiselect-keep-open

Conversation

@jaieds

@jaieds jaieds commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #490

What

  • multiple mode: dropdown stays open after an option is clicked or toggled via Enter/Space, so several options can be picked without reopening.
  • Clicking an already-selected option now deselects it (toggle) without closing.
  • Escape and outside click still close the list via Floating UI useDismiss (unchanged); focus returns to the trigger through FloatingFocusManager.
  • In inlineSearch mode focus returns to the trigger input after each pick, so type-to-filter, arrow navigation and Backspace-removes-badge keep working while the list stays open.
  • Single mode unchanged: closes on select.

Also in this PR (separate from #490)

  • Fix for a runtime multiple flip crash: toValuesArray() in utils.ts normalizes a single value into an array wherever multi-select code paths assumed an array (renderSelected, handleOnCloseItem, handleMultiSelect, SelectItem), and single mode falls back to the first entry when the value is still an array. Covered by the new ToggleMultipleAtRuntime story. This can be split out if preferred.

How

handleMultiSelect in select.tsx no longer calls setIsOpen(false); the early-return for already-selected values is replaced with a splice (deselect), and selectedIndex is cleared when the deselected option was the highlighted one so useListNavigation does not reopen on a stale highlight. The trigger refocus is kept only for inlineSearch (options there have no tabIndex and no FloatingFocusManager, so focus would drop to document.body). Checkmarks/badges update live since option checked state derives from getValues(); badges are keyed by their by value so mid-list deselects reconcile correctly.

Tests

  • Updated MultiSelect and InlineSearchMulti play tests: keep-open assertions after each pick (options re-queried after every click), toggle-deselect without close, typing filters immediately after a pick in inlineSearch, Escape closes.
  • Full vitest storybook suite: 82 files / 251 tests green.
  • Manual E2E in Chrome against Storybook: multi keep-open, toggle-deselect, Escape close, outside-click close, reopen persists selection, single mode closes on select. No console errors.

Notes

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.
@jaieds jaieds self-assigned this Aug 3, 2026
@jaieds jaieds linked an issue Aug 3, 2026 that may be closed by this pull request
@jaieds
jaieds requested a review from imnavanath August 3, 2026 13:43
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.

@ravindrakele ravindrakele left a comment

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.

Core fix reads right. One blocker on the inline-search path, rest are small.

Not inline (outside the diff hunks):

  • Badge key={ index } in renderSelected is more exposed now that mid-list deselect is possible. Key on the by value.
  • Description skips toValuesArray, the single-mode array fallback and ToggleMultipleAtRuntime. Those fix a separate runtime multiple flip crash, not #490. Call it out or split it.
  • Base is still component-improvements/editor-input and #489 is merged, so retarget to main before merge.
  • CODE_REVIEW and claude-review both failed at ~15m, which reads as a timeout rather than a finding. Worth confirming.

refs.reference.current ) as HTMLElement | null
)?.focus();
setIsOpen( false );
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

// 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

@@ -197,13 +206,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

Comment thread src/components/select/select.tsx Outdated

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

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.

Deps gained multiple but still omit indx, which shifts under combobox filtering. Worth adding while you are in here.

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.

Added indx. 73564a8


// Click on the first option
// Select two options — the dropdown stays open in multiple mode
const allOptions = await screen.findAllByRole( 'option' );

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.

These node refs are reused across re-renders. Fine today, but re-querying after each click is less brittle.

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.

Re-queried by name after each click now. 73564a8

@imnavanath

Copy link
Copy Markdown
Contributor

@jaieds - Can you check @ravindrakele's above comments once, approving for now.

@jaieds
jaieds changed the base branch from component-improvements/editor-input to staging August 11, 2026 11:48
Comment thread src/components/select/select.tsx
Comment thread src/components/select/select.tsx
- 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
@jaieds
jaieds requested a review from ravindrakele August 11, 2026 11:58

@ravindrakele ravindrakele left a comment

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.

All five review points are addressed cleanly in 73564a86.

  • The inlineSearch refocus is scoped correctly, so the button-trigger path keeps focus on the clicked option and inline search returns focus to the input. The new InlineSearchMulti assertion (typing oran with no re-click) genuinely proves it rather than just asserting the listbox is still mounted.
  • selectedIndex only clears when the deselected index is the tracked one, so deselecting a non-last option leaves the highlight on something still selected.
  • onClose, the isChecked deps and the reused option node refs are all done as suggested.

Full check run is green. LGTM.

Two non-blocking notes for later:

  • Badge key falls back to String( valueItem[ by ] ), so objects missing the by key all key to "undefined". Same shape already breaks the dedup in handleMultiSelect, so it is broken usage either way, just noting it.
  • This changes multi-select behavior for every existing consumer and there is no opt-out prop. Intended per #490, but worth a deliberate call on whether it ships as 1.8.1 or a minor.

@jaieds
jaieds merged commit 787c892 into staging Aug 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Select: multiple mode closes the dropdown after every selection

3 participants