@handled-ai/design-system 0.18.40 → 0.18.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,17 @@ interface EmailRecipientFieldProps {
20
20
  addedEmails?: Set<string>;
21
21
  placeholder?: string;
22
22
  contactToRecipient?: (contact: SuggestedContact) => RecipientChip;
23
+ /**
24
+ * Async search hook. When provided, the contact picker switches to async
25
+ * mode: the search box forwards its query here (the caller is responsible for
26
+ * debouncing and fetching) and `contacts` is treated as the server-filtered
27
+ * result set rather than a static list to filter client-side. When omitted,
28
+ * the picker filters the static `contacts` array locally (default behavior).
29
+ */
30
+ onSearch?: (query: string) => void;
31
+ /** Shows a loading indicator in the picker while async results are fetched. */
32
+ searchLoading?: boolean;
23
33
  }
24
- declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, }: EmailRecipientFieldProps): React.JSX.Element;
34
+ declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, onSearch, searchLoading, }: EmailRecipientFieldProps): React.JSX.Element;
25
35
 
26
36
  export { EmailRecipientField, type EmailRecipientFieldProps, type RecipientChip };
@@ -20,9 +20,9 @@ var __spreadValues = (a, b) => {
20
20
  return a;
21
21
  };
22
22
  var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
23
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
23
+ import { jsx, jsxs } from "react/jsx-runtime";
24
24
  import * as React from "react";
25
- import { Popover as PopoverPrimitive } from "radix-ui";
25
+ import { createPortal } from "react-dom";
26
26
  import {
27
27
  Check,
28
28
  ChevronDown,
@@ -90,15 +90,80 @@ function RecipientChipPill({
90
90
  )
91
91
  ] });
92
92
  }
93
- function ContactPickerContents({
93
+ function ContactPickerPopover({
94
+ triggerRef,
94
95
  contacts,
95
96
  addedEmails,
96
97
  onSelect,
97
- onAddEmail
98
+ onAddEmail,
99
+ onClose,
100
+ onSearch,
101
+ searchLoading = false
98
102
  }) {
103
+ const containerRef = React.useRef(null);
104
+ const searchRef = React.useRef(null);
99
105
  const [query, setQuery] = React.useState("");
106
+ const [style, setStyle] = React.useState({
107
+ position: "fixed",
108
+ top: -9999,
109
+ left: -9999
110
+ });
111
+ React.useEffect(() => {
112
+ const trigger = triggerRef.current;
113
+ if (!trigger) return;
114
+ const rect = trigger.getBoundingClientRect();
115
+ const width = Math.min(448, window.innerWidth - 32);
116
+ let left = rect.left;
117
+ if (left + width > window.innerWidth - 16) {
118
+ left = window.innerWidth - 16 - width;
119
+ }
120
+ if (left < 16) left = 16;
121
+ const popoverHeight = 280;
122
+ const spaceBelow = window.innerHeight - rect.bottom - 4;
123
+ const spaceAbove = rect.top - 4;
124
+ const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow;
125
+ let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4;
126
+ if (top < 16) top = 16;
127
+ setStyle({ position: "fixed", top, left, width });
128
+ }, [triggerRef]);
129
+ React.useEffect(() => {
130
+ var _a;
131
+ (_a = searchRef.current) == null ? void 0 : _a.focus();
132
+ const trigger = triggerRef.current;
133
+ return () => {
134
+ if (trigger && typeof trigger.focus === "function") {
135
+ trigger.focus();
136
+ }
137
+ };
138
+ }, [triggerRef]);
139
+ React.useEffect(() => {
140
+ function handleMouseDown(event) {
141
+ var _a;
142
+ if (containerRef.current && !containerRef.current.contains(event.target) && !((_a = triggerRef.current) == null ? void 0 : _a.contains(event.target))) {
143
+ onClose();
144
+ }
145
+ }
146
+ function handleKeyDown2(event) {
147
+ if (event.key === "Escape") {
148
+ event.stopPropagation();
149
+ onClose();
150
+ }
151
+ }
152
+ document.addEventListener("mousedown", handleMouseDown);
153
+ document.addEventListener("keydown", handleKeyDown2);
154
+ return () => {
155
+ document.removeEventListener("mousedown", handleMouseDown);
156
+ document.removeEventListener("keydown", handleKeyDown2);
157
+ };
158
+ }, [onClose, triggerRef]);
159
+ const asyncMode = typeof onSearch === "function";
160
+ React.useEffect(() => {
161
+ if (asyncMode) {
162
+ onSearch == null ? void 0 : onSearch(query);
163
+ }
164
+ }, [asyncMode, onSearch, query]);
100
165
  const normalizedQuery = query.trim().toLowerCase();
101
- const filtered = normalizedQuery ? contacts.filter((contact) => {
166
+ const filtered = asyncMode ? contacts : normalizedQuery ? contacts.filter((contact) => {
102
167
  var _a;
103
168
  const email = (_a = contactEmail(contact)) != null ? _a : "";
104
169
  return contact.name.toLowerCase().includes(normalizedQuery) || contact.role.toLowerCase().includes(normalizedQuery) || email.toLowerCase().includes(normalizedQuery);
@@ -111,73 +176,82 @@ function ContactPickerContents({
111
176
  setQuery("");
112
177
  }
113
178
  }
114
- return /* @__PURE__ */ jsxs(Fragment, { children: [
115
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
116
- /* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
117
- /* @__PURE__ */ jsx(
118
- "input",
119
- {
120
- autoFocus: true,
121
- value: query,
122
- onChange: (event) => setQuery(event.target.value),
123
- onKeyDown: handleKeyDown,
124
- className: "flex-1 text-[13px] bg-transparent outline-none",
125
- placeholder: "Search contacts..."
126
- }
127
- )
128
- ] }),
129
- /* @__PURE__ */ jsx("div", { role: "listbox", className: "max-h-[208px] overflow-y-auto p-1", children: contacts.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: [
130
- /* @__PURE__ */ jsx("div", { className: "font-medium text-foreground/80", children: "No contacts for this account" }),
131
- /* @__PURE__ */ jsx("div", { className: "mt-1", children: "Type an email address above and press Enter to add a recipient." })
132
- ] }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
133
- /* @__PURE__ */ jsxs("div", { children: [
134
- "No contact matches \u2018",
135
- query,
136
- "\u2019."
137
- ] }),
138
- queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
139
- "Press Enter to add ",
140
- query,
141
- "."
142
- ] }) : null
143
- ] }) : filtered.map((contact, index) => {
144
- const email = contactEmail(contact);
145
- const noEmail = !email || !isValidEmail(email);
146
- const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
147
- const disabled = noEmail || alreadyAdded;
148
- return /* @__PURE__ */ jsxs(
149
- "div",
150
- {
151
- role: "option",
152
- "aria-selected": false,
153
- "aria-disabled": disabled,
154
- onClick: () => {
155
- if (!disabled) onSelect(contact);
156
- },
157
- className: cn(
158
- "flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
159
- disabled && "opacity-45 pointer-events-none"
160
- ),
161
- children: [
162
- /* @__PURE__ */ jsx("div", { className: "flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground", children: getInitials(contact.name, email != null ? email : "?") }),
163
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
164
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
165
- /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
166
- /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
167
- ] }),
168
- email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
179
+ return createPortal(
180
+ /* @__PURE__ */ jsxs(
181
+ "div",
182
+ {
183
+ ref: containerRef,
184
+ style,
185
+ className: "bg-background border rounded-lg shadow-xl z-50 pointer-events-auto",
186
+ children: [
187
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
188
+ /* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
189
+ /* @__PURE__ */ jsx(
190
+ "input",
191
+ {
192
+ ref: searchRef,
193
+ autoFocus: true,
194
+ value: query,
195
+ onChange: (event) => setQuery(event.target.value),
196
+ onKeyDown: handleKeyDown,
197
+ className: "flex-1 text-[13px] bg-transparent outline-none",
198
+ placeholder: "Search contacts..."
199
+ }
200
+ )
201
+ ] }),
202
+ /* @__PURE__ */ jsx("div", { role: "listbox", className: "max-h-[208px] overflow-y-auto p-1", children: searchLoading ? /* @__PURE__ */ jsx("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: "Searching contacts..." }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
203
+ asyncMode && normalizedQuery.length === 0 ? /* @__PURE__ */ jsx("div", { children: "Type a name or email to search contacts." }) : /* @__PURE__ */ jsxs("div", { children: [
204
+ "No contact matches \u2018",
205
+ query,
206
+ "\u2019."
169
207
  ] }),
170
- alreadyAdded ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "Added" }) : noEmail ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "No email" }) : null
171
- ]
172
- },
173
- `${contact.name}-${email != null ? email : index}`
174
- );
175
- }) }),
176
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground", children: [
177
- /* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
178
- /* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
179
- ] })
180
- ] });
208
+ queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
209
+ "Press Enter to add ",
210
+ query,
211
+ "."
212
+ ] }) : null
213
+ ] }) : filtered.map((contact, index) => {
214
+ const email = contactEmail(contact);
215
+ const noEmail = !email || !isValidEmail(email);
216
+ const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
217
+ const disabled = noEmail || alreadyAdded;
218
+ return /* @__PURE__ */ jsxs(
219
+ "div",
220
+ {
221
+ role: "option",
222
+ "aria-selected": false,
223
+ "aria-disabled": disabled,
224
+ onClick: () => {
225
+ if (!disabled) onSelect(contact);
226
+ },
227
+ className: cn(
228
+ "flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
229
+ disabled && "opacity-45 pointer-events-none"
230
+ ),
231
+ children: [
232
+ /* @__PURE__ */ jsx("div", { className: "flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground", children: getInitials(contact.name, email != null ? email : "?") }),
233
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
234
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
235
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
236
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
237
+ ] }),
238
+ email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
239
+ ] }),
240
+ alreadyAdded ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "Added" }) : noEmail ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "No email" }) : null
241
+ ]
242
+ },
243
+ `${contact.name}-${email != null ? email : index}`
244
+ );
245
+ }) }),
246
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground", children: [
247
+ /* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
248
+ /* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
249
+ ] })
250
+ ]
251
+ }
252
+ ),
253
+ document.body
254
+ );
181
255
  }
182
256
  function EmailRecipientField({
183
257
  label,
@@ -191,10 +265,13 @@ function EmailRecipientField({
191
265
  onCcBccToggle,
192
266
  addedEmails,
193
267
  placeholder,
194
- contactToRecipient
268
+ contactToRecipient,
269
+ onSearch,
270
+ searchLoading
195
271
  }) {
196
272
  const [value, setValue] = React.useState("");
197
273
  const [pickerOpen, setPickerOpen] = React.useState(false);
274
+ const contactsTriggerRef = React.useRef(null);
198
275
  const hasUnconfirmed = recipients.some((r) => !r.confirmed);
199
276
  const state = amber && hasUnconfirmed ? "amber" : "default";
200
277
  const amberRow = state === "amber";
@@ -284,42 +361,20 @@ function EmailRecipientField({
284
361
  )
285
362
  ] }),
286
363
  showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
287
- showPicker ? /* @__PURE__ */ jsxs(PopoverPrimitive.Root, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
288
- /* @__PURE__ */ jsx(PopoverPrimitive.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
289
- "button",
290
- {
291
- type: "button",
292
- className: "inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]",
293
- children: [
294
- /* @__PURE__ */ jsx(Users, { className: "size-3" }),
295
- "Contacts",
296
- /* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
297
- ]
298
- }
299
- ) }),
300
- /* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
301
- PopoverPrimitive.Content,
302
- {
303
- side: "bottom",
304
- align: "start",
305
- sideOffset: 4,
306
- collisionPadding: 16,
307
- className: "z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0",
308
- children: /* @__PURE__ */ jsx(
309
- ContactPickerContents,
310
- {
311
- contacts,
312
- addedEmails: added,
313
- onSelect: selectContact,
314
- onAddEmail: (email) => {
315
- addEmail(email);
316
- setPickerOpen(false);
317
- }
318
- }
319
- )
320
- }
321
- ) })
322
- ] }) : null,
364
+ showPicker ? /* @__PURE__ */ jsxs(
365
+ "button",
366
+ {
367
+ ref: contactsTriggerRef,
368
+ type: "button",
369
+ onClick: () => setPickerOpen((open) => !open),
370
+ className: "inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]",
371
+ children: [
372
+ /* @__PURE__ */ jsx(Users, { className: "size-3" }),
373
+ "Contacts",
374
+ /* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
375
+ ]
376
+ }
377
+ ) : null,
323
378
  showCcBcc ? /* @__PURE__ */ jsxs(
324
379
  "button",
325
380
  {
@@ -332,7 +387,23 @@ function EmailRecipientField({
332
387
  ]
333
388
  }
334
389
  ) : null
335
- ] }) : null
390
+ ] }) : null,
391
+ pickerOpen ? /* @__PURE__ */ jsx(
392
+ ContactPickerPopover,
393
+ {
394
+ triggerRef: contactsTriggerRef,
395
+ contacts,
396
+ addedEmails: added,
397
+ onSelect: selectContact,
398
+ onAddEmail: (email) => {
399
+ addEmail(email);
400
+ setPickerOpen(false);
401
+ },
402
+ onClose: () => setPickerOpen(false),
403
+ onSearch,
404
+ searchLoading
405
+ }
406
+ ) : null
336
407
  ] })
337
408
  ]
338
409
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport {\n Check,\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const display = recipient.name || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900\">\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground\">\n <span className=\"inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700\">\n <Check className=\"size-3\" />\n </span>\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\n// Contents of the contact picker dropdown. Rendered inside a Radix\n// `Popover.Content` so its focus scope pushes onto the focus-scope stack and\n// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what\n// makes the search input typeable: a plain `createPortal(..., document.body)`\n// element renders outside the Dialog's `DialogContent`, so the Dialog's\n// FocusScope kept yanking focus back (input un-typeable) and its modal\n// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix\n// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus\n// scope, fixing both. See WIT-800 / WIT-770.\nfunction ContactPickerContents({\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return (\n <>\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {contacts.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n <div className=\"font-medium text-foreground/80\">\n No contacts for this account\n </div>\n <div className=\"mt-1\">\n Type an email address above and press Enter to add a recipient.\n </div>\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n <div>No contact matches &lsquo;{query}&rsquo;.</div>\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </>\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n function addEmail(email: string) {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return\n if (added.has(trimmed.toLowerCase())) return\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && isValidEmail(value)) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n if (isValidEmail(value)) addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>\n <PopoverPrimitive.Trigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n side=\"bottom\"\n align=\"start\"\n sideOffset={4}\n collisionPadding={16}\n className=\"z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0\"\n >\n <ContactPickerContents\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n Check,\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n /**\n * Async search hook. When provided, the contact picker switches to async\n * mode: the search box forwards its query here (the caller is responsible for\n * debouncing and fetching) and `contacts` is treated as the server-filtered\n * result set rather than a static list to filter client-side. When omitted,\n * the picker filters the static `contacts` array locally (default behavior).\n */\n onSearch?: (query: string) => void\n /** Shows a loading indicator in the picker while async results are fetched. */\n searchLoading?: boolean\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const display = recipient.name || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900\">\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground\">\n <span className=\"inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700\">\n <Check className=\"size-3\" />\n </span>\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\nfunction ContactPickerPopover({\n triggerRef,\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n onClose,\n onSearch,\n searchLoading = false,\n}: {\n triggerRef: React.RefObject<HTMLElement | null>\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onClose: () => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const containerRef = React.useRef<HTMLDivElement>(null)\n const searchRef = React.useRef<HTMLInputElement>(null)\n const [query, setQuery] = React.useState(\"\")\n const [style, setStyle] = React.useState<React.CSSProperties>({\n position: \"fixed\",\n top: -9999,\n left: -9999,\n })\n\n React.useEffect(() => {\n const trigger = triggerRef.current\n if (!trigger) return\n const rect = trigger.getBoundingClientRect()\n const width = Math.min(448, window.innerWidth - 32)\n let left = rect.left\n if (left + width > window.innerWidth - 16) {\n left = window.innerWidth - 16 - width\n }\n if (left < 16) left = 16\n const popoverHeight = 280\n const spaceBelow = window.innerHeight - rect.bottom - 4\n const spaceAbove = rect.top - 4\n const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow\n let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4\n if (top < 16) top = 16\n setStyle({ position: \"fixed\", top, left, width })\n }, [triggerRef])\n\n React.useEffect(() => {\n searchRef.current?.focus()\n const trigger = triggerRef.current\n return () => {\n if (trigger && typeof trigger.focus === \"function\") {\n trigger.focus()\n }\n }\n }, [triggerRef])\n\n React.useEffect(() => {\n function handleMouseDown(event: MouseEvent) {\n if (\n containerRef.current &&\n !containerRef.current.contains(event.target as Node) &&\n !triggerRef.current?.contains(event.target as Node)\n ) {\n onClose()\n }\n }\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") {\n event.stopPropagation()\n onClose()\n }\n }\n document.addEventListener(\"mousedown\", handleMouseDown)\n document.addEventListener(\"keydown\", handleKeyDown)\n return () => {\n document.removeEventListener(\"mousedown\", handleMouseDown)\n document.removeEventListener(\"keydown\", handleKeyDown)\n }\n }, [onClose, triggerRef])\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Async mode: forward the query upward (caller debounces + fetches) and treat\n // `contacts` as the already server-filtered result set. Local mode: filter the\n // static `contacts` array client-side.\n React.useEffect(() => {\n if (asyncMode) {\n onSearch?.(query)\n }\n }, [asyncMode, onSearch, query])\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = asyncMode\n ? contacts\n : normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return createPortal(\n <div\n ref={containerRef}\n style={style}\n className=\"bg-background border rounded-lg shadow-xl z-50 pointer-events-auto\"\n >\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n ref={searchRef}\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {searchLoading ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n {asyncMode && normalizedQuery.length === 0 ? (\n <div>Type a name or email to search contacts.</div>\n ) : (\n <div>No contact matches &lsquo;{query}&rsquo;.</div>\n )}\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </div>,\n document.body,\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n onSearch,\n searchLoading,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n function addEmail(email: string) {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return\n if (added.has(trimmed.toLowerCase())) return\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && isValidEmail(value)) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n if (isValidEmail(value)) addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <button\n ref={contactsTriggerRef}\n type=\"button\"\n onClick={() => setPickerOpen((open) => !open)}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n\n {pickerOpen ? (\n <ContactPickerPopover\n triggerRef={contactsTriggerRef}\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n onClose={() => setPickerOpen(false)}\n onSearch={onSearch}\n searchLoading={searchLoading}\n />\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":["handleKeyDown"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.40",
3
+ "version": "0.18.41",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -1,16 +1,11 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest"
2
2
  import React from "react"
3
- import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"
3
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react"
4
4
 
5
5
  import {
6
6
  EmailRecipientField,
7
7
  type RecipientChip,
8
8
  } from "../email-recipient-field"
9
- import {
10
- Dialog,
11
- DialogContent,
12
- DialogTitle,
13
- } from "../dialog"
14
9
  import type { SuggestedContact } from "../suggested-actions"
15
10
 
16
11
  afterEach(() => {
@@ -235,137 +230,90 @@ describe("EmailRecipientField", () => {
235
230
  ])
236
231
  })
237
232
 
238
- it("disables rows for added and no-email contacts", () => {
233
+ it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
234
+ const onSearch = vi.fn()
235
+ // In async mode the parent owns filtering, so we pass an unrelated result
236
+ // set and assert it is rendered verbatim (not filtered by the typed query).
237
+ const serverResults: SuggestedContact[] = [
238
+ { name: "Server Result", role: "VP", email: "server@example.com", confirmed: true },
239
+ ]
239
240
  render(
240
241
  <EmailRecipientField
241
242
  label="To"
242
243
  recipients={[]}
243
244
  onRecipientsChange={vi.fn()}
244
245
  showPicker
245
- contacts={contacts}
246
- addedEmails={new Set(["alex@example.com"])}
246
+ contacts={serverResults}
247
+ onSearch={onSearch}
247
248
  />,
248
249
  )
249
250
 
250
251
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
252
+ // Opening the picker forwards the initial (empty) query.
253
+ expect(onSearch).toHaveBeenCalledWith("")
251
254
 
252
- const options = screen.getAllByRole("option")
253
- const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
254
- const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
255
+ const searchInput = screen.getByPlaceholderText("Search contacts...")
256
+ fireEvent.change(searchInput, { target: { value: "zzz no local match" } })
257
+ expect(onSearch).toHaveBeenCalledWith("zzz no local match")
255
258
 
256
- expect(alexRow.className).toContain("pointer-events-none")
257
- expect(alexRow.textContent).toContain("Added")
258
- expect(noEmailRow.className).toContain("pointer-events-none")
259
- expect(noEmailRow.textContent).toContain("No email")
259
+ // Server result is shown verbatim even though it doesn't match the query.
260
+ expect(screen.getByText("Server Result")).toBeTruthy()
260
261
  })
261
262
 
262
- it("autofocuses the search input when the picker opens", () => {
263
+ it("async mode: shows the searching indicator while loading", () => {
263
264
  render(
264
265
  <EmailRecipientField
265
266
  label="To"
266
267
  recipients={[]}
267
268
  onRecipientsChange={vi.fn()}
268
269
  showPicker
269
- contacts={contacts}
270
+ contacts={[]}
271
+ onSearch={vi.fn()}
272
+ searchLoading
270
273
  />,
271
274
  )
272
275
 
273
276
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
274
- const search = screen.getByPlaceholderText("Search contacts...")
275
- expect(document.activeElement).toBe(search)
277
+ expect(screen.getByText("Searching contacts...")).toBeTruthy()
276
278
  })
277
279
 
278
- it("accepts typing in the search box and filters the list", () => {
280
+ it("async mode: prompts to type before searching when query is empty", () => {
279
281
  render(
280
282
  <EmailRecipientField
281
283
  label="To"
282
284
  recipients={[]}
283
285
  onRecipientsChange={vi.fn()}
284
286
  showPicker
285
- contacts={contacts}
286
- />,
287
- )
288
-
289
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
290
- const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
291
-
292
- fireEvent.change(search, { target: { value: "Bea" } })
293
- expect(search.value).toBe("Bea")
294
- expect(screen.getByText("Bea Buyer")).toBeTruthy()
295
- expect(screen.queryByText("Alex Admin")).toBeNull()
296
- })
297
-
298
- it("adds a typed email from the picker search box on Enter", () => {
299
- const onChange = vi.fn()
300
- render(
301
- <EmailRecipientField
302
- label="To"
303
- recipients={[]}
304
- onRecipientsChange={onChange}
305
- showPicker
306
- contacts={contacts}
287
+ contacts={[]}
288
+ onSearch={vi.fn()}
307
289
  />,
308
290
  )
309
291
 
310
292
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
311
- const search = screen.getByPlaceholderText("Search contacts...")
312
-
313
- fireEvent.change(search, { target: { value: "typed@example.com" } })
314
- fireEvent.keyDown(search, { key: "Enter" })
315
-
316
- expect(onChange).toHaveBeenCalledWith([
317
- { id: "typed@example.com", email: "typed@example.com", name: "", confirmed: false },
318
- ])
293
+ expect(screen.getByText("Type a name or email to search contacts.")).toBeTruthy()
319
294
  })
320
295
 
321
- it("shows a clear empty state when there are no contacts", () => {
296
+ it("disables rows for added and no-email contacts", () => {
322
297
  render(
323
298
  <EmailRecipientField
324
299
  label="To"
325
300
  recipients={[]}
326
301
  onRecipientsChange={vi.fn()}
327
302
  showPicker
328
- contacts={[]}
303
+ contacts={contacts}
304
+ addedEmails={new Set(["alex@example.com"])}
329
305
  />,
330
306
  )
331
307
 
332
308
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
333
- expect(screen.getByText("No contacts for this account")).toBeTruthy()
334
- // The empty state guides the user to type an address instead of looking broken.
335
- expect(
336
- screen.getByText(/Type an email address above and press Enter/),
337
- ).toBeTruthy()
338
- })
339
-
340
- it("remains typeable when rendered inside a modal Dialog (WIT-800 focus trap)", () => {
341
- // Reproduces the staging bug: the picker used to render via
342
- // createPortal(document.body), outside the Dialog's FocusScope, so the
343
- // search input could not hold focus. The Radix Popover rebuild keeps the
344
- // input inside its own (parent-pausing) focus scope.
345
- render(
346
- <Dialog open>
347
- <DialogContent aria-describedby={undefined}>
348
- <DialogTitle>Compose</DialogTitle>
349
- <EmailRecipientField
350
- label="To"
351
- recipients={[]}
352
- onRecipientsChange={vi.fn()}
353
- showPicker
354
- contacts={contacts}
355
- />
356
- </DialogContent>
357
- </Dialog>,
358
- )
359
309
 
360
- fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
361
- const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
362
-
363
- search.focus()
364
- expect(document.activeElement).toBe(search)
310
+ const options = screen.getAllByRole("option")
311
+ const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
312
+ const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
365
313
 
366
- fireEvent.change(search, { target: { value: "Alex" } })
367
- expect(search.value).toBe("Alex")
368
- const listbox = screen.getByRole("listbox")
369
- expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
314
+ expect(alexRow.className).toContain("pointer-events-none")
315
+ expect(alexRow.textContent).toContain("Added")
316
+ expect(noEmailRow.className).toContain("pointer-events-none")
317
+ expect(noEmailRow.textContent).toContain("No email")
370
318
  })
371
319
  })
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Popover as PopoverPrimitive } from "radix-ui"
4
+ import { createPortal } from "react-dom"
5
5
  import {
6
6
  Check,
7
7
  ChevronDown,
@@ -56,6 +56,16 @@ export interface EmailRecipientFieldProps {
56
56
  addedEmails?: Set<string>
57
57
  placeholder?: string
58
58
  contactToRecipient?: (contact: SuggestedContact) => RecipientChip
59
+ /**
60
+ * Async search hook. When provided, the contact picker switches to async
61
+ * mode: the search box forwards its query here (the caller is responsible for
62
+ * debouncing and fetching) and `contacts` is treated as the server-filtered
63
+ * result set rather than a static list to filter client-side. When omitted,
64
+ * the picker filters the static `contacts` array locally (default behavior).
65
+ */
66
+ onSearch?: (query: string) => void
67
+ /** Shows a loading indicator in the picker while async results are fetched. */
68
+ searchLoading?: boolean
59
69
  }
60
70
 
61
71
  function RecipientChipPill({
@@ -110,39 +120,111 @@ function RecipientChipPill({
110
120
  )
111
121
  }
112
122
 
113
- // Contents of the contact picker dropdown. Rendered inside a Radix
114
- // `Popover.Content` so its focus scope pushes onto the focus-scope stack and
115
- // PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
116
- // makes the search input typeable: a plain `createPortal(..., document.body)`
117
- // element renders outside the Dialog's `DialogContent`, so the Dialog's
118
- // FocusScope kept yanking focus back (input un-typeable) and its modal
119
- // `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
120
- // Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
121
- // scope, fixing both. See WIT-800 / WIT-770.
122
- function ContactPickerContents({
123
+ function ContactPickerPopover({
124
+ triggerRef,
123
125
  contacts,
124
126
  addedEmails,
125
127
  onSelect,
126
128
  onAddEmail,
129
+ onClose,
130
+ onSearch,
131
+ searchLoading = false,
127
132
  }: {
133
+ triggerRef: React.RefObject<HTMLElement | null>
128
134
  contacts: SuggestedContact[]
129
135
  addedEmails: Set<string>
130
136
  onSelect: (contact: SuggestedContact) => void
131
137
  onAddEmail: (email: string) => void
138
+ onClose: () => void
139
+ onSearch?: (query: string) => void
140
+ searchLoading?: boolean
132
141
  }) {
142
+ const containerRef = React.useRef<HTMLDivElement>(null)
143
+ const searchRef = React.useRef<HTMLInputElement>(null)
133
144
  const [query, setQuery] = React.useState("")
145
+ const [style, setStyle] = React.useState<React.CSSProperties>({
146
+ position: "fixed",
147
+ top: -9999,
148
+ left: -9999,
149
+ })
150
+
151
+ React.useEffect(() => {
152
+ const trigger = triggerRef.current
153
+ if (!trigger) return
154
+ const rect = trigger.getBoundingClientRect()
155
+ const width = Math.min(448, window.innerWidth - 32)
156
+ let left = rect.left
157
+ if (left + width > window.innerWidth - 16) {
158
+ left = window.innerWidth - 16 - width
159
+ }
160
+ if (left < 16) left = 16
161
+ const popoverHeight = 280
162
+ const spaceBelow = window.innerHeight - rect.bottom - 4
163
+ const spaceAbove = rect.top - 4
164
+ const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow
165
+ let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4
166
+ if (top < 16) top = 16
167
+ setStyle({ position: "fixed", top, left, width })
168
+ }, [triggerRef])
169
+
170
+ React.useEffect(() => {
171
+ searchRef.current?.focus()
172
+ const trigger = triggerRef.current
173
+ return () => {
174
+ if (trigger && typeof trigger.focus === "function") {
175
+ trigger.focus()
176
+ }
177
+ }
178
+ }, [triggerRef])
179
+
180
+ React.useEffect(() => {
181
+ function handleMouseDown(event: MouseEvent) {
182
+ if (
183
+ containerRef.current &&
184
+ !containerRef.current.contains(event.target as Node) &&
185
+ !triggerRef.current?.contains(event.target as Node)
186
+ ) {
187
+ onClose()
188
+ }
189
+ }
190
+ function handleKeyDown(event: KeyboardEvent) {
191
+ if (event.key === "Escape") {
192
+ event.stopPropagation()
193
+ onClose()
194
+ }
195
+ }
196
+ document.addEventListener("mousedown", handleMouseDown)
197
+ document.addEventListener("keydown", handleKeyDown)
198
+ return () => {
199
+ document.removeEventListener("mousedown", handleMouseDown)
200
+ document.removeEventListener("keydown", handleKeyDown)
201
+ }
202
+ }, [onClose, triggerRef])
203
+
204
+ const asyncMode = typeof onSearch === "function"
205
+
206
+ // Async mode: forward the query upward (caller debounces + fetches) and treat
207
+ // `contacts` as the already server-filtered result set. Local mode: filter the
208
+ // static `contacts` array client-side.
209
+ React.useEffect(() => {
210
+ if (asyncMode) {
211
+ onSearch?.(query)
212
+ }
213
+ }, [asyncMode, onSearch, query])
134
214
 
135
215
  const normalizedQuery = query.trim().toLowerCase()
136
- const filtered = normalizedQuery
137
- ? contacts.filter((contact) => {
138
- const email = contactEmail(contact) ?? ""
139
- return (
140
- contact.name.toLowerCase().includes(normalizedQuery) ||
141
- contact.role.toLowerCase().includes(normalizedQuery) ||
142
- email.toLowerCase().includes(normalizedQuery)
143
- )
144
- })
145
- : contacts
216
+ const filtered = asyncMode
217
+ ? contacts
218
+ : normalizedQuery
219
+ ? contacts.filter((contact) => {
220
+ const email = contactEmail(contact) ?? ""
221
+ return (
222
+ contact.name.toLowerCase().includes(normalizedQuery) ||
223
+ contact.role.toLowerCase().includes(normalizedQuery) ||
224
+ email.toLowerCase().includes(normalizedQuery)
225
+ )
226
+ })
227
+ : contacts
146
228
 
147
229
  const queryIsEmail = isValidEmail(query)
148
230
 
@@ -154,11 +236,16 @@ function ContactPickerContents({
154
236
  }
155
237
  }
156
238
 
157
- return (
158
- <>
239
+ return createPortal(
240
+ <div
241
+ ref={containerRef}
242
+ style={style}
243
+ className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
244
+ >
159
245
  <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
160
246
  <Search className="size-4 text-muted-foreground shrink-0" />
161
247
  <input
248
+ ref={searchRef}
162
249
  autoFocus
163
250
  value={query}
164
251
  onChange={(event) => setQuery(event.target.value)}
@@ -169,18 +256,17 @@ function ContactPickerContents({
169
256
  </div>
170
257
 
171
258
  <div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
172
- {contacts.length === 0 ? (
173
- <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
174
- <div className="font-medium text-foreground/80">
175
- No contacts for this account
176
- </div>
177
- <div className="mt-1">
178
- Type an email address above and press Enter to add a recipient.
179
- </div>
259
+ {searchLoading ? (
260
+ <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
261
+ Searching contacts...
180
262
  </div>
181
263
  ) : filtered.length === 0 ? (
182
264
  <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
183
- <div>No contact matches &lsquo;{query}&rsquo;.</div>
265
+ {asyncMode && normalizedQuery.length === 0 ? (
266
+ <div>Type a name or email to search contacts.</div>
267
+ ) : (
268
+ <div>No contact matches &lsquo;{query}&rsquo;.</div>
269
+ )}
184
270
  {queryIsEmail ? (
185
271
  <div className="mt-1">Press Enter to add {query}.</div>
186
272
  ) : null}
@@ -245,7 +331,8 @@ function ContactPickerContents({
245
331
  <CornerDownLeft className="size-3 shrink-0" />
246
332
  <span>Type an address and press Enter to add someone not listed.</span>
247
333
  </div>
248
- </>
334
+ </div>,
335
+ document.body,
249
336
  )
250
337
  }
251
338
 
@@ -262,9 +349,12 @@ export function EmailRecipientField({
262
349
  addedEmails,
263
350
  placeholder,
264
351
  contactToRecipient,
352
+ onSearch,
353
+ searchLoading,
265
354
  }: EmailRecipientFieldProps) {
266
355
  const [value, setValue] = React.useState("")
267
356
  const [pickerOpen, setPickerOpen] = React.useState(false)
357
+ const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
268
358
 
269
359
  const hasUnconfirmed = recipients.some((r) => !r.confirmed)
270
360
  const state: "default" | "amber" =
@@ -365,37 +455,16 @@ export function EmailRecipientField({
365
455
  {showPicker || showCcBcc ? (
366
456
  <div className="flex gap-1.5 mt-2">
367
457
  {showPicker ? (
368
- <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>
369
- <PopoverPrimitive.Trigger asChild>
370
- <button
371
- type="button"
372
- className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
373
- >
374
- <Users className="size-3" />
375
- Contacts
376
- <ChevronDown className="size-3" />
377
- </button>
378
- </PopoverPrimitive.Trigger>
379
- <PopoverPrimitive.Portal>
380
- <PopoverPrimitive.Content
381
- side="bottom"
382
- align="start"
383
- sideOffset={4}
384
- collisionPadding={16}
385
- className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
386
- >
387
- <ContactPickerContents
388
- contacts={contacts}
389
- addedEmails={added}
390
- onSelect={selectContact}
391
- onAddEmail={(email) => {
392
- addEmail(email)
393
- setPickerOpen(false)
394
- }}
395
- />
396
- </PopoverPrimitive.Content>
397
- </PopoverPrimitive.Portal>
398
- </PopoverPrimitive.Root>
458
+ <button
459
+ ref={contactsTriggerRef}
460
+ type="button"
461
+ onClick={() => setPickerOpen((open) => !open)}
462
+ className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
463
+ >
464
+ <Users className="size-3" />
465
+ Contacts
466
+ <ChevronDown className="size-3" />
467
+ </button>
399
468
  ) : null}
400
469
  {showCcBcc ? (
401
470
  <button
@@ -409,6 +478,22 @@ export function EmailRecipientField({
409
478
  ) : null}
410
479
  </div>
411
480
  ) : null}
481
+
482
+ {pickerOpen ? (
483
+ <ContactPickerPopover
484
+ triggerRef={contactsTriggerRef}
485
+ contacts={contacts}
486
+ addedEmails={added}
487
+ onSelect={selectContact}
488
+ onAddEmail={(email) => {
489
+ addEmail(email)
490
+ setPickerOpen(false)
491
+ }}
492
+ onClose={() => setPickerOpen(false)}
493
+ onSearch={onSearch}
494
+ searchLoading={searchLoading}
495
+ />
496
+ ) : null}
412
497
  </div>
413
498
  </div>
414
499
  )