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-no-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bazza-ui/react': minor
---

Add `noResults` to `SuggestionMenu.Root`. With `'empty'` (the default) the menu stays open and shows `Empty` when nothing matches, and Enter reaches the host input. With `'close'` the menu closes once every search for the query has finished empty (reason `'no-results'`), before the empty popup is painted. An `update()` with the same query leaves it closed, for example when only the caret moved. An `update()` with any other query searches again. `SuggestionMenu.useAsyncMenuCoordinator` is available for showing a "searching…" indicator while results refetch.
2 changes: 2 additions & 0 deletions packages/react/src/suggestion-menu/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import type {
* - `escape-key`: Escape was pressed in the host input
* - `outside-press`: a press outside the popup and the host input
* - `focus-out`: the host input lost focus
* - `no-results`: with `noResults: 'close'`, the results settled empty
*/
export type SuggestionMenuOpenChangeReason =
| typeof REASONS.imperativeAction
| typeof REASONS.itemPress
| typeof REASONS.escapeKey
| typeof REASONS.outsidePress
| typeof REASONS.focusOut
| typeof REASONS.noResults
| typeof REASONS.none

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/suggestion-menu/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export class SuggestionMenuHandle<Payload = unknown> {
if (!this.isOpen) this.#requestOpen(true)
}

/** Closes the menu. */
/**
* Closes the menu. Call it whenever the trigger ends (deleted, or the caret
* left it), even if the menu is already closed: it also resets what the
* menu remembers about the trigger, such as a query that had no results.
*/
close(): void {
this.#requestOpen(false)
}
Expand Down
71 changes: 69 additions & 2 deletions packages/react/src/suggestion-menu/root/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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 { PopupMenuOpenChangeReason } from '../../internal/popup-menu/events.js'
import {
type ForwardKeyDown,
type PopupMenuHighlightChangeHandler,
Expand Down Expand Up @@ -110,6 +111,16 @@ export interface SuggestionMenuRootProps<Payload = unknown>
* @default (count, label) => count === 0 ? 'No results' : `${count} results, first: ${label}`
*/
getAriaResultsText?: GetAriaResultsText
/**
* What happens when nothing matches the query, once every search for it has
* finished.
* - `'empty'`: the menu stays open and shows `Empty`; Enter reaches the host.
* - `'close'`: the menu closes (reason `'no-results'`). An `update()` with
* the same query leaves it closed; one with any other query searches
* again.
* @default 'empty'
*/
noResults?: 'empty' | 'close'
/** The menu's parts, or a function of the payload and query that returns them. */
children:
| React.ReactNode
Expand Down Expand Up @@ -169,6 +180,7 @@ export function SuggestionMenuRoot<Payload = unknown>(
getResolvedId,
idScope = 'surface',
getAriaResultsText = defaultGetAriaResultsText,
noResults = 'empty',
children,
...rest
} = props
Expand Down Expand Up @@ -234,6 +246,13 @@ export function SuggestionMenuRoot<Payload = unknown>(
handleOpenChangeRef.current = handleOpenChange
const forwardKeyDownRef = React.useRef<ForwardKeyDown | null>(null)
const detachHostRef = React.useRef<(() => void) | null>(null)
// With `noResults: 'close'`: the query whose results settled empty.
const emptyQueryRef = React.useRef<string | null>(null)
const noResultsRef = React.useRef(noResults)
noResultsRef.current = noResults
const queryPropRef = React.useRef(queryProp)
queryPropRef.current = queryProp
const openOnQueryChangeRef = React.useRef(false)

// `aria-activedescendant` follows keyboard highlight only (ADR 0005). Set
// when the menu moves the highlight for a key; cleared when the query
Expand All @@ -249,7 +268,31 @@ export function SuggestionMenuRoot<Payload = unknown>(
nextOpen: boolean,
reason: SuggestionMenuOpenChangeReason,
event?: Event,
) => handleOpenChangeRef.current(nextOpen, reason, event)
) => {
if (reason === REASONS.imperativeAction) {
const empty = emptyQueryRef.current
if (nextOpen && empty !== null) {
// The query that settled empty stays closed (e.g. only the caret
// moved); any other query searches again. A controlled query may
// not have rendered yet, so that open waits for it.
const controlled = queryPropRef.current
if (controlled === empty) {
openOnQueryChangeRef.current = true
return
}
if (controlled === undefined && handle.getState().query === empty) {
return
}
}
emptyQueryRef.current = null
openOnQueryChangeRef.current = false
}
handleOpenChangeRef.current(
nextOpen,
reason as PopupMenuOpenChangeReason,
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
Expand Down Expand Up @@ -310,6 +353,9 @@ export function SuggestionMenuRoot<Payload = unknown>(
})
},
attachHost: (host) => {
// A new host (or none) starts fresh.
emptyQueryRef.current = null
openOnQueryChangeRef.current = false
detachHostRef.current?.()
detachHostRef.current = host
? watchHost(host, {
Expand Down Expand Up @@ -338,6 +384,19 @@ export function SuggestionMenuRoot<Payload = unknown>(
}

const query = queryProp ?? handleState.query
// An open that waited for a controlled query to leave the empty one. The
// prop can render after the `update()` (e.g. state set from an editor's
// native listener), so it waits until the next `update()`, `close()` or
// host change.
React.useLayoutEffect(() => {
if (!openOnQueryChangeRef.current || query === emptyQueryRef.current) return
openOnQueryChangeRef.current = false
emptyQueryRef.current = null
handleOpenChangeRef.current(
true,
REASONS.imperativeAction as PopupMenuOpenChangeReason,
)
}, [query])
const anchor = anchorProp ?? handleState.anchor
const payload = handleState.payload
const host = handleState.host
Expand Down Expand Up @@ -367,6 +426,14 @@ export function SuggestionMenuRoot<Payload = unknown>(
getAriaResultsTextRef.current = getAriaResultsText
const reportResults = React.useCallback(
(count: number, label: string | null) => {
if (count === 0 && noResultsRef.current === 'close') {
emptyQueryRef.current = queryRef.current
handleOpenChangeRef.current(
false,
REASONS.noResults as PopupMenuOpenChangeReason,
)
return
}
const text = getAriaResultsTextRef.current(count, label)
setAnnouncement((current) => ({ text, key: (current?.key ?? 0) + 1 }))
},
Expand Down Expand Up @@ -407,7 +474,7 @@ export function SuggestionMenuRoot<Payload = unknown>(
(nextOpen: boolean, details: Popover.Root.ChangeEventDetails) => {
handleOpenChange(
nextOpen,
details.reason as SuggestionMenuOpenChangeReason,
details.reason as PopupMenuOpenChangeReason,
details.event,
)
},
Expand Down
Loading
Loading