import { createPortal } from 'react-dom'; import type { MentionPerson } from '../utils/mentions'; /** * The `@` menu over the comment composer's textarea. * * NOTHING IS PRESELECTED (`active === null` until the person arrows into the * list), the same rule `useSkillReferenceAutocomplete` keeps for `/` and `$`: * until then Enter and Tab mean what they always meant in a comment box. * * PORTALED AND FIXED, not absolute inside the composer: the popover clips its * own box and the expanded dialog scrolls, so a menu positioned inside the * textarea's wrapper is cut off at the card's edge. It is placed from the * textarea's measured rect instead — above it by default, below when the * composer sits too close to the top of the window for the menu to open * upward. */ export interface MentionAnchor { readonly top: number; readonly bottom: number; readonly left: number; readonly width: number; } /** The menu's own height cap (max-h-48 = 12rem), in px. */ const MENU_MAX_PX = 192; const MENU_GAP_PX = 4; /** Measure the composer textarea for {@link MentionPicker}. Null closes the menu. */ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | null { if (element === null) return null; const rect = element.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }; } /** Consecutive runs of people with the same `group`, with each run's first index in the flat list. */ export function mentionGroups(people: readonly MentionPerson[]): { group: string; start: number; people: MentionPerson[] }[] { const runs: { group: string; start: number; people: MentionPerson[] }[] = []; people.forEach((person, index) => { const group = person.group ?? ''; const last = runs[runs.length - 1]; if (last && last.group === group) last.people.push(person); else runs.push({ group, start: index, people: [person] }); }); return runs; } /** The optional avatar column: an image, else initials on a tinted disc. */ function MentionAvatar({ avatar, label, }: { readonly avatar: NonNullable; readonly label: string; }) { if (avatar.url) { return ( ); } const initials = (avatar.initials ?? label.trim().charAt(0)).slice(0, 2).toUpperCase(); return ( ); } export function MentionPicker({ id, people, emptyNotice, heading, active, anchor, onPick, onHover, }: { /** Id referenced by the focused textarea's aria-controls attribute. */ readonly id?: string; readonly people: readonly MentionPerson[]; /** Shown as one non-selectable row when `people` is empty. */ readonly emptyNotice?: string | null; /** Optional heading above the list. Absent → nothing rendered. */ readonly heading?: string | null; /** Index of the arrow-focused row, or null for "nothing preselected". */ readonly active: number | null; readonly anchor: MentionAnchor | null; readonly onPick: (person: MentionPerson) => void; readonly onHover: (index: number) => void; }) { if (anchor === null || typeof document === 'undefined') return null; if (people.length === 0 && !emptyNotice) return null; const above = anchor.top >= MENU_MAX_PX + MENU_GAP_PX; const style = above ? { bottom: window.innerHeight - anchor.top + MENU_GAP_PX } : { top: anchor.bottom + MENU_GAP_PX }; return createPortal(
{heading && (

{heading}

)} {people.length === 0 ? (

{emptyNotice}

) : people.some((person) => person.group) ? ( // Grouped (a host set `group`): each run of one group is a `role="group"` labelled by its heading. mentionGroups(people).map((run) => { const headingId = id ? `${id}-group-${run.start}` : undefined; return (

{run.group}

{run.people.map((person, offset) => { const index = run.start + offset; return ( ); })}
); }) ) : ( people.map((person, index) => ( )) )}
, document.body, ); }