Skip to content
Draft
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
30 changes: 25 additions & 5 deletions frontend/src/pages/groups/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export default function MessageComposer({
}: MessageComposerProps) {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const mentionPopupRef = useRef<HTMLDivElement>(null);
const mentionOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [value, setValue] = useState('');
const [query, setQuery] = useState<MentionQuery | null>(null);
const [highlighted, setHighlighted] = useState(0);
Expand All @@ -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`.
Expand Down Expand Up @@ -177,9 +194,12 @@ export default function MessageComposer({
return (
<div className="group-composer">
{query && candidates.length > 0 && (
<div className="group-mention-popup">
<div ref={mentionPopupRef} className="group-mention-popup">
{candidates.map((member, index) => (
<button
ref={(element) => {
mentionOptionRefs.current[index] = element;
}}
key={member.participant_id}
type="button"
className={`group-mention-option ${index === highlighted ? 'active' : ''}`}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/groups/groups.css
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@
max-width: 340px;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-1, 4px);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
Expand Down
17 changes: 17 additions & 0 deletions frontend/tests/groupInteractionContract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const messageStream = readFileSync(
new URL('../src/pages/groups/MessageStream.tsx', import.meta.url),
'utf8',
);
const messageComposer = readFileSync(
new URL('../src/pages/groups/MessageComposer.tsx', import.meta.url),
'utf8',
);
const groupStyles = readFileSync(
new URL('../src/pages/groups/groups.css', import.meta.url),
'utf8',
);

test('new group sessions may use the backend default title while group names stay required', () => {
assert.match(promptModal, /allowEmpty\?: boolean/);
Expand Down Expand Up @@ -116,3 +124,12 @@ test('group planning failures preserve the backend message and diagnostics', ()
assert.match(groupsPage, /intake\.error\?\.trace_id/);
assert.match(groupsPage, /intake\.error\?\.code \?\? intake\.error_code/);
});

test('mention candidates stay reachable by pointer and keyboard scrolling', () => {
assert.doesNotMatch(messageComposer, /\.slice\(0, 8\)/);
assert.match(messageComposer, /mentionPopupRef/);
assert.match(messageComposer, /mentionOptionRefs/);
assert.match(messageComposer, /popup\.scrollTop/);
assert.match(groupStyles, /\.group-mention-popup\s*\{[\s\S]*?overflow-y:\s*auto/);
assert.match(groupStyles, /\.group-mention-popup\s*\{[\s\S]*?overscroll-behavior:\s*contain/);
});