|
| 1 | +/** |
| 2 | + * @file The pop-down listbox a {@link TokenChip} shows while its gloss input is the active combobox. |
| 3 | + * Rendering and positioning live here; the combobox state (open, active row, keyboard) is owned |
| 4 | + * by the chip, which drives this purely through props. Portaled to `document.body` so it escapes |
| 5 | + * the clipping and stacking of the interlinear view's scroll viewports and token-row stacking |
| 6 | + * contexts. |
| 7 | + */ |
| 8 | +import { useEffect, useRef, useState } from 'react'; |
| 9 | +import { createPortal } from 'react-dom'; |
| 10 | +import type { GlossedSuggestionEntry } from '../utils/suggestion-engine'; |
| 11 | +import { statusTextColorClass } from '../utils/status-colors'; |
| 12 | + |
| 13 | +/** Props for {@link SuggestionDropdown}. */ |
| 14 | +type SuggestionDropdownProps = Readonly<{ |
| 15 | + /** |
| 16 | + * The gloss input this dropdown anchors under. Read on open (and window resize) to position the |
| 17 | + * panel; never focused or mutated here so DOM focus stays in the input for the combobox. |
| 18 | + */ |
| 19 | + anchorRef: Readonly<{ current: HTMLInputElement | undefined }>; |
| 20 | + /** The listbox element id, matching the input's `aria-controls`. */ |
| 21 | + listboxId: string; |
| 22 | + /** |
| 23 | + * Maps a row index to its option element id, matching the input's `aria-activedescendant`. Owned |
| 24 | + * by the chip so the input and the options agree on ids. |
| 25 | + */ |
| 26 | + optionId: (index: number) => string; |
| 27 | + /** The glossed suggestion entries to render, in rank order (the suggested pick first). */ |
| 28 | + entries: readonly GlossedSuggestionEntry[]; |
| 29 | + /** The keyboard-highlighted row, or -1 when none is highlighted (Enter then picks the top row). */ |
| 30 | + activeIndex: number; |
| 31 | + /** The token's surface form, used only to build per-row accessible labels. */ |
| 32 | + surfaceText: string; |
| 33 | + /** Called with a row index when the pointer enters it, so hover and keyboard share one highlight. */ |
| 34 | + onActiveIndexChange: (index: number) => void; |
| 35 | + /** Called with a payload id when a row is chosen (approve the suggested / promote a candidate). */ |
| 36 | + onSelect: (id: string) => void; |
| 37 | + /** Called when the dropdown should close itself (the user scrolled the view away). */ |
| 38 | + onRequestClose: () => void; |
| 39 | +}>; |
| 40 | + |
| 41 | +/** |
| 42 | + * Renders the portaled suggestion listbox for a token's gloss combobox. Row 0 is the suggested pick |
| 43 | + * (green); the rest are candidates (blue), matching the inline coloring this replaced. The |
| 44 | + * keyboard-active row gets the same `bg-accent` background hovering applies, and hovering a row |
| 45 | + * sets the active index so only one row is ever highlighted. Each row suppresses its mouse-down |
| 46 | + * default so clicking it never blurs the input — focus stays in the input and the click selects |
| 47 | + * instead. The panel closes itself on outer scrolling (the anchor would drift); scrolling the |
| 48 | + * panel's own overflow is ignored so a long list can be scrolled without dismissing it. |
| 49 | + * |
| 50 | + * @param props - Component props (see {@link SuggestionDropdownProps}). |
| 51 | + * @returns A `document.body` portal containing the listbox, positioned under the anchor input. |
| 52 | + */ |
| 53 | +export default function SuggestionDropdown({ |
| 54 | + anchorRef, |
| 55 | + listboxId, |
| 56 | + optionId, |
| 57 | + entries, |
| 58 | + activeIndex, |
| 59 | + surfaceText, |
| 60 | + onActiveIndexChange, |
| 61 | + onSelect, |
| 62 | + onRequestClose, |
| 63 | +}: SuggestionDropdownProps) { |
| 64 | + const listRef = useRef<HTMLUListElement | undefined>(undefined); |
| 65 | + const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); |
| 66 | + |
| 67 | + /** |
| 68 | + * Ref callback that stores the list element, used to tell apart scrolling the panel's own |
| 69 | + * overflow (ignored) from outer scrolling (closes). Normalizes React's `null` on unmount to |
| 70 | + * `undefined`. |
| 71 | + * |
| 72 | + * @param el - The mounted list, or `null` on unmount. |
| 73 | + */ |
| 74 | + const setListRef = (el: HTMLUListElement | null) => { |
| 75 | + listRef.current = el ?? undefined; |
| 76 | + }; |
| 77 | + |
| 78 | + // Position the panel under the anchor on open and keep it glued there across window resizes and |
| 79 | + // outer scrolling. The continuous view smooth-scrolls the token strip to center a phrase whenever |
| 80 | + // a token's gloss input is focused — the same focus that opens this dropdown — so closing on outer |
| 81 | + // scroll would dismiss the panel the instant it appeared. Instead we reposition under the anchor as |
| 82 | + // it moves, then close only once the anchor has scrolled out of the viewport (a far user scroll |
| 83 | + // that abandons this token). A capture listener catches scrolls of ancestor viewports; scrolls of |
| 84 | + // the panel's own overflow are ignored so a long list can be scrolled without moving or closing it. |
| 85 | + useEffect(() => { |
| 86 | + const anchor = anchorRef.current; |
| 87 | + /* v8 ignore next -- the chip only mounts this while the input (the anchor) is rendered */ |
| 88 | + if (!anchor) return undefined; |
| 89 | + const updatePosition = () => { |
| 90 | + const rect = anchor.getBoundingClientRect(); |
| 91 | + setPosition({ top: rect.bottom + 2, left: rect.left }); |
| 92 | + }; |
| 93 | + updatePosition(); |
| 94 | + const handleScroll = (e: Event) => { |
| 95 | + if (e.target instanceof Node && listRef.current?.contains(e.target)) return; |
| 96 | + const rect = anchor.getBoundingClientRect(); |
| 97 | + // The anchor has scrolled out of view: there is nothing to glue to, so dismiss the panel. |
| 98 | + if (rect.bottom < 0 || rect.top > window.innerHeight) { |
| 99 | + onRequestClose(); |
| 100 | + return; |
| 101 | + } |
| 102 | + setPosition({ top: rect.bottom + 2, left: rect.left }); |
| 103 | + }; |
| 104 | + window.addEventListener('resize', updatePosition); |
| 105 | + window.addEventListener('scroll', handleScroll, true); |
| 106 | + return () => { |
| 107 | + window.removeEventListener('resize', updatePosition); |
| 108 | + window.removeEventListener('scroll', handleScroll, true); |
| 109 | + }; |
| 110 | + }, [anchorRef, onRequestClose]); |
| 111 | + |
| 112 | + return createPortal( |
| 113 | + <ul |
| 114 | + ref={setListRef} |
| 115 | + className="tw:fixed tw:z-30 tw:max-h-48 tw:overflow-y-auto tw:rounded-md tw:border tw:border-border tw:bg-popover tw:py-1 tw:shadow-md" |
| 116 | + id={listboxId} |
| 117 | + role="listbox" |
| 118 | + style={{ top: position.top, left: position.left }} |
| 119 | + > |
| 120 | + {entries.map((entry, index) => ( |
| 121 | + <li |
| 122 | + key={entry.id} |
| 123 | + aria-label={ |
| 124 | + index === 0 |
| 125 | + ? `Accept suggestion ${entry.gloss} for ${surfaceText}` |
| 126 | + : `Promote ${entry.gloss} for ${surfaceText}` |
| 127 | + } |
| 128 | + aria-selected={index === activeIndex} |
| 129 | + className={`tw:cursor-pointer tw:whitespace-nowrap tw:px-2 tw:py-0.5 tw:text-sm tw:italic ${index === 0 ? statusTextColorClass('suggested') : statusTextColorClass('candidate')}${index === activeIndex ? ' tw:bg-accent' : ''}`} |
| 130 | + data-testid={index === 0 ? 'suggestion-accept' : 'suggestion-candidate'} |
| 131 | + id={optionId(index)} |
| 132 | + role="option" |
| 133 | + // Select on mouse-down, suppressing its default focus shift, so choosing a row never blurs |
| 134 | + // the gloss input (the input keeps focus for the combobox) and the keyboard path stays the |
| 135 | + // only place Enter/arrow handling lives. |
| 136 | + onMouseDown={(e) => { |
| 137 | + e.preventDefault(); |
| 138 | + onSelect(entry.id); |
| 139 | + }} |
| 140 | + onMouseEnter={() => onActiveIndexChange(index)} |
| 141 | + > |
| 142 | + {entry.gloss} |
| 143 | + </li> |
| 144 | + ))} |
| 145 | + </ul>, |
| 146 | + document.body, |
| 147 | + ); |
| 148 | +} |
0 commit comments