Skip to content

[WC-3347]: fix(combobox-web): keep filter text cleared after select-all + Backspace - #2386

Open
samuelreichert wants to merge 12 commits into
mainfrom
WC-3347-combobox-multiselect-backspace
Open

[WC-3347]: fix(combobox-web): keep filter text cleared after select-all + Backspace#2386
samuelreichert wants to merge 12 commits into
mainfrom
WC-3347-combobox-multiselect-backspace

Conversation

@samuelreichert

@samuelreichert samuelreichert commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Pull request type

Bug fix (non-breaking change which fixes an issue)


Description

Reported by a customer: in a multi-select Combobox, type filter text, press Ctrl/Cmd+A, press Backspace. The input looks empty — but click outside the Combobox and back in, and the text is there again. Using Delete instead of Backspace works correctly.

Root cause. MultiSelection.tsx layers a custom onKeyDown on top of downshift's input props, gated on inputRef.current?.selectionStart === 0:

if (
    (event.key === "Backspace" && inputRef.current?.selectionStart === 0) ||
    (event.key === "ArrowLeft" && isSelectedItemsBoxStyle && inputRef.current?.selectionStart === 0)
) {
    setActiveIndex(selectedItems.length - 1);
}

Fix. downshift already ships exactly the predicate this handler needs, and applies it to its own dropdown Backspace handling — but does not export it. Mirror it locally as isChipNavigationPermitted: no modifier held, and either an empty input or a caret collapsed at position 0. The ArrowLeft branch carried the identical faulty check and is corrected with it. The helper carries a comment naming the downshift version and source file so a future upgrade re-checks it.

What should be covered while testing?

Multi-select Combobox with at least one already-selected chip (with zero chips setActiveIndex(-1) is a no-op and the bug never surfaces):

  1. Type filter text → Ctrl/Cmd+A → Backspace. Input clears, focus stays in the input, no chip becomes active. Click outside, click back in — still empty.
  2. Same with Delete — unchanged behaviour.
  3. Select only part of the text starting at position 0, press Backspace — only the selected characters go, trailing text survives.
  4. Empty filter input + Backspace — last chip becomes active and can be removed (regression risk: this must still work).
  5. selectedItemsStyle="boxes": ArrowLeft with a collapsed caret at position 0 reaches the chips; ArrowLeft with text selected stays in the input.
  6. Single-select Combobox: Backspace on an empty input still clears the selection.

@samuelreichert
samuelreichert requested a review from a team as a code owner August 14, 2026 14:43
@github-actions

This comment has been minimized.

@samuelreichert
samuelreichert force-pushed the WC-3347-combobox-multiselect-backspace branch 2 times, most recently from 9e0e5af to f1e2229 Compare August 17, 2026 08:51
@github-actions

This comment has been minimized.

@samuelreichert
samuelreichert force-pushed the WC-3347-combobox-multiselect-backspace branch from f1e2229 to 95cb2ae Compare August 18, 2026 09:34
@github-actions

This comment has been minimized.

r0b1n
r0b1n previously approved these changes Aug 20, 2026
@samuelreichert
samuelreichert force-pushed the WC-3347-combobox-multiselect-backspace branch 2 times, most recently from f1afa89 to 0bfd36f Compare August 27, 2026 12:53
@samuelreichert
samuelreichert requested a review from r0b1n August 27, 2026 12:54
@github-actions

This comment has been minimized.

gjulivan
gjulivan previously approved these changes Aug 28, 2026
samuelreichert and others added 10 commits September 7, 2026 13:55
…c change

Backspace/Delete on a selected chip drops keyboard focus to the document
body unless the chip was the last one, because downshift only re-focuses
when its own activeIndex changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e removal

downshift removes the selected item but only moves DOM focus when its own
activeIndex changes, which it does not for any chip other than the last.
Track the chip being removed and restore focus to the item that takes its
place once the removal renders, falling back to the filter input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…moval

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@samuelreichert
samuelreichert force-pushed the WC-3347-combobox-multiselect-backspace branch from 7251570 to 586a986 Compare September 7, 2026 11:55
@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

AI Code Review

⚠️ Approved with suggestions — low-severity items only, safe to merge


What was reviewed

File Change
packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx Core fix: isChipNavigationPermitted helper + chip focus-restoration effect
packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx New unit test blocks for WC-3347 key matrix and chip-removal focus
packages/pluggableWidgets/combobox-web/e2e/ComboboxMultiSelectionKeys.spec.js New E2E spec for round-trip blur/refocus and chip navigation
packages/pluggableWidgets/combobox-web/CHANGELOG.md Two ### Fixed entries under [Unreleased]
docs/requirements/e2e-test-guidelines.md New "Keyboard Interaction" table and focus-assertion guidance
packages/pluggableWidgets/combobox-web/openspec/** Archived WC-3347 change + active chip-focus change artifacts

Skipped (out of scope): dist/, pnpm-lock.yaml, openspec .yaml/.md artifacts (docs only)


Findings

⚠️ Low — chipRefs array grows monotonically and is never trimmed

File: packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx line 51, 191

Note: chipRefs.current is populated via inline ref callbacks keyed by index. When a chip is removed, the unmounting ref callback sets chipRefs.current[index] = null, but the array itself is never shortened. Over a long session (many add/remove cycles) the array accumulates stale null slots. This is benign today because the focus lookup is null-safe (chipRefs.current[indexToFocus]?.focus()), and indexToFocus is always within the live chip count. No action required, but worth noting for a future cleanup:

// Optional: reset the array length to match the current chip count each render
chipRefs.current.length = selectedItems.length;

⚠️ Low — E2E task 4.4 and manual verification 5.2 still open

File: packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-chip-focus-after-removal/tasks.md lines 721, 732

Note: Two tasks are unchecked in the archived-in-progress change: the E2E re-run after test fixes (4.4) and manual Studio Pro verification (5.2). The task notes explain both are blocked on CI infrastructure or a local Mendix project — not on code defects. Confirm a green CI run before merging, and either complete or explicitly defer 5.2 in the PR description so reviewers aren't left wondering.


⚠️ Low — isChipNavigationPermitted does not guard the ArrowLeft collapse case

File: packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx line 92–94

Note: The new isChipNavigationPermitted guard mirrors downshift's isKeyDownOperationPermitted and correctly blocks chip navigation when text is selected or a modifier is held. One subtle gap: ArrowLeft with a collapsed caret at position 0 but with value !== "" — e.g. cursor is at the very start of non-empty text — will call setActiveIndex because the predicate returns true (value is non-empty, but selectionStart === 0 && selectionEnd === 0 makes the negated condition false). This is actually intentional per the spec ("ArrowLeft with a collapsed caret at position 0 reaches the chips"), and it matches downshift's own behaviour. No code change needed, but the inline comment on the condition refers to the "Backspace" case only; a brief note that ArrowLeft intentionally permits this case would make it easier for future readers to follow:

// Text still present and the caret is either not at the start or is highlighting a
// range (e.g. after select-all) -> the key belongs to the text field.
// Note: a collapsed caret at position 0 (selectionStart === selectionEnd === 0) intentionally
// passes through — ArrowLeft should still reach the chips from the start of non-empty text.

Positives

  • The isChipNavigationPermitted helper exactly mirrors downshift's internal isKeyDownOperationPermitted and is documented with the version reference and a "re-check on upgrade" note — this is the right approach for borrowed-but-unexported library logic.
  • The focus-restoration useEffect is correctly keyed on selectedItems.length rather than the array identity, and the design doc explicitly explains why (selectedItems can be a fresh array on any render).
  • The useEffect vs useLayoutEffect choice is justified in the design: passive effects run in declaration order so this runs after downshift's own focus effect — a subtle correctness detail that's easy to get wrong.
  • The unit test suite covers all five chip-removal positions (first, middle, last, only) for both Backspace and Delete, and explicitly asserts document.activeElement !== document.body — the exact regression being fixed.
  • The E2E helper functions (selectChips, focusFilterInput) are well-commented with the "why" (collapsed input, centre-click on container), directly referencing the docs/requirements/e2e-test-guidelines.md patterns that were added in the same PR.
  • The "no Escape" comment at the top of the E2E file is exactly the kind of trap that would burn the next person extending the file — surfacing it up front is good practice.
  • CHANGELOG entries are user-facing behaviour descriptions with no implementation detail — exactly the right level of abstraction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants