Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/suggestion-menu-keymap.md
Original file line number Diff line number Diff line change
@@ -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.
100 changes: 100 additions & 0 deletions packages/react/src/suggestion-menu/root/host-events.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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)
}
}
53 changes: 53 additions & 0 deletions packages/react/src/suggestion-menu/root/keymap.ts
Original file line number Diff line number Diff line change
@@ -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)
}
119 changes: 105 additions & 14 deletions packages/react/src/suggestion-menu/root/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -94,6 +101,30 @@ export interface SuggestionMenuRootProps<Payload = unknown>
| ((state: SuggestionMenuRootRenderState<Payload>) => 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<ForwardKeyDown | null>
}) {
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<string>()
function warnOnce(key: string, message: string) {
if (process.env.NODE_ENV === 'production' || warned.has(key)) return
Expand Down Expand Up @@ -185,21 +216,80 @@ export function SuggestionMenuRoot<Payload = unknown>(

const handleOpenChangeRef = React.useRef(handleOpenChange)
handleOpenChangeRef.current = handleOpenChange
const forwardKeyDownRef = React.useRef<ForwardKeyDown | null>(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(
Expand Down Expand Up @@ -272,6 +362,7 @@ export function SuggestionMenuRoot<Payload = unknown>(
closeOnOutsidePress="pointerdown"
componentName="suggestion-menu"
>
<KeyboardBridge forwardKeyDownRef={forwardKeyDownRef} />
<SuggestionMenuRootContext.Provider value={rootContext}>
<Popover.Root
{...rest}
Expand Down
Loading
Loading