@handled-ai/design-system 0.18.42 → 0.18.44

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.
@@ -21,14 +21,12 @@ interface EmailRecipientFieldProps {
21
21
  placeholder?: string;
22
22
  contactToRecipient?: (contact: SuggestedContact) => RecipientChip;
23
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).
24
+ * Async search mode. When provided, the picker forwards the typed query to
25
+ * this callback (the caller debounces + fetches) and treats `contacts` as the
26
+ * already server-filtered result set instead of filtering it client-side.
29
27
  */
30
28
  onSearch?: (query: string) => void;
31
- /** Shows a loading indicator in the picker while async results are fetched. */
29
+ /** Shows a "Searching contacts..." indicator while async results load. */
32
30
  searchLoading?: boolean;
33
31
  }
34
32
  declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, onSearch, searchLoading, }: EmailRecipientFieldProps): React.JSX.Element;
@@ -20,11 +20,10 @@ var __spreadValues = (a, b) => {
20
20
  return a;
21
21
  };
22
22
  var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
23
- import { jsx, jsxs } from "react/jsx-runtime";
23
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
24
24
  import * as React from "react";
25
- import { createPortal } from "react-dom";
25
+ import { Popover as PopoverPrimitive } from "radix-ui";
26
26
  import {
27
- Check,
28
27
  ChevronDown,
29
28
  CornerDownLeft,
30
29
  Plus,
@@ -50,16 +49,21 @@ function RecipientChipPill({
50
49
  onConfirm,
51
50
  onRemove
52
51
  }) {
53
- const display = recipient.name || recipient.email;
52
+ const primary = recipient.name || recipient.email;
53
+ const secondary = recipient.name ? recipient.email : "";
54
+ const display = primary || recipient.email;
54
55
  if (!recipient.confirmed) {
55
- return /* @__PURE__ */ jsxs("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", children: [
56
- /* @__PURE__ */ jsx("span", { className: "truncate max-w-[180px]", children: display }),
56
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800", children: [
57
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
58
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
59
+ secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-amber-800/70", children: secondary }) : null
60
+ ] }),
57
61
  /* @__PURE__ */ jsx(
58
62
  "button",
59
63
  {
60
64
  type: "button",
61
65
  onClick: onConfirm,
62
- className: "text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85",
66
+ className: "ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200",
63
67
  children: "Confirm"
64
68
  }
65
69
  ),
@@ -69,93 +73,38 @@ function RecipientChipPill({
69
73
  type: "button",
70
74
  "aria-label": `Remove ${display}`,
71
75
  onClick: onRemove,
72
- className: "inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40",
76
+ className: "inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70",
73
77
  children: /* @__PURE__ */ jsx(X, { className: "size-3" })
74
78
  }
75
79
  )
76
80
  ] });
77
81
  }
78
- return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground", children: [
79
- /* @__PURE__ */ jsx("span", { className: "inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700", children: /* @__PURE__ */ jsx(Check, { className: "size-3" }) }),
80
- /* @__PURE__ */ jsx("span", { className: "truncate max-w-[180px]", children: display }),
82
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground", children: [
83
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
84
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
85
+ secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-muted-foreground", children: secondary }) : null
86
+ ] }),
81
87
  /* @__PURE__ */ jsx(
82
88
  "button",
83
89
  {
84
90
  type: "button",
85
91
  "aria-label": `Remove ${display}`,
86
92
  onClick: onRemove,
87
- className: "inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted",
93
+ className: "inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
88
94
  children: /* @__PURE__ */ jsx(X, { className: "size-3" })
89
95
  }
90
96
  )
91
97
  ] });
92
98
  }
93
- function ContactPickerPopover({
94
- triggerRef,
99
+ function ContactPickerContents({
95
100
  contacts,
96
101
  addedEmails,
97
102
  onSelect,
98
103
  onAddEmail,
99
- onClose,
100
104
  onSearch,
101
105
  searchLoading = false
102
106
  }) {
103
- const containerRef = React.useRef(null);
104
- const searchRef = React.useRef(null);
105
107
  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
108
  const asyncMode = typeof onSearch === "function";
160
109
  const onSearchRef = React.useRef(onSearch);
161
110
  React.useEffect(() => {
@@ -181,82 +130,73 @@ function ContactPickerPopover({
181
130
  setQuery("");
182
131
  }
183
132
  }
184
- return createPortal(
185
- /* @__PURE__ */ jsxs(
186
- "div",
187
- {
188
- ref: containerRef,
189
- style,
190
- className: "bg-background border rounded-lg shadow-xl z-50 pointer-events-auto",
191
- children: [
192
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
193
- /* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
194
- /* @__PURE__ */ jsx(
195
- "input",
196
- {
197
- ref: searchRef,
198
- autoFocus: true,
199
- value: query,
200
- onChange: (event) => setQuery(event.target.value),
201
- onKeyDown: handleKeyDown,
202
- className: "flex-1 text-[13px] bg-transparent outline-none",
203
- placeholder: "Search contacts..."
204
- }
205
- )
206
- ] }),
207
- /* @__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: [
208
- asyncMode && normalizedQuery.length === 0 ? /* @__PURE__ */ jsx("div", { children: "Type a name or email to search contacts." }) : /* @__PURE__ */ jsxs("div", { children: [
209
- "No contact matches \u2018",
210
- query,
211
- "\u2019."
133
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
134
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
135
+ /* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
136
+ /* @__PURE__ */ jsx(
137
+ "input",
138
+ {
139
+ autoFocus: true,
140
+ value: query,
141
+ onChange: (event) => setQuery(event.target.value),
142
+ onKeyDown: handleKeyDown,
143
+ className: "flex-1 text-[13px] bg-transparent outline-none",
144
+ placeholder: "Search contacts..."
145
+ }
146
+ )
147
+ ] }),
148
+ /* @__PURE__ */ jsx("div", { role: "listbox", className: "max-h-[208px] overflow-y-auto p-1", children: searchLoading ? /* @__PURE__ */ jsx("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: "Searching contacts..." }) : asyncMode && normalizedQuery.length === 0 ? /* @__PURE__ */ jsx("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: "Type a name or email to search contacts." }) : !asyncMode && contacts.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: [
149
+ /* @__PURE__ */ jsx("div", { className: "font-medium text-foreground/80", children: "No contacts for this account" }),
150
+ /* @__PURE__ */ jsx("div", { className: "mt-1", children: "Type an email address above and press Enter to add a recipient." })
151
+ ] }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
152
+ /* @__PURE__ */ jsxs("div", { children: [
153
+ "No contact matches \u2018",
154
+ query,
155
+ "\u2019."
156
+ ] }),
157
+ queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
158
+ "Press Enter to add ",
159
+ query,
160
+ "."
161
+ ] }) : null
162
+ ] }) : filtered.map((contact, index) => {
163
+ const email = contactEmail(contact);
164
+ const noEmail = !email || !isValidEmail(email);
165
+ const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
166
+ const disabled = noEmail || alreadyAdded;
167
+ return /* @__PURE__ */ jsxs(
168
+ "div",
169
+ {
170
+ role: "option",
171
+ "aria-selected": false,
172
+ "aria-disabled": disabled,
173
+ onClick: () => {
174
+ if (!disabled) onSelect(contact);
175
+ },
176
+ className: cn(
177
+ "flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
178
+ disabled && "opacity-45 pointer-events-none"
179
+ ),
180
+ children: [
181
+ /* @__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 : "?") }),
182
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
183
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
184
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
185
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
186
+ ] }),
187
+ email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
212
188
  ] }),
213
- queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
214
- "Press Enter to add ",
215
- query,
216
- "."
217
- ] }) : null
218
- ] }) : filtered.map((contact, index) => {
219
- const email = contactEmail(contact);
220
- const noEmail = !email || !isValidEmail(email);
221
- const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
222
- const disabled = noEmail || alreadyAdded;
223
- return /* @__PURE__ */ jsxs(
224
- "div",
225
- {
226
- role: "option",
227
- "aria-selected": false,
228
- "aria-disabled": disabled,
229
- onClick: () => {
230
- if (!disabled) onSelect(contact);
231
- },
232
- className: cn(
233
- "flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
234
- disabled && "opacity-45 pointer-events-none"
235
- ),
236
- children: [
237
- /* @__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 : "?") }),
238
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
239
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
240
- /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
241
- /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
242
- ] }),
243
- email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
244
- ] }),
245
- 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
246
- ]
247
- },
248
- `${contact.name}-${email != null ? email : index}`
249
- );
250
- }) }),
251
- /* @__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: [
252
- /* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
253
- /* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
254
- ] })
255
- ]
256
- }
257
- ),
258
- document.body
259
- );
189
+ 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
190
+ ]
191
+ },
192
+ `${contact.name}-${email != null ? email : index}`
193
+ );
194
+ }) }),
195
+ /* @__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: [
196
+ /* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
197
+ /* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
198
+ ] })
199
+ ] });
260
200
  }
261
201
  function EmailRecipientField({
262
202
  label,
@@ -276,28 +216,36 @@ function EmailRecipientField({
276
216
  }) {
277
217
  const [value, setValue] = React.useState("");
278
218
  const [pickerOpen, setPickerOpen] = React.useState(false);
279
- const contactsTriggerRef = React.useRef(null);
280
219
  const hasUnconfirmed = recipients.some((r) => !r.confirmed);
281
220
  const state = amber && hasUnconfirmed ? "amber" : "default";
282
221
  const amberRow = state === "amber";
283
222
  const added = addedEmails != null ? addedEmails : /* @__PURE__ */ new Set();
284
223
  const resolvedPlaceholder = placeholder != null ? placeholder : recipients.length > 0 ? "Add another..." : "Add email...";
224
+ const committedByKeyRef = React.useRef(null);
285
225
  function addEmail(email) {
286
226
  const trimmed = email.trim();
287
- if (!isValidEmail(trimmed)) return;
288
- if (added.has(trimmed.toLowerCase())) return;
227
+ if (!isValidEmail(trimmed)) return false;
228
+ if (added.has(trimmed.toLowerCase())) return false;
289
229
  onRecipientsChange([
290
230
  ...recipients,
291
231
  { id: trimmed, email: trimmed, name: "", confirmed: false }
292
232
  ]);
293
233
  setValue("");
234
+ return true;
294
235
  }
295
236
  function handleKeyDown(event) {
296
- if ((event.key === "Enter" || event.key === ",") && isValidEmail(value)) {
237
+ if ((event.key === "Enter" || event.key === ",") && value.trim()) {
297
238
  event.preventDefault();
298
239
  addEmail(value);
299
240
  return;
300
241
  }
242
+ if (event.key === "Tab" && value.trim()) {
243
+ const trimmed = value.trim();
244
+ if (addEmail(trimmed)) {
245
+ committedByKeyRef.current = trimmed.toLowerCase();
246
+ }
247
+ return;
248
+ }
301
249
  if (event.key === "Backspace" && value === "" && recipients.length > 0) {
302
250
  event.preventDefault();
303
251
  onRecipientsChange(recipients.slice(0, -1));
@@ -358,7 +306,12 @@ function EmailRecipientField({
358
306
  onChange: (event) => setValue(event.target.value),
359
307
  onKeyDown: handleKeyDown,
360
308
  onBlur: () => {
361
- if (isValidEmail(value)) addEmail(value);
309
+ const trimmed = value.trim().toLowerCase();
310
+ if (trimmed && committedByKeyRef.current === trimmed) {
311
+ committedByKeyRef.current = null;
312
+ return;
313
+ }
314
+ addEmail(value);
362
315
  },
363
316
  placeholder: resolvedPlaceholder,
364
317
  className: "min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
@@ -366,20 +319,44 @@ function EmailRecipientField({
366
319
  )
367
320
  ] }),
368
321
  showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
369
- showPicker ? /* @__PURE__ */ jsxs(
370
- "button",
371
- {
372
- ref: contactsTriggerRef,
373
- type: "button",
374
- onClick: () => setPickerOpen((open) => !open),
375
- 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]",
376
- children: [
377
- /* @__PURE__ */ jsx(Users, { className: "size-3" }),
378
- "Contacts",
379
- /* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
380
- ]
381
- }
382
- ) : null,
322
+ showPicker ? /* @__PURE__ */ jsxs(PopoverPrimitive.Root, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
323
+ /* @__PURE__ */ jsx(PopoverPrimitive.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
324
+ "button",
325
+ {
326
+ type: "button",
327
+ 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]",
328
+ children: [
329
+ /* @__PURE__ */ jsx(Users, { className: "size-3" }),
330
+ "Contacts",
331
+ /* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
332
+ ]
333
+ }
334
+ ) }),
335
+ /* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
336
+ PopoverPrimitive.Content,
337
+ {
338
+ side: "bottom",
339
+ align: "start",
340
+ sideOffset: 4,
341
+ collisionPadding: 16,
342
+ className: "z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0",
343
+ children: /* @__PURE__ */ jsx(
344
+ ContactPickerContents,
345
+ {
346
+ contacts,
347
+ addedEmails: added,
348
+ onSelect: selectContact,
349
+ onAddEmail: (email) => {
350
+ addEmail(email);
351
+ setPickerOpen(false);
352
+ },
353
+ onSearch,
354
+ searchLoading
355
+ }
356
+ )
357
+ }
358
+ ) })
359
+ ] }) : null,
383
360
  showCcBcc ? /* @__PURE__ */ jsxs(
384
361
  "button",
385
362
  {
@@ -392,23 +369,7 @@ function EmailRecipientField({
392
369
  ]
393
370
  }
394
371
  ) : null
395
- ] }) : null,
396
- pickerOpen ? /* @__PURE__ */ jsx(
397
- ContactPickerPopover,
398
- {
399
- triggerRef: contactsTriggerRef,
400
- contacts,
401
- addedEmails: added,
402
- onSelect: selectContact,
403
- onAddEmail: (email) => {
404
- addEmail(email);
405
- setPickerOpen(false);
406
- },
407
- onClose: () => setPickerOpen(false),
408
- onSearch,
409
- searchLoading
410
- }
411
- ) : null
372
+ ] }) : null
412
373
  ] })
413
374
  ]
414
375
  }
@@ -1 +1 @@
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 // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\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 onSearchRef.current?.(query)\n }\n }, [asyncMode, 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"]}
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 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 mode. When provided, the picker forwards the typed query to\n * this callback (the caller debounces + fetches) and treats `contacts` as the\n * already server-filtered result set instead of filtering it client-side.\n */\n onSearch?: (query: string) => void\n /** Shows a \"Searching contacts...\" indicator while async results load. */\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 primary = recipient.name || recipient.email\n const secondary = recipient.name ? recipient.email : \"\"\n const display = primary || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-amber-800/70\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-muted-foreground\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\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 onSearch,\n searchLoading = false,\n}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\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 onSearchRef.current?.(query)\n }\n }, [asyncMode, 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 (\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 {searchLoading ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : asyncMode && normalizedQuery.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Type a name or email to search contacts.\n </div>\n ) : !asyncMode && 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 onSearch,\n searchLoading,\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 const committedByKeyRef = React.useRef<string | null>(null)\n\n function addEmail(email: string): boolean {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return false\n if (added.has(trimmed.toLowerCase())) return false\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n return true\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && value.trim()) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Tab\" && value.trim()) {\n const trimmed = value.trim()\n if (addEmail(trimmed)) {\n committedByKeyRef.current = trimmed.toLowerCase()\n }\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 const trimmed = value.trim().toLowerCase()\n if (trimmed && committedByKeyRef.current === trimmed) {\n committedByKeyRef.current = null\n return\n }\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n 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 onSearch={onSearch}\n searchLoading={searchLoading}\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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.42",
3
+ "version": "0.18.44",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -1,11 +1,16 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest"
2
2
  import React from "react"
3
- import { render, screen, fireEvent, cleanup } from "@testing-library/react"
3
+ import { render, screen, fireEvent, cleanup, within } 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"
9
14
  import type { SuggestedContact } from "../suggested-actions"
10
15
 
11
16
  afterEach(() => {
@@ -53,6 +58,53 @@ describe("EmailRecipientField", () => {
53
58
  ])
54
59
  })
55
60
 
61
+ it("adds an unconfirmed chip on Tab without trapping focus", () => {
62
+ const onChange = vi.fn()
63
+ render(
64
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
65
+ )
66
+
67
+ const input = getInput()
68
+ fireEvent.change(input, { target: { value: "tab@example.com" } })
69
+ const wasNotCancelled = fireEvent.keyDown(input, { key: "Tab" })
70
+
71
+ expect(wasNotCancelled).toBe(true)
72
+ expect(onChange).toHaveBeenCalledWith([
73
+ { id: "tab@example.com", email: "tab@example.com", name: "", confirmed: false },
74
+ ])
75
+ })
76
+
77
+ it("adds an unconfirmed chip on blur", () => {
78
+ const onChange = vi.fn()
79
+ render(
80
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
81
+ )
82
+
83
+ const input = getInput()
84
+ fireEvent.change(input, { target: { value: "blur@example.com" } })
85
+ fireEvent.blur(input)
86
+
87
+ expect(onChange).toHaveBeenCalledWith([
88
+ { id: "blur@example.com", email: "blur@example.com", name: "", confirmed: false },
89
+ ])
90
+ })
91
+
92
+ it("uses the roomy confirmation chip treatment for unconfirmed recipients", () => {
93
+ render(
94
+ <EmailRecipientField
95
+ label="To"
96
+ recipients={[{ id: "a@b.com", email: "a@b.com", name: "Ada Lovelace", confirmed: false }]}
97
+ onRecipientsChange={vi.fn()}
98
+ />,
99
+ )
100
+
101
+ const confirmButton = screen.getByRole("button", { name: "Confirm" })
102
+ expect(confirmButton.className).toContain("bg-amber-200/60")
103
+ expect(confirmButton.className).toContain("px-2")
104
+ expect(screen.getByText("Ada Lovelace").className).toContain("font-medium")
105
+ expect(screen.getByText("a@b.com").className).toContain("text-amber-800/70")
106
+ })
107
+
56
108
  it("does not add an invalid email", () => {
57
109
  const onChange = vi.fn()
58
110
  render(
@@ -230,71 +282,170 @@ describe("EmailRecipientField", () => {
230
282
  ])
231
283
  })
232
284
 
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
- ]
285
+ it("disables rows for added and no-email contacts", () => {
240
286
  render(
241
287
  <EmailRecipientField
242
288
  label="To"
243
289
  recipients={[]}
244
290
  onRecipientsChange={vi.fn()}
245
291
  showPicker
246
- contacts={serverResults}
247
- onSearch={onSearch}
292
+ contacts={contacts}
293
+ addedEmails={new Set(["alex@example.com"])}
248
294
  />,
249
295
  )
250
296
 
251
297
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
252
- // Opening the picker forwards the initial (empty) query.
253
- expect(onSearch).toHaveBeenCalledWith("")
254
298
 
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")
299
+ const options = screen.getAllByRole("option")
300
+ const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
301
+ const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
258
302
 
259
- // Server result is shown verbatim even though it doesn't match the query.
260
- expect(screen.getByText("Server Result")).toBeTruthy()
303
+ expect(alexRow.className).toContain("pointer-events-none")
304
+ expect(alexRow.textContent).toContain("Added")
305
+ expect(noEmailRow.className).toContain("pointer-events-none")
306
+ expect(noEmailRow.textContent).toContain("No email")
261
307
  })
262
308
 
263
- it("async mode: does not re-search the same query when only onSearch identity changes", () => {
264
- const firstOnSearch = vi.fn()
265
- const secondOnSearch = vi.fn()
266
- const { rerender } = render(
309
+ it("autofocuses the search input when the picker opens", () => {
310
+ render(
267
311
  <EmailRecipientField
268
312
  label="To"
269
313
  recipients={[]}
270
314
  onRecipientsChange={vi.fn()}
271
315
  showPicker
272
- contacts={[]}
273
- onSearch={firstOnSearch}
316
+ contacts={contacts}
274
317
  />,
275
318
  )
276
319
 
277
320
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
278
- // Initial empty query forwarded once.
279
- expect(firstOnSearch).toHaveBeenCalledTimes(1)
280
- expect(firstOnSearch).toHaveBeenCalledWith("")
321
+ const search = screen.getByPlaceholderText("Search contacts...")
322
+ expect(document.activeElement).toBe(search)
323
+ })
281
324
 
282
- // A parent re-render that swaps the callback identity (e.g. an inline
283
- // handler) must NOT retrigger a search for the unchanged query.
284
- rerender(
325
+ it("accepts typing in the search box and filters the list", () => {
326
+ render(
327
+ <EmailRecipientField
328
+ label="To"
329
+ recipients={[]}
330
+ onRecipientsChange={vi.fn()}
331
+ showPicker
332
+ contacts={contacts}
333
+ />,
334
+ )
335
+
336
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
337
+ const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
338
+
339
+ fireEvent.change(search, { target: { value: "Bea" } })
340
+ expect(search.value).toBe("Bea")
341
+ expect(screen.getByText("Bea Buyer")).toBeTruthy()
342
+ expect(screen.queryByText("Alex Admin")).toBeNull()
343
+ })
344
+
345
+ it("adds a typed email from the picker search box on Enter", () => {
346
+ const onChange = vi.fn()
347
+ render(
348
+ <EmailRecipientField
349
+ label="To"
350
+ recipients={[]}
351
+ onRecipientsChange={onChange}
352
+ showPicker
353
+ contacts={contacts}
354
+ />,
355
+ )
356
+
357
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
358
+ const search = screen.getByPlaceholderText("Search contacts...")
359
+
360
+ fireEvent.change(search, { target: { value: "typed@example.com" } })
361
+ fireEvent.keyDown(search, { key: "Enter" })
362
+
363
+ expect(onChange).toHaveBeenCalledWith([
364
+ { id: "typed@example.com", email: "typed@example.com", name: "", confirmed: false },
365
+ ])
366
+ })
367
+
368
+ it("shows a clear empty state when there are no contacts", () => {
369
+ render(
285
370
  <EmailRecipientField
286
371
  label="To"
287
372
  recipients={[]}
288
373
  onRecipientsChange={vi.fn()}
289
374
  showPicker
290
375
  contacts={[]}
291
- onSearch={secondOnSearch}
292
376
  />,
293
377
  )
294
- expect(secondOnSearch).not.toHaveBeenCalled()
378
+
379
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
380
+ expect(screen.getByText("No contacts for this account")).toBeTruthy()
381
+ // The empty state guides the user to type an address instead of looking broken.
382
+ expect(
383
+ screen.getByText(/Type an email address above and press Enter/),
384
+ ).toBeTruthy()
385
+ })
386
+
387
+ it("remains typeable when rendered inside a modal Dialog (WIT-800 focus trap)", () => {
388
+ // Reproduces the staging bug: the picker used to render via
389
+ // createPortal(document.body), outside the Dialog's FocusScope, so the
390
+ // search input could not hold focus. The Radix Popover rebuild keeps the
391
+ // input inside its own (parent-pausing) focus scope.
392
+ render(
393
+ <Dialog open>
394
+ <DialogContent aria-describedby={undefined}>
395
+ <DialogTitle>Compose</DialogTitle>
396
+ <EmailRecipientField
397
+ label="To"
398
+ recipients={[]}
399
+ onRecipientsChange={vi.fn()}
400
+ showPicker
401
+ contacts={contacts}
402
+ />
403
+ </DialogContent>
404
+ </Dialog>,
405
+ )
406
+
407
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
408
+ const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
409
+
410
+ search.focus()
411
+ expect(document.activeElement).toBe(search)
412
+
413
+ fireEvent.change(search, { target: { value: "Alex" } })
414
+ expect(search.value).toBe("Alex")
415
+ const listbox = screen.getByRole("listbox")
416
+ expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
417
+ })
418
+
419
+ it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
420
+ const onSearch = vi.fn()
421
+ // In async mode `contacts` is the already server-filtered result set, so
422
+ // every supplied contact should render regardless of the typed query.
423
+ render(
424
+ <EmailRecipientField
425
+ label="To"
426
+ recipients={[]}
427
+ onRecipientsChange={vi.fn()}
428
+ showPicker
429
+ contacts={contacts}
430
+ onSearch={onSearch}
431
+ />,
432
+ )
433
+
434
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
435
+ // Opening forwards the initial empty query.
436
+ expect(onSearch).toHaveBeenCalledWith("")
437
+
438
+ const search = screen.getByPlaceholderText("Search contacts...")
439
+ fireEvent.change(search, { target: { value: "zzz" } })
440
+
441
+ expect(onSearch).toHaveBeenCalledWith("zzz")
442
+ const listbox = screen.getByRole("listbox")
443
+ // No client-side filtering: server-provided contacts still show.
444
+ expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
445
+ expect(within(listbox).getByText("Bea Buyer")).toBeTruthy()
295
446
  })
296
447
 
297
- it("async mode: shows the searching indicator while loading", () => {
448
+ it("async mode: shows a searching indicator while results load", () => {
298
449
  render(
299
450
  <EmailRecipientField
300
451
  label="To"
@@ -311,7 +462,7 @@ describe("EmailRecipientField", () => {
311
462
  expect(screen.getByText("Searching contacts...")).toBeTruthy()
312
463
  })
313
464
 
314
- it("async mode: prompts to type before searching when query is empty", () => {
465
+ it("async mode: prompts to type before searching when the query is empty", () => {
315
466
  render(
316
467
  <EmailRecipientField
317
468
  label="To"
@@ -324,30 +475,42 @@ describe("EmailRecipientField", () => {
324
475
  )
325
476
 
326
477
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
327
- expect(screen.getByText("Type a name or email to search contacts.")).toBeTruthy()
478
+ expect(
479
+ screen.getByText("Type a name or email to search contacts."),
480
+ ).toBeTruthy()
328
481
  })
329
482
 
330
- it("disables rows for added and no-email contacts", () => {
331
- render(
483
+ it("async mode: does not re-search the same query when only onSearch identity changes", () => {
484
+ const firstOnSearch = vi.fn()
485
+ const { rerender } = render(
332
486
  <EmailRecipientField
333
487
  label="To"
334
488
  recipients={[]}
335
489
  onRecipientsChange={vi.fn()}
336
490
  showPicker
337
- contacts={contacts}
338
- addedEmails={new Set(["alex@example.com"])}
491
+ contacts={[]}
492
+ onSearch={firstOnSearch}
339
493
  />,
340
494
  )
341
495
 
342
496
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
497
+ expect(firstOnSearch).toHaveBeenCalledTimes(1)
498
+ expect(firstOnSearch).toHaveBeenCalledWith("")
343
499
 
344
- const options = screen.getAllByRole("option")
345
- const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
346
- const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
500
+ const secondOnSearch = vi.fn()
501
+ rerender(
502
+ <EmailRecipientField
503
+ label="To"
504
+ recipients={[]}
505
+ onRecipientsChange={vi.fn()}
506
+ showPicker
507
+ contacts={[]}
508
+ onSearch={secondOnSearch}
509
+ />,
510
+ )
347
511
 
348
- expect(alexRow.className).toContain("pointer-events-none")
349
- expect(alexRow.textContent).toContain("Added")
350
- expect(noEmailRow.className).toContain("pointer-events-none")
351
- expect(noEmailRow.textContent).toContain("No email")
512
+ // Query is unchanged (""), so swapping the callback identity must not
513
+ // trigger another search.
514
+ expect(secondOnSearch).not.toHaveBeenCalled()
352
515
  })
353
516
  })
@@ -1,9 +1,8 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { createPortal } from "react-dom"
4
+ import { Popover as PopoverPrimitive } from "radix-ui"
5
5
  import {
6
- Check,
7
6
  ChevronDown,
8
7
  CornerDownLeft,
9
8
  Plus,
@@ -57,14 +56,12 @@ export interface EmailRecipientFieldProps {
57
56
  placeholder?: string
58
57
  contactToRecipient?: (contact: SuggestedContact) => RecipientChip
59
58
  /**
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).
59
+ * Async search mode. When provided, the picker forwards the typed query to
60
+ * this callback (the caller debounces + fetches) and treats `contacts` as the
61
+ * already server-filtered result set instead of filtering it client-side.
65
62
  */
66
63
  onSearch?: (query: string) => void
67
- /** Shows a loading indicator in the picker while async results are fetched. */
64
+ /** Shows a "Searching contacts..." indicator while async results load. */
68
65
  searchLoading?: boolean
69
66
  }
70
67
 
@@ -77,16 +74,25 @@ function RecipientChipPill({
77
74
  onConfirm: () => void
78
75
  onRemove: () => void
79
76
  }) {
80
- const display = recipient.name || recipient.email
77
+ const primary = recipient.name || recipient.email
78
+ const secondary = recipient.name ? recipient.email : ""
79
+ const display = primary || recipient.email
81
80
 
82
81
  if (!recipient.confirmed) {
83
82
  return (
84
- <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">
85
- <span className="truncate max-w-[180px]">{display}</span>
83
+ <span className="inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
84
+ <span className="min-w-0 inline-flex items-baseline gap-1">
85
+ <span className="truncate font-medium max-w-[150px]">{primary}</span>
86
+ {secondary ? (
87
+ <span className="truncate max-w-[190px] text-amber-800/70">
88
+ {secondary}
89
+ </span>
90
+ ) : null}
91
+ </span>
86
92
  <button
87
93
  type="button"
88
94
  onClick={onConfirm}
89
- className="text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85"
95
+ className="ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200"
90
96
  >
91
97
  Confirm
92
98
  </button>
@@ -94,7 +100,7 @@ function RecipientChipPill({
94
100
  type="button"
95
101
  aria-label={`Remove ${display}`}
96
102
  onClick={onRemove}
97
- className="inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40"
103
+ className="inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70"
98
104
  >
99
105
  <X className="size-3" />
100
106
  </button>
@@ -103,16 +109,20 @@ function RecipientChipPill({
103
109
  }
104
110
 
105
111
  return (
106
- <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground">
107
- <span className="inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700">
108
- <Check className="size-3" />
112
+ <span className="inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground">
113
+ <span className="min-w-0 inline-flex items-baseline gap-1">
114
+ <span className="truncate font-medium max-w-[150px]">{primary}</span>
115
+ {secondary ? (
116
+ <span className="truncate max-w-[190px] text-muted-foreground">
117
+ {secondary}
118
+ </span>
119
+ ) : null}
109
120
  </span>
110
- <span className="truncate max-w-[180px]">{display}</span>
111
121
  <button
112
122
  type="button"
113
123
  aria-label={`Remove ${display}`}
114
124
  onClick={onRemove}
115
- className="inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted"
125
+ className="inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
116
126
  >
117
127
  <X className="size-3" />
118
128
  </button>
@@ -120,86 +130,31 @@ function RecipientChipPill({
120
130
  )
121
131
  }
122
132
 
123
- function ContactPickerPopover({
124
- triggerRef,
133
+ // Contents of the contact picker dropdown. Rendered inside a Radix
134
+ // `Popover.Content` so its focus scope pushes onto the focus-scope stack and
135
+ // PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
136
+ // makes the search input typeable: a plain `createPortal(..., document.body)`
137
+ // element renders outside the Dialog's `DialogContent`, so the Dialog's
138
+ // FocusScope kept yanking focus back (input un-typeable) and its modal
139
+ // `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
140
+ // Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
141
+ // scope, fixing both. See WIT-800 / WIT-770.
142
+ function ContactPickerContents({
125
143
  contacts,
126
144
  addedEmails,
127
145
  onSelect,
128
146
  onAddEmail,
129
- onClose,
130
147
  onSearch,
131
148
  searchLoading = false,
132
149
  }: {
133
- triggerRef: React.RefObject<HTMLElement | null>
134
150
  contacts: SuggestedContact[]
135
151
  addedEmails: Set<string>
136
152
  onSelect: (contact: SuggestedContact) => void
137
153
  onAddEmail: (email: string) => void
138
- onClose: () => void
139
154
  onSearch?: (query: string) => void
140
155
  searchLoading?: boolean
141
156
  }) {
142
- const containerRef = React.useRef<HTMLDivElement>(null)
143
- const searchRef = React.useRef<HTMLInputElement>(null)
144
157
  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
158
 
204
159
  const asyncMode = typeof onSearch === "function"
205
160
 
@@ -245,16 +200,11 @@ function ContactPickerPopover({
245
200
  }
246
201
  }
247
202
 
248
- return createPortal(
249
- <div
250
- ref={containerRef}
251
- style={style}
252
- className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
253
- >
203
+ return (
204
+ <>
254
205
  <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
255
206
  <Search className="size-4 text-muted-foreground shrink-0" />
256
207
  <input
257
- ref={searchRef}
258
208
  autoFocus
259
209
  value={query}
260
210
  onChange={(event) => setQuery(event.target.value)}
@@ -266,16 +216,25 @@ function ContactPickerPopover({
266
216
 
267
217
  <div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
268
218
  {searchLoading ? (
269
- <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
219
+ <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
270
220
  Searching contacts...
271
221
  </div>
222
+ ) : asyncMode && normalizedQuery.length === 0 ? (
223
+ <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
224
+ Type a name or email to search contacts.
225
+ </div>
226
+ ) : !asyncMode && contacts.length === 0 ? (
227
+ <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
228
+ <div className="font-medium text-foreground/80">
229
+ No contacts for this account
230
+ </div>
231
+ <div className="mt-1">
232
+ Type an email address above and press Enter to add a recipient.
233
+ </div>
234
+ </div>
272
235
  ) : filtered.length === 0 ? (
273
236
  <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
274
- {asyncMode && normalizedQuery.length === 0 ? (
275
- <div>Type a name or email to search contacts.</div>
276
- ) : (
277
- <div>No contact matches &lsquo;{query}&rsquo;.</div>
278
- )}
237
+ <div>No contact matches &lsquo;{query}&rsquo;.</div>
279
238
  {queryIsEmail ? (
280
239
  <div className="mt-1">Press Enter to add {query}.</div>
281
240
  ) : null}
@@ -340,8 +299,7 @@ function ContactPickerPopover({
340
299
  <CornerDownLeft className="size-3 shrink-0" />
341
300
  <span>Type an address and press Enter to add someone not listed.</span>
342
301
  </div>
343
- </div>,
344
- document.body,
302
+ </>
345
303
  )
346
304
  }
347
305
 
@@ -363,7 +321,6 @@ export function EmailRecipientField({
363
321
  }: EmailRecipientFieldProps) {
364
322
  const [value, setValue] = React.useState("")
365
323
  const [pickerOpen, setPickerOpen] = React.useState(false)
366
- const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
367
324
 
368
325
  const hasUnconfirmed = recipients.some((r) => !r.confirmed)
369
326
  const state: "default" | "amber" =
@@ -375,23 +332,33 @@ export function EmailRecipientField({
375
332
  const resolvedPlaceholder =
376
333
  placeholder ?? (recipients.length > 0 ? "Add another..." : "Add email...")
377
334
 
378
- function addEmail(email: string) {
335
+ const committedByKeyRef = React.useRef<string | null>(null)
336
+
337
+ function addEmail(email: string): boolean {
379
338
  const trimmed = email.trim()
380
- if (!isValidEmail(trimmed)) return
381
- if (added.has(trimmed.toLowerCase())) return
339
+ if (!isValidEmail(trimmed)) return false
340
+ if (added.has(trimmed.toLowerCase())) return false
382
341
  onRecipientsChange([
383
342
  ...recipients,
384
343
  { id: trimmed, email: trimmed, name: "", confirmed: false },
385
344
  ])
386
345
  setValue("")
346
+ return true
387
347
  }
388
348
 
389
349
  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
390
- if ((event.key === "Enter" || event.key === ",") && isValidEmail(value)) {
350
+ if ((event.key === "Enter" || event.key === ",") && value.trim()) {
391
351
  event.preventDefault()
392
352
  addEmail(value)
393
353
  return
394
354
  }
355
+ if (event.key === "Tab" && value.trim()) {
356
+ const trimmed = value.trim()
357
+ if (addEmail(trimmed)) {
358
+ committedByKeyRef.current = trimmed.toLowerCase()
359
+ }
360
+ return
361
+ }
395
362
  if (event.key === "Backspace" && value === "" && recipients.length > 0) {
396
363
  event.preventDefault()
397
364
  onRecipientsChange(recipients.slice(0, -1))
@@ -452,9 +419,14 @@ export function EmailRecipientField({
452
419
  onChange={(event) => setValue(event.target.value)}
453
420
  onKeyDown={handleKeyDown}
454
421
  onBlur={() => {
422
+ const trimmed = value.trim().toLowerCase()
423
+ if (trimmed && committedByKeyRef.current === trimmed) {
424
+ committedByKeyRef.current = null
425
+ return
426
+ }
455
427
  // Commit any valid pending email so it is not silently dropped
456
428
  // when the user clicks Send without pressing Enter/comma first.
457
- if (isValidEmail(value)) addEmail(value)
429
+ addEmail(value)
458
430
  }}
459
431
  placeholder={resolvedPlaceholder}
460
432
  className="min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
@@ -464,16 +436,39 @@ export function EmailRecipientField({
464
436
  {showPicker || showCcBcc ? (
465
437
  <div className="flex gap-1.5 mt-2">
466
438
  {showPicker ? (
467
- <button
468
- ref={contactsTriggerRef}
469
- type="button"
470
- onClick={() => setPickerOpen((open) => !open)}
471
- 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]"
472
- >
473
- <Users className="size-3" />
474
- Contacts
475
- <ChevronDown className="size-3" />
476
- </button>
439
+ <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>
440
+ <PopoverPrimitive.Trigger asChild>
441
+ <button
442
+ type="button"
443
+ 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]"
444
+ >
445
+ <Users className="size-3" />
446
+ Contacts
447
+ <ChevronDown className="size-3" />
448
+ </button>
449
+ </PopoverPrimitive.Trigger>
450
+ <PopoverPrimitive.Portal>
451
+ <PopoverPrimitive.Content
452
+ side="bottom"
453
+ align="start"
454
+ sideOffset={4}
455
+ collisionPadding={16}
456
+ className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
457
+ >
458
+ <ContactPickerContents
459
+ contacts={contacts}
460
+ addedEmails={added}
461
+ onSelect={selectContact}
462
+ onAddEmail={(email) => {
463
+ addEmail(email)
464
+ setPickerOpen(false)
465
+ }}
466
+ onSearch={onSearch}
467
+ searchLoading={searchLoading}
468
+ />
469
+ </PopoverPrimitive.Content>
470
+ </PopoverPrimitive.Portal>
471
+ </PopoverPrimitive.Root>
477
472
  ) : null}
478
473
  {showCcBcc ? (
479
474
  <button
@@ -487,22 +482,6 @@ export function EmailRecipientField({
487
482
  ) : null}
488
483
  </div>
489
484
  ) : null}
490
-
491
- {pickerOpen ? (
492
- <ContactPickerPopover
493
- triggerRef={contactsTriggerRef}
494
- contacts={contacts}
495
- addedEmails={added}
496
- onSelect={selectContact}
497
- onAddEmail={(email) => {
498
- addEmail(email)
499
- setPickerOpen(false)
500
- }}
501
- onClose={() => setPickerOpen(false)}
502
- onSearch={onSearch}
503
- searchLoading={searchLoading}
504
- />
505
- ) : null}
506
485
  </div>
507
486
  </div>
508
487
  )