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
Summary
SuggestionDropdownpositions itself by hand: it portals todocument.body, measures thegloss input with
getBoundingClientRect, and keeps itself glued there withresizeandcapture-phase
scrolllisteners, closing when the anchor leaves the viewport. PlatformPopoverdoes all of this — Radix PopperautoUpdate, 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
ComboBoxandSelecthave no free-text entry (thegloss field's primary job), and routing through
Command/cmdkwould cost the input'saria-controls/aria-activedescendantwiring, since cmdk owns those ids internally anduseCommandStateisn't re-exported byplatform-bible-react. The positioning, unlike thecombobox 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 nocollision 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 usesflipsolely to choose above-vs-below, so the panel always travels with its anchor.
Do not add
hideWhenDetached: it leaves the combobox open andaria-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 throughPopoverContentinstead of
createPortal. Delete:useLayoutEffectand itsresize/ capture-phasescrolllisteners(
SuggestionDropdown.tsx:82-107)positionstate, thetranslateX(-50%)+minWidthstyle trick, and thelistRef-based "is this our own overflow scrolling?" checkanchorRefandonRequestCloseprops, and theonRequestCloseplumbing inTokenChipKeep the active-row
scrollIntoVieweffect (SuggestionDropdown.tsx:112-116) — Radix doesnot scroll a highlighted descendant into view — and keep the
<li>rows with their statuscolors,
aria-labels, and mousedown-select.src/components/TokenChip.tsx— wrap the gloss-input span (TokenChip.tsx:444) inPopover+PopoverAnchor, driven by the existingdropdownShownstate. The chip alreadynests a
Popoverfor the morpheme editor (TokenChip.tsx:393); the two roots areindependent. No change to
dropdownShown,handleGlossKeyDown, or any of the open/closetriggers.
Implementation notes
PopoverContentwrapper and@radix-ui/react-popoverspread caller props after their ownrole="dialog"/id, sopassing
role="listbox"and the chip'slistboxIdkeeps the existing combobox wiringintact. (Verified in source.) This also means the panel is not selectable as
[data-slot="dialog-content"], so it stays distinguishable from real modals.onOpenAutoFocusmust be prevented so opening never pullsfocus off the gloss input — the entire design depends on focus staying there.
onCloseAutoFocusmust 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.
PopoverContentshipstw:w-72 tw:p-2.5 tw:gap-2.5 tw:flex tw:flex-col; the panel needs to size to its rows withthe anchor width as a floor via
--radix-popover-trigger-width. The defaultsalign="center"andsideOffset={4}already match the current centered, 2px-belowplacement closely enough — confirm visually rather than hand-tuning.
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 closingon scroll.
__mocks__/platform-bible-react.tsx'sPopoverContentstub needsrole,id, andstyleadded to its prop pass-through.
Tests
The
describe('TokenChip suggestion dropdown scrolling')block(
TokenChip.suggestions.test.tsx:643-716, four tests plus itsstubGlossRecthelper) testspositioning 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
getBoundingClientRect,createPortal, or scroll/resize listeners remain inSuggestionDropdown.role="combobox",aria-expanded,aria-controls, andaria-activedescendant, pointing at the same listbox and option ids as today.activeIndex === -1still means "no highlight, Enter takes thetop pick"; arrows, Escape, row selection, and the "+" button behave as before.
travels off-screen with the token, and is back in place when it returns — without the user
refocusing the input.
suggestion-accept/suggestion-candidatetest ids and status colors.Out of scope
Command/cmdk— settled against in Adopt Platform.Bible (platform-bible-react) components in place of raw HTML #165 Tier 4.ModalShellandViewOptionsDropdown, already handled in Adopt Platform.Bible (platform-bible-react) components in place of raw HTML #165 Tier 3.