1"use client";
2
3
16import { UserAvatar } from "@/components/database/cell-renderer";
17import { AtSign, Database, FileText, Users } from "lucide-react";
18import type { MentionGroup, MentionItem, MentionKind } from "./mention-model";
19
20export interface MentionPopupProps {
21 groups: MentionGroup[];
22
23 highlight: number;
24 loading?: boolean;
25 onPick: (item: MentionItem) => void;
26 onHover: (flatIndex: number) => void;
27}
28
29const GROUP_ICON: Record<MentionKind, typeof AtSign> = {
30 user: AtSign,
31 team: Users,
32 page: FileText,
33 database: Database
34};
35
36
41const KIND_A11Y: Record<MentionKind, string> = {
42 user: "Member",
43 team: "Team",
44 page: "Page",
45 database: "Database"
46};
47
48export function MentionPopup({ groups, highlight, loading, onPick, onHover }: MentionPopupProps) {
49 let flat = 0;
50 return (
51 <ul
52 className="absolute left-0 top-full z-40 mt-1 max-h-80 w-72 overflow-y-auto rounded-md border bg-popover p-1 shadow-lg"
53 role="listbox"
54 aria-label="Mention"
55 data-testid="mention-listbox"
56 >
57 {loading && groups.length === 0 ? (
58 <li className="px-2 py-2 text-xs text-muted-foreground">Loading…</li>
59 ) : null}
60 {!loading && groups.length === 0 ? (
61 <li className="px-2 py-2 text-xs text-muted-foreground">No matches</li>
62 ) : null}
63 {groups.map((group) => {
64 const Icon = GROUP_ICON[group.kind];
65 const groupIndex = flat;
66 flat += group.items.length;
67 return (
68 <li key={group.key} role="group" aria-label={group.label}>
69 <div className="flex items-center gap-1.5 px-2 pb-0.5 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
70 <Icon className="size-3" aria-hidden />
71 {group.label}
72 </div>
73 {group.items.map((item, i) => {
74 const idx = groupIndex + i;
75 const selected = idx === highlight;
76 return (
77 <li
78 key={item.id}
79 role="option"
80 aria-selected={selected}
81 aria-label={`${item.label} (${KIND_A11Y[group.kind]})`}
82 data-testid={`mention-option-${item.kind}-${item.id}`}
83 >
84 <button
85 type="button"
86 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted data-[selected=true]:bg-muted"
87 data-selected={selected}
88 onMouseDown={(e) => {
89 e.preventDefault();
90 onPick(item);
91 }}
92 onMouseEnter={() => onHover(idx)}
93 >
94 {item.user ? (
95 <UserAvatar user={item.user} size={18} />
96 ) : (
97 <span className="flex size-[18px] shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
98 <Icon className="size-3" aria-hidden />
99 </span>
100 )}
101 <span className="min-w-0 flex-1 truncate">{item.label}</span>
102 {item.sublabel ? (
103 <span className="shrink-0 text-[10px] text-muted-foreground">{item.sublabel}</span>
104 ) : null}
105 </button>
106 </li>
107 );
108 })}
109 </li>
110 );
111 })}
112 </ul>
113 );
114}
115