Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

135 linesCodeBlameRaw
1import { Users } from "lucide-react";
2import { type ComponentProps, type KeyboardEvent, useId, useRef, useState } from "react";
3
4import { type MentionMatch, mentionSuggestions } from "../lib/mention";
5import { cn } from "../lib/cn";
6import { useImageAttach } from "./attach-images";
7import { Textarea } from "./ui";
8
9/**
10 * A comment box that offers to complete the teams in `handles`
11 * (`@acme/backend`): Up and Down move through the suggestions, Tab or Enter
12 * takes one, Escape dismisses them. Inside an `<AttachImages>`, it takes
13 * images too: pasted, dropped or chosen with Attach an image
14 * (components/attach-images.tsx).
15 */
16export function MentionTextarea({ handles, ...props }: ComponentProps<"textarea"> & { handles?: readonly string[] }) {
17 const ref = useRef<HTMLTextAreaElement>(null);
18 const [match, setMatch] = useState<MentionMatch | null>(null);
19 const [active, setActive] = useState(0);
20 const list = useId();
21 const images = useImageAttach(ref);
22
23 const look = () => {
24 const box = ref.current;
25 if (!box) return;
26 const found = box.selectionStart === box.selectionEnd ? mentionSuggestions(box.value, box.selectionStart, handles) : null;
27 setMatch(found);
28 setActive(0);
29 };
30
31 const complete = (handle: string) => {
32 const box = ref.current;
33 if (!box || !match) return;
34 const caret = box.selectionStart;
35 box.setRangeText(`${handle} `, match.start, caret, "end");
36 setMatch(null);
37 box.focus();
38 };
39
40 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
41 if (match) {
42 const count = match.options.length;
43 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
44 event.preventDefault();
45 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
46 return;
47 }
48 if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
49 event.preventDefault();
50 complete(match.options[active] ?? match.options[0]!);
51 return;
52 }
53 if (event.key === "Escape") {
54 event.preventDefault();
55 setMatch(null);
56 return;
57 }
58 }
59 props.onKeyDown?.(event);
60 };
61
62 const open = match != null;
63 return (
64 <div>
65 <div className="relative">
66 <Textarea
67 {...props}
68 ref={ref}
69 role={open ? "combobox" : undefined}
70 aria-autocomplete="list"
71 aria-expanded={open}
72 aria-controls={open ? list : undefined}
73 aria-activedescendant={open ? `${list}-${active}` : undefined}
74 onKeyDown={onKeyDown}
75 onInput={(event) => {
76 look();
77 props.onInput?.(event);
78 }}
79 onClick={(event) => {
80 look();
81 props.onClick?.(event);
82 }}
83 onBlur={(event) => {
84 // After a click on a suggestion has landed.
85 setTimeout(() => setMatch(null), 150);
86 props.onBlur?.(event);
87 }}
88 onPaste={(event) => {
89 images?.onPaste(event);
90 props.onPaste?.(event);
91 }}
92 onDragOver={(event) => {
93 images?.onDragOver(event);
94 props.onDragOver?.(event);
95 }}
96 onDragLeave={(event) => {
97 images?.onDragLeave();
98 props.onDragLeave?.(event);
99 }}
100 onDrop={(event) => {
101 images?.onDrop(event);
102 props.onDrop?.(event);
103 }}
104 className={cn(images?.dragging && "border-accent-dim ring-2 ring-accent/25", props.className)}
105 />
106 {match && (
107 <ul
108 id={list}
109 role="listbox"
110 aria-label="Mentions"
111 className="absolute top-full left-0 z-20 mt-1 max-w-full min-w-44 overflow-hidden rounded-md border border-line bg-raised p-1 shadow-lg shadow-black/30"
112 >
113 {match.options.map((handle, index) => (
114 <li
115 key={handle}
116 id={`${list}-${index}`}
117 role="option"
118 aria-selected={index === active}
119 onMouseDown={(event) => event.preventDefault()}
120 onMouseEnter={() => setActive(index)}
121 onClick={() => complete(handle)}
122 className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 font-mono text-xs text-fg ${index === active ? "bg-line" : ""}`}
123 >
124 <Users size={12} className="shrink-0 text-faint" />
125 <span className="min-w-0 truncate">{handle}</span>
126 {index === active && <span className="ml-auto pl-3 font-sans text-faint">Tab</span>}
127 </li>
128 ))}
129 </ul>
130 )}
131 </div>
132 {images?.footer}
133 </div>
134 );
135}