Skip to content
120 linesCodeBlameRaw
1import { useEffect, useId, useRef, useState } from "react";
2
3import type { PersonMatch } from "@g1t/contracts";
4
5import { Input } from "./ui";
6import { Avatar } from "./ui/avatar";
7import { UserCard } from "./user-card";
8import { PEOPLE_SEARCH_PATH, peopleQuery } from "../lib/people-search";
9
10/**
11 * The invite form's "who" field on People: type a username, a name or an
12 * email address. As someone types a name, people on g1t are offered by
13 * username and name, with their avatars and the card over each name; an
14 * email address is never looked up. Posts what is in the field as `name`.
15 */
16export function PeoplePicker({ name, placeholder }: { name: string; placeholder?: string }) {
17 const [text, setText] = useState("");
18 const [people, setPeople] = useState<PersonMatch[]>([]);
19 const [open, setOpen] = useState(false);
20 const [active, setActive] = useState(0);
21 const listId = useId();
22 const asked = useRef(0);
23
24 useEffect(() => {
25 const query = peopleQuery(text);
26 if (!query) {
27 setPeople([]);
28 return;
29 }
30 const ask = ++asked.current;
31 const timer = setTimeout(() => {
32 fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
33 .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
34 .then((found) => {
35 if (ask !== asked.current) return;
36 setPeople(found.people ?? []);
37 setActive(0);
38 })
39 .catch(() => {
40 if (ask === asked.current) setPeople([]);
41 });
42 }, 150);
43 return () => clearTimeout(timer);
44 }, [text]);
45
46 const choose = (person: PersonMatch) => {
47 setText(person.username);
48 setPeople([]);
49 setOpen(false);
50 };
51 const showing = open && people.length > 0;
52
53 return (
54 <div className="relative">
55 <Input
56 name={name}
57 required
58 maxLength={254}
59 autoComplete="off"
60 placeholder={placeholder}
61 value={text}
62 role="combobox"
63 aria-expanded={showing}
64 aria-controls={listId}
65 aria-autocomplete="list"
66 aria-activedescendant={showing ? `${listId}-${active}` : undefined}
67 onChange={(event) => {
68 setText(event.currentTarget.value);
69 setOpen(true);
70 }}
71 onFocus={() => setOpen(true)}
72 onBlur={() => setTimeout(() => setOpen(false), 150)}
73 onKeyDown={(event) => {
74 if (!showing) return;
75 if (event.key === "ArrowDown") {
76 event.preventDefault();
77 setActive((at) => (at + 1) % people.length);
78 } else if (event.key === "ArrowUp") {
79 event.preventDefault();
80 setActive((at) => (at - 1 + people.length) % people.length);
81 } else if (event.key === "Enter" && people[active] && people[active].username !== text.trim().replace(/^@+/, "")) {
82 event.preventDefault();
83 choose(people[active]);
84 } else if (event.key === "Escape") {
85 setOpen(false);
86 }
87 }}
88 />
89 {showing && (
90 <ul
91 id={listId}
92 role="listbox"
93 className="absolute top-full right-0 left-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-line bg-surface p-1 shadow-lg"
94 >
95 {people.map((person, index) => (
96 <li
97 key={person.username}
98 id={`${listId}-${index}`}
99 role="option"
100 aria-selected={index === active}
101 className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${index === active ? "bg-line text-fg" : "text-fg/90"}`}
102 onMouseEnter={() => setActive(index)}
103 // Before the input's blur, so the choice is not lost.
104 onMouseDown={(event) => {
105 event.preventDefault();
106 choose(person);
107 }}
108 >
109 <Avatar name={person.username} image={person.avatar} size={20} />
110 <UserCard username={person.username}>
111 <span className="font-mono">{person.username}</span>
112 </UserCard>
113 {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
114 </li>
115 ))}
116 </ul>
117 )}
118 </div>
119 );
120}