Skip to content

Anchor the suggestion dropdown with platform Popover instead of hand-rolled positioning (platform-bible-react components Tier 4) #200

Description

@alex-rawlings-yyc

Summary

SuggestionDropdown positions itself by hand: it portals to document.body, measures the
gloss input with getBoundingClientRect, and keeps itself glued there with resize and
capture-phase scroll listeners, closing when the anchor leaves the viewport. Platform
Popover does all of this — Radix Popper autoUpdate, portal, collision handling, z-index —
and #165 Tier 3 already made the same swap in ViewOptionsDropdown.

This comes out of the #165 Tier 4 fit evaluation, which concluded that the combobox
itself
should stay bespoke: platform ComboBox and Select have no free-text entry (the
gloss field's primary job), and routing through Command/cmdk would cost the input's
aria-controls / aria-activedescendant wiring, since cmdk owns those ids internally and
useCommandState isn't re-exported by platform-bible-react. The positioning, unlike the
combobox semantics, has a clean platform counterpart. That's this issue.

Behavior (decided)

The dropdown follows focus: while the gloss input is focused and the token has suggestions,
the dropdown is shown. Scrolling is not a dismissal — the panel stays glued to the input,
travels off-screen with it, and is back in place when the token scrolls back, with no
refocus needed. Only Escape or typing a non-empty gloss dismisses it while focus stays in
the field.

This deliberately drops the current close-on-scroll-out-of-viewport rule
(SuggestionDropdown.tsx:91-100). That rule compensates for hand-rolled positioning with no
collision logic — without it the old code would strand a panel at a stale rect — rather than
expressing a UX decision. Radix cannot strand the panel: it shifts only on the horizontal
axis (shift({ mainAxis: true, crossAxis: false, limiter: limitShift() })) and uses flip
solely to choose above-vs-below, so the panel always travels with its anchor.

Do not add hideWhenDetached: it leaves the combobox open and aria-expanded="true"
behind an invisible panel.

Known, accepted edge case: with the token scrolled off-screen and the input still focused,
Enter approves the top suggestion rather than committing the typed draft (today's scroll-close
makes it commit the draft). Reachable only by wheel-scrolling away from a field you're still
typing in, and the current behavior is an artifact of the layout rule above rather than a
decision. Not worth a guard.

Scope

src/components/SuggestionDropdown.tsx — render the list through PopoverContent
instead of createPortal. Delete:

  • the positioning useLayoutEffect and its resize / capture-phase scroll listeners
    (SuggestionDropdown.tsx:82-107)
  • the position state, the translateX(-50%) + minWidth style trick, and the
    listRef-based "is this our own overflow scrolling?" check
  • the anchorRef and onRequestClose props, and the onRequestClose plumbing in TokenChip

Keep the active-row scrollIntoView effect (SuggestionDropdown.tsx:112-116) — Radix does
not scroll a highlighted descendant into view — and keep the <li> rows with their status
colors, aria-labels, and mousedown-select.

src/components/TokenChip.tsx — wrap the gloss-input span (TokenChip.tsx:444) in
Popover + PopoverAnchor, driven by the existing dropdownShown state. The chip already
nests a Popover for the morpheme editor (TokenChip.tsx:393); the two roots are
independent. No change to dropdownShown, handleGlossKeyDown, or any of the open/close
triggers.

Implementation notes

  • ARIA survives unchanged. Both the platform PopoverContent wrapper and
    @radix-ui/react-popover spread caller props after their own role="dialog" / id, so
    passing role="listbox" and the chip's listboxId keeps the existing combobox wiring
    intact. (Verified in source.) This also means the panel is not selectable as
    [data-slot="dialog-content"], so it stays distinguishable from real modals.
  • Prevent both focus events. onOpenAutoFocus must be prevented so opening never pulls
    focus off the gloss input — the entire design depends on focus staying there. onCloseAutoFocus
    must be prevented too: the dropdown closes on input blur, and Radix's default focus
    restoration would yank focus back to the anchor as it closes.
  • Override the content defaults. PopoverContent ships
    tw:w-72 tw:p-2.5 tw:gap-2.5 tw:flex tw:flex-col; the panel needs to size to its rows with
    the anchor width as a floor via --radix-popover-trigger-width. The defaults
    align="center" and sideOffset={4} already match the current centered, 2px-below
    placement closely enough — confirm visually rather than hand-tuning.
  • Anchor tracking is load-bearing. The continuous view smooth-scrolls the token strip on
    the same focus that opens this dropdown, so the panel must follow the anchor as it moves.
    That's autoUpdate, and it's why the current code hand-rolls listeners instead of closing
    on scroll.
  • __mocks__/platform-bible-react.tsx's PopoverContent stub needs role, id, and style
    added to its prop pass-through.

Tests

The describe('TokenChip suggestion dropdown scrolling') block
(TokenChip.suggestions.test.tsx:643-716, four tests plus its stubGlossRect helper) tests
positioning and scroll-close behavior that no longer belongs to us — delete it rather than
port it. The rest of the suite queries by role and test id and should survive with minimal
churn.

Acceptance

  • No getBoundingClientRect, createPortal, or scroll/resize listeners remain in
    SuggestionDropdown.
  • The gloss input keeps role="combobox", aria-expanded, aria-controls, and
    aria-activedescendant, pointing at the same listbox and option ids as today.
  • Keyboard model is untouched: activeIndex === -1 still means "no highlight, Enter takes the
    top pick"; arrows, Escape, row selection, and the "+" button behave as before.
  • Opening or closing the dropdown never moves DOM focus out of the gloss input.
  • The panel tracks the input while the strip smooth-scrolls the focused token into view,
    travels off-screen with the token, and is back in place when it returns — without the user
    refocusing the input.
  • Rows keep their suggestion-accept / suggestion-candidate test ids and status colors.

Out of scope

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions