From ef1848b439d05bd24ec1a7c970957c5f4c5f5801 Mon Sep 17 00:00:00 2001 From: Kian Bazza Date: Mon, 28 Sep 2026 20:04:13 -0400 Subject: [PATCH] feat(suggestion-menu): add the keymap and dismissal --- .changeset/suggestion-menu-keymap.md | 5 + .../src/suggestion-menu/root/host-events.ts | 100 +++ .../react/src/suggestion-menu/root/keymap.ts | 53 ++ .../react/src/suggestion-menu/root/root.tsx | 119 +++- .../suggestion-menu.keymap.test.tsx | 607 ++++++++++++++++++ 5 files changed, 870 insertions(+), 14 deletions(-) create mode 100644 .changeset/suggestion-menu-keymap.md create mode 100644 packages/react/src/suggestion-menu/root/host-events.ts create mode 100644 packages/react/src/suggestion-menu/root/keymap.ts create mode 100644 packages/react/src/suggestion-menu/suggestion-menu.keymap.test.tsx diff --git a/.changeset/suggestion-menu-keymap.md b/.changeset/suggestion-menu-keymap.md new file mode 100644 index 00000000..0a4dfdcd --- /dev/null +++ b/.changeset/suggestion-menu-keymap.md @@ -0,0 +1,5 @@ +--- +'@bazza-ui/react': minor +--- + +`SuggestionMenu` handles the keyboard from the host input. Pass key presses to `handle.handleKeyDown(event)`: while the menu is open, ↑/↓, Ctrl+N/P and Home/End move the highlight, Enter chooses the highlighted row, and Escape closes the menu without closing an enclosing dialog. It returns `true` when the menu used the key. Tab, caret keys, typing, modified keys and keys pressed during IME composition stay with the input. The menu closes when the input loses focus, but not when focus moves into the menu or the window loses focus, and pressing inside the menu keeps focus in the input. diff --git a/packages/react/src/suggestion-menu/root/host-events.ts b/packages/react/src/suggestion-menu/root/host-events.ts new file mode 100644 index 00000000..dca2f1f3 --- /dev/null +++ b/packages/react/src/suggestion-menu/root/host-events.ts @@ -0,0 +1,100 @@ +'use client' + +export interface HostEventsOptions { + isOpen: () => boolean + /** The popup element, while it's mounted. */ + getPopup: () => HTMLElement | null + /** Focus left the host for somewhere other than the popup. */ + onFocusOut: (event: FocusEvent) => void + /** A press inside the popup is about to choose a row. */ + onSelectionStart: () => void + /** The press has been handled. */ + onSelectionEnd: () => void +} + +function isInPopup(event: Event, popup: HTMLElement | null) { + if (!popup) return false + // composedPath() sees through shadow roots, where `target` is retargeted. + const target = event.composedPath()[0] ?? event.target + return target instanceof Node && popup.contains(target) +} + +/** + * Watches the attached host input for the menu: + * - focus leaving the host closes the menu, unless it moved into the popup, + * the whole window lost focus, or the host got it back before the next task; + * - a press inside the popup doesn't move focus out of the host (form fields + * inside the popup keep their native focus); + * - a click inside the popup marks a row selection, so the Root can tell a + * close requested by the row's `onSelect` from the menu's own. + * Returns a function that stops watching. + */ +export function watchHost(host: Element, options: HostEventsOptions) { + const doc = host.ownerDocument + let focusTimeout: ReturnType | undefined + let endSelection: (() => void) | null = null + + const onFocusOut = (event: Event) => { + if (!options.isOpen()) return + const next = (event as FocusEvent).relatedTarget + const popup = options.getPopup() + if (next instanceof Node && popup?.contains(next)) return + clearTimeout(focusTimeout) + focusTimeout = setTimeout(() => { + if (!options.isOpen() || !doc.hasFocus()) return + // Read now: the host may have been attached before it was in the page. + const root = host.getRootNode() as Document | ShadowRoot + if (root.activeElement === host) return + options.onFocusOut(event as FocusEvent) + }) + } + + const onMouseDown = (event: MouseEvent) => { + if (!options.isOpen() || !isInPopup(event, options.getPopup())) return + const target = event.composedPath()[0] + if ( + target instanceof Element && + target.closest('input, textarea, select, [contenteditable]') + ) { + return + } + event.preventDefault() + } + + const onClick = (event: MouseEvent) => { + if (!options.isOpen() || !isInPopup(event, options.getPopup())) return + // A click fired from inside a row's handler belongs to that selection. + if (endSelection) return + options.onSelectionStart() + // The row's handler (React's listener on its root) runs later in this + // dispatch, and browsers run microtasks between listeners, so the + // selection ends when the click reaches the window. A timeout covers a + // click that stopped propagating before that. + const view = doc.defaultView + // Only this click ends it: the row's handler may fire another one. + const onWindowClick = (windowEvent: Event) => { + if (windowEvent === event) end() + } + const end = () => { + if (endSelection !== end) return + endSelection = null + view?.removeEventListener('click', onWindowClick) + clearTimeout(timeout) + options.onSelectionEnd() + } + endSelection = end + view?.addEventListener('click', onWindowClick) + const timeout = setTimeout(end) + } + + host.addEventListener('focusout', onFocusOut) + doc.addEventListener('mousedown', onMouseDown, true) + doc.addEventListener('click', onClick, true) + return () => { + endSelection?.() + clearTimeout(focusTimeout) + host.removeEventListener('focusout', onFocusOut) + doc.removeEventListener('mousedown', onMouseDown, true) + doc.removeEventListener('click', onClick, true) + } +} diff --git a/packages/react/src/suggestion-menu/root/keymap.ts b/packages/react/src/suggestion-menu/root/keymap.ts new file mode 100644 index 00000000..3e967828 --- /dev/null +++ b/packages/react/src/suggestion-menu/root/keymap.ts @@ -0,0 +1,53 @@ +import type * as React from 'react' + +type HostKeyboardEvent = KeyboardEvent | React.KeyboardEvent + +/** Keys the menu may use while open; everything else stays with the host. */ +const MENU_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter']) + +export interface SuggestionMenuKeymapOptions { + /** Whether the menu is open. */ + isOpen: () => boolean + /** Runs the menu's own keyboard handling; returns whether it used the key. */ + forward: (event: HostKeyboardEvent) => boolean + /** Closes the menu because Escape was pressed. */ + dismiss: (event: HostKeyboardEvent) => void +} + +/** + * Decides what a key press in the host input does to the suggestion menu. + * Returns `true` when the menu used the key (its default action is then + * prevented), `false` when the key keeps its meaning in the host. + * + * While open: ↑/↓, Ctrl+N/P and Home/End move the highlight, Enter chooses the + * highlighted row, and Escape closes the menu without reaching an enclosing + * dialog. Tab, ←/→, PageUp/PageDown, printable keys, and any key with ⌘, Alt + * or Shift (or Ctrl other than N/P) belong to the host. Nothing is handled + * while closed or during IME composition. + */ +export function handleSuggestionMenuKey( + event: HostKeyboardEvent, + options: SuggestionMenuKeymapOptions, +): boolean { + const native = 'nativeEvent' in event ? event.nativeEvent : event + if (native.isComposing || event.keyCode === 229) return false + if (event.defaultPrevented || !options.isOpen()) return false + + const { key, ctrlKey, metaKey, altKey, shiftKey } = event + if (metaKey || altKey || shiftKey) return false + + if (ctrlKey) { + if (key !== 'n' && key !== 'p') return false + return options.forward(event) + } + + if (key === 'Escape') { + event.preventDefault() + event.stopPropagation() + options.dismiss(event) + return true + } + + if (!MENU_KEYS.has(key)) return false + return options.forward(event) +} diff --git a/packages/react/src/suggestion-menu/root/root.tsx b/packages/react/src/suggestion-menu/root/root.tsx index f2a6d924..e69a893b 100644 --- a/packages/react/src/suggestion-menu/root/root.tsx +++ b/packages/react/src/suggestion-menu/root/root.tsx @@ -4,21 +4,28 @@ import { Popover, type PopoverRootProps } from '@base-ui/react/popover' import * as React from 'react' import type { ListboxStore, VirtualItem } from '../../internal/listbox/index.js' import { + type ForwardKeyDown, type PopupMenuHighlightChangeHandler, PopupMenuProviders, type UsePopupMenuRootParams, + useFocusOwner, + useForwardedKeyDown, + usePopupMenuContext, usePopupMenuRoot, } from '../../internal/popup-menu/index.js' import type { GetResolvedIdFn, PopupMenuIdScope, } from '../../internal/popup-menu/menu-tree/types.js' +import { REASONS } from '../../utils/events/index.js' import type { SuggestionMenuHighlightChangeEventDetails, SuggestionMenuOpenChangeEventDetails, SuggestionMenuOpenChangeReason, } from '../events.js' import type { SuggestionMenuAnchor, SuggestionMenuHandle } from '../handle.js' +import { watchHost } from './host-events.js' +import { handleSuggestionMenuKey } from './keymap.js' import { SuggestionMenuRootContext } from './root-context.js' /** What the Root's `children` function receives. */ @@ -94,6 +101,30 @@ export interface SuggestionMenuRootProps | ((state: SuggestionMenuRootRenderState) => React.ReactNode) } +/** + * Runs inside the providers so the menu's keyboard handling can reach the + * store; hands the forwarding function to the Root. + */ +function KeyboardBridge(props: { + forwardKeyDownRef: React.MutableRefObject +}) { + const { store, closeAll } = usePopupMenuContext() + const focusOwnerStore = useFocusOwner() + const open = store.useState('open') + const forward = useForwardedKeyDown({ + store, + surfaceId: 'suggestion-menu-host', + focusOwnerStore, + depth: 0, + submenuContext: null, + subpageContext: null, + enabled: open, + closeAll, + }) + props.forwardKeyDownRef.current = forward + return null +} + const warned = new Set() function warnOnce(key: string, message: string) { if (process.env.NODE_ENV === 'production' || warned.has(key)) return @@ -185,21 +216,80 @@ export function SuggestionMenuRoot( const handleOpenChangeRef = React.useRef(handleOpenChange) handleOpenChangeRef.current = handleOpenChange + const forwardKeyDownRef = React.useRef(null) + const detachHostRef = React.useRef<(() => void) | null>(null) - React.useLayoutEffect( - () => - handle.connect({ - isOpen: () => store.select('open'), - requestOpenChange: ( - nextOpen: boolean, - reason: SuggestionMenuOpenChangeReason, - event?: Event, - ) => handleOpenChangeRef.current(nextOpen, reason, event), - handleKeyDown: () => false, - attachHost: () => {}, - }), - [handle, store], - ) + React.useLayoutEffect(() => { + const isOpen = () => store.select('open') + const send = ( + nextOpen: boolean, + reason: SuggestionMenuOpenChangeReason, + event?: Event, + ) => handleOpenChangeRef.current(nextOpen, reason, event) + + // While a row is being chosen (Enter, or a click on a row), a `close()` + // from the row's `onSelect` waits, so the menu's own close reports + // `item-press` first; the waiting close is then a no-op. + let selecting = 0 + let heldClose: { event: Event | undefined } | null = null + const startSelection = () => { + selecting += 1 + } + const endSelection = () => { + selecting -= 1 + if (selecting > 0 || !heldClose) return + const { event } = heldClose + heldClose = null + send(false, REASONS.imperativeAction, event) + } + const request = ( + nextOpen: boolean, + reason: SuggestionMenuOpenChangeReason, + event?: Event, + ) => { + if (!nextOpen && reason === REASONS.imperativeAction && selecting > 0) { + heldClose = { event } + return + } + send(nextOpen, reason, event) + } + + return handle.connect({ + isOpen, + requestOpenChange: request, + handleKeyDown: (event) => + handleSuggestionMenuKey(event, { + isOpen, + forward: (forwarded) => { + const choosing = forwarded.key === 'Enter' + if (choosing) startSelection() + try { + return forwardKeyDownRef.current?.(forwarded) ?? false + } finally { + if (choosing) endSelection() + } + }, + dismiss: (keyEvent) => + request( + false, + REASONS.escapeKey, + 'nativeEvent' in keyEvent ? keyEvent.nativeEvent : keyEvent, + ), + }), + attachHost: (host) => { + detachHostRef.current?.() + detachHostRef.current = host + ? watchHost(host, { + isOpen, + getPopup: () => store.context.refs.popupRef.current, + onFocusOut: (event) => request(false, REASONS.focusOut, event), + onSelectionStart: startSelection, + onSelectionEnd: endSelection, + }) + : null + }, + }) + }, [handle, store]) if (queryProp !== undefined && handleState.updated.query) { warnOnce( @@ -272,6 +362,7 @@ export function SuggestionMenuRoot( closeOnOutsidePress="pointerdown" componentName="suggestion-menu" > + {nodes.map(renderNode)} +} + +function setup( + options: { + onSelect?: (value: string) => void + footer?: React.ReactNode + } = {}, +) { + const menu = SuggestionMenu.createHandle() + const onOpenChange = vi.fn() + const handled: Array<[string, boolean]> = [] + const item = (value: string): ItemDef => ({ + kind: 'item', + value, + onSelect: () => options.onSelect?.(value), + render: ({ props }) => ( + + {value} + + ), + }) + const onDialogKeyDown = vi.fn() + render( + // biome-ignore lint/a11y/noStaticElementInteractions: stands in for an enclosing dialog's Escape listener +
+