Skip to content

fix(suggestion): narrow the trigger keyboard guard to the keys BaseSelect breaks - #183

Merged
cc-hearts merged 1 commit into
mainfrom
fix/suggestion-narrow-keyboard-guard
Aug 13, 2026
Merged

fix(suggestion): narrow the trigger keyboard guard to the keys BaseSelect breaks#183
cc-hearts merged 1 commit into
mainfrom
fix/suggestion-narrow-keyboard-guard

Conversation

@cc-hearts

Copy link
Copy Markdown
Member

中文版模板 / Chinese template

🤔 This is a ...

  • 🆕 New feature
  • 🐞 Bug fix
  • 📝 Site / documentation improvement
  • 📽️ Demo improvement
  • 💄 Component style improvement
  • 🤖 TypeScript definition improvement
  • 📦 Bundle size optimization
  • ⚡️ Performance optimization
  • ⭐️ Feature enhancement
  • 🌐 Internationalization
  • 🛠 Refactoring
  • 🎨 Code style optimization
  • ✅ Test Case
  • 🔀 Branch merge
  • ⏩ Workflow
  • ⌨️ Accessibility improvement
  • ❓ Other (about what?)

🔗 Related Issues

💡 Background and Solution

#177 fixed #171 by stopping every keydown on the -content wrapper except Enter while the popup is open. That does keep Space and Enter away from BaseSelect, but it is wider than the problem and leaves four holes. Each one below was reproduced with a real mount before writing the fix.

1. The trigger became a keyboard black hole

stopPropagation() sits below every ancestor listener, so nothing typed in the trigger reaches the host app. Measured: with a listener on window, an Escape and a Cmd+K typed in the trigger produced 0 calls.

That breaks, among others, Modal/Drawer Esc — @v-c/portal's esc stack listens in the bubble phase on window:

function onWindowKeyDown(e) {
  if (e.key === "Escape" && !e.isComposing) { /* ... close the top-most portal */ }
}
window.addEventListener("keydown", onWindowKeyDown);

A Sender inside a Modal is a common layout in chat UIs, and Esc stopped closing it.

2. Enter was a dead key whenever the popup could not use it

useActive returns false for Enter as long as the popup is open, which makes Sender skip submitting, and the wrapper then forwarded the event to BaseSelect, which preventDefault()s Enter unconditionally. When the option list has nothing to select, the key does nothing at all — no selection, no newline, no submit. Two reachable cases:

  • the active item has children (the basic demo's Explore a topic) — measured defaultPrevented: true, select never fired;
  • a function items filtered down to [] while the popup is still open.

3. Enter from an IME composition, or with a modifier, selected a suggestion

Measured with the popup open: an Enter carrying isComposing: true was forwarded, preventDefault()ed, and emitted select("report") — so confirming a Chinese candidate picks a suggestion instead. Shift+Enter behaved the same way, which also means a submitType="shiftEnter" user cannot send at all while the popup is open (submits: [], selected: ["report"]). Sender already guards on isComposing and on modifiers in TextArea.tsx; Suggestion did not.

4. Unrelated: a function items lost its trigger info

items is usually an inline arrow (see the trigger demo), so it gets a new identity on every parent render, the watcher re-evaluates it with no argument and the list is rebuilt as if nothing had triggered it — Trigger by 'undefined'. Since Sender re-renders the parent on every keystroke, this fires constantly in real usage.

Solution

Stop only what BaseSelect's non-editable keyboard model actually breaks, and let everything else bubble:

const onContentKeyDown = (event: KeyboardEvent) => {
  const { key } = event;

  if (key === "Enter") {
    // Forward it only when the popup owns this Enter, so the option list can select.
    if (!shouldSelectOnEnter(event)) {
      event.stopPropagation();
    }
    return;
  }

  if (key === " " || (key === "Backspace" && mergedOpen.value)) {
    event.stopPropagation();
  }
};

Space and Backspace keep #177's behaviour (onInternalKeyDown always preventDefault()s Space; the option list turns Backspace into prevColumn() / close, which is wrong inside a textarea). Escape, arrows and application shortcuts now reach the app again.

Who owns an Enter is decided in one place, in useActive, and reused by the wrapper:

const shouldSelectOnEnter = (event: KeyboardEvent) => {
  if (!open.value || !isPlainEnter(event)) return false;

  const activeItem = getActiveItem();
  return !!activeItem && !activeItem.children?.length;
};

useActive's own Enter branch follows the same rule: a modifier or IME Enter is left to the trigger, an Enter with nothing to select falls through (so Sender submits or inserts a newline), and an Enter on a parent item expands it like ArrowRight instead of doing nothing. Escape additionally calls stopPropagation() — consistent with the arrow branches — so the popup absorbs the first Esc and the Modal behind it survives.

Finally, Suggestion remembers the last onTrigger info and passes it back when a function items is re-evaluated.

Tests

15 → 22 cases. New: keys still bubble to window (with Esc layering), Enter expands a parent item, Enter falls back to the trigger on an empty list, IME and modifier Enter are not hijacked, Backspace does not close the popup, a function items keeps its trigger info, plus one Suggestion + real Sender integration case walking the original #171 flow — space types while the popup is open, Enter selects instead of sending, Enter sends again once it is closed.

Full suite: 472 passed (46 files). vp check and type-check both pass.

Manual pass still worth doing on a real browser for IME behaviour (Chrome reports key: "Process", Safari key: "Enter" + isComposing) and for the macOS double-space-to-period substitution mentioned in ant-design/x#1873.

📝 Change Log

Language Changelog
🇺🇸 English Fix Suggestion swallowing keyboard events that belong to the app around it, such as Esc closing a Modal, and fix Enter doing nothing when the highlighted item has children or the list is empty. Enter from an IME composition or with a modifier now stays with the trigger, and a function items no longer loses its trigger info.
🇨🇳 Chinese 修复 Suggestion 吞掉宿主应用按键的问题(如 Esc 无法关闭外层 Modal),以及高亮项含子项或列表为空时回车无响应的问题。输入法组合中或带修饰键的回车不再被建议列表接管,函数式 items 也不会再丢失触发信息。

…lect breaks

The content wrapper stopped every keydown except Enter while the popup was open,
so nothing typed in the trigger reached the app around it. Modal and Drawer never
saw Escape — the portal esc stack listens on window — and application shortcuts
died while the trigger had focus.

Stop only what BaseSelect's non-editable keyboard model actually breaks: Space,
Backspace while the popup is open, and an Enter the popup cannot use. Everything
else keeps bubbling.

Enter now reaches Cascader only when the popup owns it, meaning a plain Enter (no
modifier, no IME composition) with a leaf item highlighted. Enter on a parent item
expands it like ArrowRight instead of doing nothing, and an Enter with nothing to
select falls back to the trigger so Sender can submit or insert a newline again.
Escape stops at the popup it closes, so a Modal behind it survives the first press.

Also remember the info of the last trigger. A function `items` is usually an inline
arrow with a new identity on every parent render, and re-evaluating it without that
info rebuilt the list as if nothing had triggered it.
@cc-hearts
cc-hearts merged commit 440d046 into main Aug 13, 2026
7 checks passed
@cc-hearts
cc-hearts deleted the fix/suggestion-narrow-keyboard-guard branch August 13, 2026 02:27
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.

Suggestion 快捷指令导致 Sender 无法输入空格、换行

1 participant