diff --git a/.changeset/suggestion-menu-no-results.md b/.changeset/suggestion-menu-no-results.md new file mode 100644 index 00000000..49a02fca --- /dev/null +++ b/.changeset/suggestion-menu-no-results.md @@ -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. diff --git a/packages/react/src/suggestion-menu/events.ts b/packages/react/src/suggestion-menu/events.ts index 7044eda5..a741c494 100644 --- a/packages/react/src/suggestion-menu/events.ts +++ b/packages/react/src/suggestion-menu/events.ts @@ -16,6 +16,7 @@ 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 @@ -23,6 +24,7 @@ export type SuggestionMenuOpenChangeReason = | typeof REASONS.escapeKey | typeof REASONS.outsidePress | typeof REASONS.focusOut + | typeof REASONS.noResults | typeof REASONS.none /** diff --git a/packages/react/src/suggestion-menu/handle.ts b/packages/react/src/suggestion-menu/handle.ts index 8b3b0893..749724bf 100644 --- a/packages/react/src/suggestion-menu/handle.ts +++ b/packages/react/src/suggestion-menu/handle.ts @@ -128,7 +128,11 @@ export class SuggestionMenuHandle { 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) } diff --git a/packages/react/src/suggestion-menu/root/root.tsx b/packages/react/src/suggestion-menu/root/root.tsx index 5300691c..6e310e5c 100644 --- a/packages/react/src/suggestion-menu/root/root.tsx +++ b/packages/react/src/suggestion-menu/root/root.tsx @@ -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, @@ -110,6 +111,16 @@ export interface SuggestionMenuRootProps * @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 @@ -169,6 +180,7 @@ export function SuggestionMenuRoot( getResolvedId, idScope = 'surface', getAriaResultsText = defaultGetAriaResultsText, + noResults = 'empty', children, ...rest } = props @@ -234,6 +246,13 @@ export function SuggestionMenuRoot( handleOpenChangeRef.current = handleOpenChange const forwardKeyDownRef = React.useRef(null) const detachHostRef = React.useRef<(() => void) | null>(null) + // With `noResults: 'close'`: the query whose results settled empty. + const emptyQueryRef = React.useRef(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 @@ -249,7 +268,31 @@ export function SuggestionMenuRoot( 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 @@ -310,6 +353,9 @@ export function SuggestionMenuRoot( }) }, attachHost: (host) => { + // A new host (or none) starts fresh. + emptyQueryRef.current = null + openOnQueryChangeRef.current = false detachHostRef.current?.() detachHostRef.current = host ? watchHost(host, { @@ -338,6 +384,19 @@ export function SuggestionMenuRoot( } 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 @@ -367,6 +426,14 @@ export function SuggestionMenuRoot( 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 })) }, @@ -407,7 +474,7 @@ export function SuggestionMenuRoot( (nextOpen: boolean, details: Popover.Root.ChangeEventDetails) => { handleOpenChange( nextOpen, - details.reason as SuggestionMenuOpenChangeReason, + details.reason as PopupMenuOpenChangeReason, details.event, ) }, diff --git a/packages/react/src/suggestion-menu/suggestion-menu.no-results.test.tsx b/packages/react/src/suggestion-menu/suggestion-menu.no-results.test.tsx new file mode 100644 index 00000000..12f3492e --- /dev/null +++ b/packages/react/src/suggestion-menu/suggestion-menu.no-results.test.tsx @@ -0,0 +1,373 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { + ItemDef, + NodeDef, + QueryLoaderConfig, +} from '../internal/popup-menu/index.js' +import { createVanillaQueryLoader } from '../loaders/vanilla.js' +import { SuggestionMenu } from './index.js' + +function Rows() { + const { nodes, renderNode } = SuggestionMenu.useDataList() + return <>{nodes.map(renderNode)} +} + +const item = (value: string): ItemDef => ({ + kind: 'item', + value, + render: ({ props }) => ( + + {value} + + ), +}) + +function setup( + options: { + noResults?: 'empty' | 'close' + content?: ItemDef[] + asyncContent?: QueryLoaderConfig + } = {}, +) { + const menu = SuggestionMenu.createHandle() + const onOpenChange = vi.fn() + const keys: boolean[] = [] + render( + <> +