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

`SuggestionMenu` anchors the popup to the host's text. `anchor` is a function returning a `DOMRect`, measured again whenever the page or the field scrolls, so the popup follows the text. A new position is computed only when the rect changes, and returning `null` keeps the last position. `SuggestionMenu.Positioner` places the popup below and aligned to the start by default. If there's no room below, it opens above, and it stays on that side until it closes instead of flipping while the user types. It never moves beside the anchor, where it would cover the text.
2 changes: 1 addition & 1 deletion packages/react/src/suggestion-menu/index.parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export {
PopupMenuLinkItem as LinkItem,
PopupMenuLoading as Loading,
PopupMenuPortal as Portal,
PopupMenuPositioner as Positioner,
PopupMenuScrollDownArrow as ScrollDownArrow,
PopupMenuScrollUpArrow as ScrollUpArrow,
PopupMenuSeparator as Separator,
Expand All @@ -37,5 +36,6 @@ export {
} from './handle.js'
export { SuggestionMenuList as List } from './list/list.js'
export { SuggestionMenuPopup as Popup } from './popup/popup.js'
export { SuggestionMenuPositioner as Positioner } from './positioner/positioner.js'
export { SuggestionMenuRoot as Root } from './root/root.js'
export { SuggestionMenuSurface as Surface } from './surface/surface.js'
5 changes: 4 additions & 1 deletion packages/react/src/suggestion-menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export type {
SuggestionMenuPopup,
SuggestionMenuPopupProps,
} from './popup/popup.js'
export type {
SuggestionMenuPositioner,
SuggestionMenuPositionerProps,
} from './positioner/positioner.js'
export type { GetAriaResultsText as SuggestionMenuGetAriaResultsText } from './root/results-status.js'
export type {
SuggestionMenuRoot,
Expand Down Expand Up @@ -70,7 +74,6 @@ export type {
PopupMenuLoadingProps as SuggestionMenuLoadingProps,
PopupMenuNode,
PopupMenuPortalProps as SuggestionMenuPortalProps,
PopupMenuPositionerProps as SuggestionMenuPositionerProps,
PopupMenuSeparatorProps as SuggestionMenuSeparatorProps,
QueryLoaderConfig,
SeparatorDef,
Expand Down
115 changes: 115 additions & 0 deletions packages/react/src/suggestion-menu/positioner/positioner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client'

import * as React from 'react'
import {
PopupMenuPositioner,
usePopupMenuContext,
} from '../../internal/popup-menu/index.js'
import { useSuggestionMenuRootContext } from '../root/root-context.js'

type Side = NonNullable<PopupMenuPositioner.Props['side']>
type CollisionAvoidance = PopupMenuPositioner.Props['collisionAvoidance']

export interface SuggestionMenuPositionerProps
extends PopupMenuPositioner.Props {
/**
* Which side of the anchor to place the popup on. If there's no room, it
* opens on the other side, and stays there until it closes.
* @default 'bottom'
*/
side?: Side
/**
* How to align the popup against the anchor.
* @default 'start'
*/
align?: PopupMenuPositioner.Props['align']
}

/**
* Positions the popup against the host's text. The side is chosen when the
* menu opens and held until it closes: the popup may shift along the line,
* but never flips between above and below while it's open.
* Renders a `<div>` element.
*/
export const SuggestionMenuPositioner = React.forwardRef<
HTMLDivElement,
SuggestionMenuPositionerProps
>(function SuggestionMenuPositioner(props, forwardedRef) {
const {
side = 'bottom',
align = 'start',
collisionAvoidance,
...rest
} = props
const { store } = usePopupMenuContext()
const { query, anchorReady: anchored } = useSuggestionMenuRootContext()
const open = store.useState('open')
const positionerRef = React.useRef<HTMLDivElement | null>(null)
const [heldSide, setHeldSide] = React.useState<Side | null>(null)

// The side is chosen when the menu opens: Base UI positions the popup
// asynchronously, and the side it chose (after any flip) has landed by the
// next frame after there's an anchor. It's held from then on.
const holdRenderedSide = React.useCallback(() => {
const rendered = positionerRef.current?.getAttribute('data-side')
setHeldSide((current) => current ?? ((rendered as Side | null) || side))
}, [side])
React.useLayoutEffect(() => {
if (!open) {
setHeldSide(null)
return undefined
}
if (!anchored) return undefined
const frame = requestAnimationFrame(holdRenderedSide)
return () => cancelAnimationFrame(frame)
}, [open, anchored, holdRenderedSide])
// Frames don't run in a background tab; the first keystroke after the
// popup was positioned against the anchor holds it then.
const anchoredQueryRef = React.useRef<string | null>(null)
React.useLayoutEffect(() => {
if (!open || !anchored) {
anchoredQueryRef.current = null
return
}
if (anchoredQueryRef.current === null) {
anchoredQueryRef.current = query
return
}
if (query !== anchoredQueryRef.current) holdRenderedSide()
}, [open, query, anchored, holdRenderedSide])

// Only ever above or below: a popup beside the anchor would cover the text
// being typed.
const base: CollisionAvoidance = collisionAvoidance ?? {
side: 'flip',
align: 'shift',
fallbackAxisSide: 'none',
}
const resolvedCollisionAvoidance: CollisionAvoidance =
heldSide !== null
? { ...(typeof base === 'object' ? base : {}), side: 'none' }
: base

const setRef = React.useCallback(
(node: HTMLDivElement | null) => {
positionerRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
},
[forwardedRef],
)

return (
<PopupMenuPositioner
ref={setRef}
{...rest}
side={heldSide ?? side}
align={align}
collisionAvoidance={resolvedCollisionAvoidance}
/>
)
})

export namespace SuggestionMenuPositioner {
export type Props = SuggestionMenuPositionerProps
}
2 changes: 2 additions & 0 deletions packages/react/src/suggestion-menu/root/root-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface SuggestionMenuRootContextValue {
reportResults: (count: number, label: string | null) => void
/** Writes the host's ARIA attributes again, e.g. once the list mounts. */
syncHostAria: (listId?: string) => void
/** Whether the anchor has measured a rect during this opening. */
anchorReady: boolean
}

export const SuggestionMenuRootContext =
Expand Down
23 changes: 15 additions & 8 deletions packages/react/src/suggestion-menu/root/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
SuggestionMenuResultsStatus,
} from './results-status.js'
import { SuggestionMenuRootContext } from './root-context.js'
import { useVirtualAnchor } from './virtual-anchor.js'

/** What the Root's `children` function receives. */
export interface SuggestionMenuRootRenderState<Payload = unknown> {
Expand Down Expand Up @@ -377,13 +378,18 @@ export function SuggestionMenuRoot<Payload = unknown>(
setAnnouncement(null)
}, [open])

const virtualAnchor = React.useMemo(() => {
if (!anchor) return undefined
return {
getBoundingClientRect: () => anchor() ?? new DOMRect(),
contextElement: host ?? undefined,
}
}, [anchor, host])
// Measured while open: again on each `update()`, query change and opening,
// never on close (the text the anchor measured may be gone by then).
const measureKey = React.useMemo(
() => ({}),
// biome-ignore lint/correctness/useExhaustiveDependencies: these are the triggers
[handleState, query, open],
)
const { virtualAnchor, ready: anchorReady } = useVirtualAnchor(
open ? anchor : undefined,
host,
measureKey,
)

const handleOpenChangeComplete = React.useCallback(
(nextOpen: boolean) => {
Expand Down Expand Up @@ -415,8 +421,9 @@ export function SuggestionMenuRoot<Payload = unknown>(
payload,
reportResults,
syncHostAria,
anchorReady,
}),
[handle, query, payload, reportResults, syncHostAria],
[handle, query, payload, reportResults, syncHostAria, anchorReady],
)

return (
Expand Down
80 changes: 80 additions & 0 deletions packages/react/src/suggestion-menu/root/virtual-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client'

import * as React from 'react'
import type { SuggestionMenuAnchor } from '../handle.js'

/** The anchor the popup is positioned against. */
export interface SuggestionMenuVirtualAnchor {
getBoundingClientRect(): DOMRect
/** The host input, so scrolling its containers repositions the popup. */
contextElement: Element | undefined
}

function sameRect(a: DOMRect, b: DOMRect) {
return (
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
)
}

/**
* Wraps the anchor function in a virtual anchor for the positioner.
*
* Base UI repositions (a flip/shift/size pass) whenever it gets a new anchor
* object, so a new one is made only when the measured rect differs from the
* last one. The anchor is measured once per change of `measureKey` (each
* `update()`, prop change or opening), the anchor function or the host.
* `getBoundingClientRect()` always calls the latest function, so scrolling
* re-measures and the popup follows. When the function returns `null`, the
* last rect is kept.
*/
export function useVirtualAnchor(
anchor: SuggestionMenuAnchor | null | undefined,
host: Element | null,
measureKey: unknown,
): { virtualAnchor: SuggestionMenuVirtualAnchor | undefined; ready: boolean } {
const anchorRef = React.useRef(anchor)
anchorRef.current = anchor
const lastRectRef = React.useRef<DOMRect | null>(null)
// Whether a rect was measured since the anchor was last passed in (i.e.
// during this opening); the kept object may still hold the last one's.
const readyRef = React.useRef(false)
if (!anchor) readyRef.current = false
const cacheRef = React.useRef<{
anchor: SuggestionMenuAnchor | null | undefined
host: Element | null
measureKey: unknown
value: SuggestionMenuVirtualAnchor | undefined
} | null>(null)

const cache = cacheRef.current
if (
cache &&
cache.anchor === anchor &&
cache.host === host &&
cache.measureKey === measureKey
) {
return { virtualAnchor: cache.value, ready: readyRef.current }
}

let value = cache?.value
const rect = anchor?.() ?? null
if (rect) readyRef.current = true
const hostChanged =
value !== undefined && value.contextElement !== (host ?? undefined)
if (rect && (!lastRectRef.current || !sameRect(rect, lastRectRef.current))) {
lastRectRef.current = rect
value = undefined
}
if (anchor && (value === undefined || hostChanged) && lastRectRef.current) {
value = {
getBoundingClientRect: () => {
const latest = anchorRef.current?.() ?? null
if (latest) lastRectRef.current = latest
return lastRectRef.current ?? new DOMRect()
},
contextElement: host ?? undefined,
}
}
cacheRef.current = { anchor, host, measureKey, value }
return { virtualAnchor: value, ready: readyRef.current }
}
Loading
Loading