From 328eaffadee34ef8593f15c2c049c4161d8888da Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Mon, 10 Aug 2026 17:22:14 +0800 Subject: [PATCH] Keep every group mention candidate reachable The mention picker stopped at eight results and keyboard navigation could move the active candidate outside the visible popup. Keep all filtered members in the bounded list and synchronize its local scroll position with the highlighted option. Constraint: Preserve existing structured mention identity and IME behavior. Rejected: Keep the eight-candidate cap | members beyond the cap would remain unreachable by scrolling. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep keyboard highlight changes synchronized with the mention popup scroll position. Tested: Frontend npm test (89 passed); npm run build; Playwright with 12 mocked members, mouse wheel and keyboard scrolling. Not-tested: Live backend WebSocket behavior; unrelated to the local candidate picker. --- frontend/src/pages/groups/MessageComposer.tsx | 30 +++++++++++++++---- frontend/src/pages/groups/groups.css | 1 + .../tests/groupInteractionContract.test.mjs | 17 +++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/groups/MessageComposer.tsx b/frontend/src/pages/groups/MessageComposer.tsx index 0ca9fd481..75e6a50c9 100644 --- a/frontend/src/pages/groups/MessageComposer.tsx +++ b/frontend/src/pages/groups/MessageComposer.tsx @@ -47,6 +47,8 @@ export default function MessageComposer({ }: MessageComposerProps) { const { t } = useTranslation(); const textareaRef = useRef(null); + const mentionPopupRef = useRef(null); + const mentionOptionRefs = useRef>([]); const [value, setValue] = useState(''); const [query, setQuery] = useState(null); const [highlighted, setHighlighted] = useState(0); @@ -63,12 +65,27 @@ export default function MessageComposer({ const candidates = useMemo(() => { if (!query) return []; const needle = query.text.toLowerCase(); - return members - .filter((member) => member.display_name.toLowerCase().includes(needle)) - .slice(0, 8); + return members.filter((member) => member.display_name.toLowerCase().includes(needle)); }, [members, query]); - useEffect(() => setHighlighted(0), [query?.text]); + useEffect(() => { + setHighlighted(0); + if (mentionPopupRef.current) mentionPopupRef.current.scrollTop = 0; + }, [query?.text]); + + useEffect(() => { + const popup = mentionPopupRef.current; + const option = mentionOptionRefs.current[highlighted]; + if (!popup || !option) return; + + const popupRect = popup.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + if (optionRect.top < popupRect.top) { + popup.scrollTop -= popupRect.top - optionRect.top; + } else if (optionRect.bottom > popupRect.bottom) { + popup.scrollTop += optionRect.bottom - popupRect.bottom; + } + }, [candidates, highlighted]); // Auto-grow the textarea to fit its content (capped by max-height in CSS). Runs for typing, // mention insertion and the post-send clear alike, since they all flow through `value`. @@ -177,9 +194,12 @@ export default function MessageComposer({ return (
{query && candidates.length > 0 && ( -
+
{candidates.map((member, index) => (