@echothink-ui/inbox 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AgentDraftReviewPanel.d.ts +18 -0
  3. package/dist/components/ApprovalToSendPanel.d.ts +11 -0
  4. package/dist/components/AttachmentList.d.ts +8 -0
  5. package/dist/components/AttachmentPreview.d.ts +7 -0
  6. package/dist/components/EmailAutomationRulePanel.d.ts +8 -0
  7. package/dist/components/InboxShell.d.ts +10 -0
  8. package/dist/components/LabelManager.d.ts +9 -0
  9. package/dist/components/MailboxFolderList.d.ts +8 -0
  10. package/dist/components/MessageComposer.d.ts +13 -0
  11. package/dist/components/MessageFilterBar.d.ts +13 -0
  12. package/dist/components/MessageList.d.ts +8 -0
  13. package/dist/components/MessagePreview.d.ts +9 -0
  14. package/dist/components/MessageSearch.d.ts +11 -0
  15. package/dist/components/MessageThread.d.ts +22 -0
  16. package/dist/components/PriorityInboxView.d.ts +14 -0
  17. package/dist/components/RecipientPicker.d.ts +10 -0
  18. package/dist/components/ThreadSummaryPanel.d.ts +9 -0
  19. package/dist/index.cjs +1699 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.css +2155 -0
  22. package/dist/index.css.map +1 -0
  23. package/dist/index.d.ts +21 -0
  24. package/dist/index.js +1645 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/types.d.ts +75 -0
  27. package/dist/utils.d.ts +5 -0
  28. package/package.json +44 -0
  29. package/src/components/AgentDraftReviewPanel.tsx +128 -0
  30. package/src/components/ApprovalToSendPanel.tsx +118 -0
  31. package/src/components/AttachmentList.tsx +85 -0
  32. package/src/components/AttachmentPreview.tsx +207 -0
  33. package/src/components/EmailAutomationRulePanel.tsx +100 -0
  34. package/src/components/InboxShell.tsx +62 -0
  35. package/src/components/LabelManager.tsx +147 -0
  36. package/src/components/MailboxFolderList.tsx +66 -0
  37. package/src/components/MessageComposer.tsx +160 -0
  38. package/src/components/MessageFilterBar.test.tsx +34 -0
  39. package/src/components/MessageFilterBar.tsx +69 -0
  40. package/src/components/MessageList.tsx +101 -0
  41. package/src/components/MessagePreview.test.tsx +48 -0
  42. package/src/components/MessagePreview.tsx +84 -0
  43. package/src/components/MessageSearch.tsx +96 -0
  44. package/src/components/MessageThread.tsx +173 -0
  45. package/src/components/PriorityInboxView.tsx +74 -0
  46. package/src/components/RecipientPicker.tsx +181 -0
  47. package/src/components/ThreadSummaryPanel.tsx +107 -0
  48. package/src/index.test.tsx +276 -0
  49. package/src/index.tsx +60 -0
  50. package/src/styles.css +2523 -0
  51. package/src/types.ts +85 -0
  52. package/src/utils.ts +33 -0
@@ -0,0 +1,84 @@
1
+ import * as React from "react";
2
+ import type { Attachment, ThreadMessage } from "../types";
3
+ import { formatDateTime } from "../utils";
4
+ import { AttachmentList } from "./AttachmentList";
5
+
6
+ export interface MessagePreviewProps extends React.HTMLAttributes<HTMLElement> {
7
+ message: ThreadMessage;
8
+ showSubject?: boolean;
9
+ onPreviewAttachment?: (attachment: Attachment) => void;
10
+ onDownloadAttachment?: (attachment: Attachment) => void;
11
+ }
12
+
13
+ export function MessagePreview({
14
+ message,
15
+ showSubject = true,
16
+ onPreviewAttachment,
17
+ onDownloadAttachment,
18
+ className,
19
+ "aria-label": ariaLabel,
20
+ "aria-labelledby": ariaLabelledBy,
21
+ ...props
22
+ }: MessagePreviewProps) {
23
+ const subjectId = React.useId();
24
+ const attachmentsHeadingId = React.useId();
25
+ const visibleSubject = showSubject ? message.subject : undefined;
26
+ const labelledBy = ariaLabel
27
+ ? ariaLabelledBy
28
+ : ariaLabelledBy ?? (visibleSubject ? subjectId : undefined);
29
+ const computedAriaLabel = ariaLabel ?? (labelledBy ? undefined : "Message preview");
30
+ const hasAttachments = Boolean(message.attachments?.length);
31
+
32
+ return (
33
+ <article
34
+ {...props}
35
+ className={["eth-inbox-message-preview", className].filter(Boolean).join(" ")}
36
+ data-eth-component="MessagePreview"
37
+ aria-label={computedAriaLabel}
38
+ aria-labelledby={labelledBy}
39
+ >
40
+ <header className="eth-inbox-message-preview__header">
41
+ <div className="eth-inbox-message-preview__heading">
42
+ {visibleSubject ? <h3 id={subjectId}>{visibleSubject}</h3> : null}
43
+ <dl className="eth-inbox-message-preview__recipients" aria-label="Message routing">
44
+ <div>
45
+ <dt>From</dt>
46
+ <dd>{message.from}</dd>
47
+ </div>
48
+ <div>
49
+ <dt>To</dt>
50
+ <dd>{formatRecipients(message.to)}</dd>
51
+ </div>
52
+ {message.cc?.length ? (
53
+ <div>
54
+ <dt>Cc</dt>
55
+ <dd>{formatRecipients(message.cc)}</dd>
56
+ </div>
57
+ ) : null}
58
+ </dl>
59
+ </div>
60
+ <time dateTime={message.date}>{formatDateTime(message.date)}</time>
61
+ </header>
62
+ <div className="eth-inbox-message-preview__body">
63
+ <p>{message.body}</p>
64
+ </div>
65
+ {hasAttachments ? (
66
+ <section
67
+ className="eth-inbox-message-preview__attachments"
68
+ aria-labelledby={attachmentsHeadingId}
69
+ >
70
+ <h4 id={attachmentsHeadingId}>Attachments</h4>
71
+ <AttachmentList
72
+ attachments={message.attachments}
73
+ onPreview={onPreviewAttachment}
74
+ onDownload={onDownloadAttachment}
75
+ />
76
+ </section>
77
+ ) : null}
78
+ </article>
79
+ );
80
+ }
81
+
82
+ function formatRecipients(recipients: string[]) {
83
+ return recipients.length ? recipients.join(", ") : "No recipients";
84
+ }
@@ -0,0 +1,96 @@
1
+ import * as React from "react";
2
+ import { IconButton, SearchInput } from "@echothink-ui/core";
3
+ import { ChevronRightIcon } from "@echothink-ui/icons";
4
+
5
+ export interface MessageSearchProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
6
+ value?: string;
7
+ onChange?: (value: string) => void;
8
+ placeholder?: string;
9
+ resultCount?: number;
10
+ activeResultIndex?: number;
11
+ onPreviousResult?: () => void;
12
+ onNextResult?: () => void;
13
+ }
14
+
15
+ export function MessageSearch({
16
+ value = "",
17
+ onChange,
18
+ placeholder = "Search messages",
19
+ resultCount,
20
+ activeResultIndex = 0,
21
+ onPreviousResult,
22
+ onNextResult,
23
+ className,
24
+ role,
25
+ "aria-label": ariaLabel,
26
+ ...props
27
+ }: MessageSearchProps) {
28
+ const hasResultCount = typeof resultCount === "number";
29
+ const normalizedResultCount =
30
+ hasResultCount && Number.isFinite(resultCount) ? Math.max(0, Math.trunc(resultCount)) : 0;
31
+ const normalizedResultIndex = Number.isFinite(activeResultIndex)
32
+ ? Math.trunc(activeResultIndex)
33
+ : 0;
34
+ const hasResults = normalizedResultCount > 0;
35
+ const currentResult = hasResults
36
+ ? Math.min(Math.max(normalizedResultIndex, 0), normalizedResultCount - 1) + 1
37
+ : 0;
38
+ const canGoPrevious = hasResults && currentResult > 1 && Boolean(onPreviousResult);
39
+ const canGoNext = hasResults && currentResult < normalizedResultCount && Boolean(onNextResult);
40
+ const resultNoun = normalizedResultCount === 1 ? "result" : "results";
41
+ const resultLabel = hasResults
42
+ ? `${currentResult} of ${normalizedResultCount} ${resultNoun}`
43
+ : "No results";
44
+
45
+ return (
46
+ <div
47
+ {...props}
48
+ className={["eth-inbox-search", className].filter(Boolean).join(" ")}
49
+ data-eth-component="MessageSearch"
50
+ role={role ?? "search"}
51
+ aria-label={ariaLabel ?? "Message search"}
52
+ >
53
+ <div className="eth-inbox-search__input">
54
+ <SearchInput
55
+ value={value}
56
+ placeholder={placeholder}
57
+ labelText={placeholder}
58
+ onChange={(event) => onChange?.(event.currentTarget.value)}
59
+ onClear={() => onChange?.("")}
60
+ className="eth-inbox-search__control"
61
+ />
62
+ </div>
63
+ {hasResultCount ? (
64
+ <div className="eth-inbox-search__results">
65
+ <span className="eth-inbox-search__count" aria-live="polite">
66
+ {resultLabel}
67
+ </span>
68
+ <div
69
+ className="eth-inbox-search__nav"
70
+ role="group"
71
+ aria-label="Search result navigation"
72
+ >
73
+ <IconButton
74
+ className="eth-inbox-search__nav-button eth-inbox-search__nav-button--previous"
75
+ density="compact"
76
+ intent="ghost"
77
+ label="Previous result"
78
+ icon={<ChevronRightIcon size={16} />}
79
+ disabled={!canGoPrevious}
80
+ onClick={onPreviousResult}
81
+ />
82
+ <IconButton
83
+ className="eth-inbox-search__nav-button"
84
+ density="compact"
85
+ intent="ghost"
86
+ label="Next result"
87
+ icon={<ChevronRightIcon size={16} />}
88
+ disabled={!canGoNext}
89
+ onClick={onNextResult}
90
+ />
91
+ </div>
92
+ </div>
93
+ ) : null}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,173 @@
1
+ import * as React from "react";
2
+ import { Badge, Button, StatusDot } from "@echothink-ui/core";
3
+ import type { EthDensity, EthIntent, EthOperationalStatus } from "@echothink-ui/core";
4
+ import type { Attachment, MessageThreadModel, ThreadMessage } from "../types";
5
+ import { formatDateTime } from "../utils";
6
+ import { MessagePreview } from "./MessagePreview";
7
+
8
+ export interface MessageThreadAction {
9
+ id: string;
10
+ label: React.ReactNode;
11
+ icon?: React.ReactNode;
12
+ intent?: EthIntent;
13
+ density?: EthDensity;
14
+ disabled?: boolean;
15
+ onSelect?: () => void;
16
+ }
17
+
18
+ export interface MessageThreadProps extends React.HTMLAttributes<HTMLElement> {
19
+ thread?: MessageThreadModel;
20
+ summaryRef?: React.ReactNode;
21
+ status?: EthOperationalStatus;
22
+ statusLabel?: React.ReactNode;
23
+ actions?: MessageThreadAction[];
24
+ onPreviewAttachment?: (attachment: Attachment) => void;
25
+ onDownloadAttachment?: (attachment: Attachment) => void;
26
+ }
27
+
28
+ export function MessageThread({
29
+ thread,
30
+ summaryRef,
31
+ status,
32
+ statusLabel,
33
+ actions = [],
34
+ onPreviewAttachment,
35
+ onDownloadAttachment,
36
+ className,
37
+ ...props
38
+ }: MessageThreadProps) {
39
+ const messages = React.useMemo(
40
+ () =>
41
+ [...(thread?.messages ?? [])].sort(
42
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
43
+ ),
44
+ [thread?.messages]
45
+ );
46
+ const older = messages.slice(0, Math.max(0, messages.length - 3));
47
+ const recent = messages.slice(Math.max(0, messages.length - 3));
48
+ const participantCount = countParticipants(messages);
49
+ const attachmentCount = messages.reduce(
50
+ (count, message) => count + (message.attachments?.length ?? 0),
51
+ 0
52
+ );
53
+ const latestMessage = messages.at(-1);
54
+ const messageLabel = formatCount(messages.length, "message");
55
+ const participantLabel = formatCount(participantCount, "participant");
56
+ const attachmentLabel = formatCount(attachmentCount, "attachment");
57
+
58
+ return (
59
+ <article
60
+ {...props}
61
+ className={`eth-inbox-thread ${className ?? ""}`}
62
+ data-eth-component="MessageThread"
63
+ >
64
+ <header className="eth-inbox-thread__header">
65
+ <div className="eth-inbox-thread__heading">
66
+ <span className="eth-inbox-thread__eyebrow">Conversation thread</span>
67
+ <h2>{thread?.subject ?? "Thread"}</h2>
68
+ <ul className="eth-inbox-thread__meta" aria-label="Thread metadata">
69
+ <li>
70
+ <Badge severity="neutral">{messageLabel}</Badge>
71
+ </li>
72
+ <li>
73
+ <Badge severity="neutral">{participantLabel}</Badge>
74
+ </li>
75
+ {attachmentCount ? (
76
+ <li>
77
+ <Badge severity="info">{attachmentLabel}</Badge>
78
+ </li>
79
+ ) : null}
80
+ {status ? (
81
+ <li>
82
+ <StatusDot status={status} label={statusLabel ?? statusToLabel(status)} />
83
+ </li>
84
+ ) : null}
85
+ {latestMessage ? (
86
+ <li className="eth-inbox-thread__updated">
87
+ Last activity{" "}
88
+ <time dateTime={latestMessage.date}>{formatDateTime(latestMessage.date)}</time>
89
+ </li>
90
+ ) : null}
91
+ </ul>
92
+ </div>
93
+ {actions.length ? (
94
+ <div className="eth-inbox-thread__actions" role="group" aria-label="Thread actions">
95
+ {actions.map((action) => (
96
+ <Button
97
+ key={action.id}
98
+ intent={action.intent ?? "secondary"}
99
+ density={action.density ?? "compact"}
100
+ disabled={action.disabled}
101
+ icon={action.icon}
102
+ onClick={action.onSelect}
103
+ >
104
+ {action.label}
105
+ </Button>
106
+ ))}
107
+ </div>
108
+ ) : null}
109
+ </header>
110
+ {summaryRef ? (
111
+ <aside className="eth-inbox-thread__summary" aria-label="Thread summary">
112
+ <span className="eth-inbox-thread__summary-label">Thread summary</span>
113
+ <div className="eth-inbox-thread__summary-body">{summaryRef}</div>
114
+ </aside>
115
+ ) : null}
116
+ {!messages.length ? (
117
+ <div className="eth-inbox-thread__empty" role="status">
118
+ <strong>No messages in this thread</strong>
119
+ <span>Replies, summaries, and attachment activity will appear here.</span>
120
+ </div>
121
+ ) : null}
122
+ {older.length ? (
123
+ <details className="eth-inbox-thread__older">
124
+ <summary>{formatCount(older.length, "older message")}</summary>
125
+ {older.map((message) => (
126
+ <MessagePreview
127
+ key={message.id}
128
+ message={message}
129
+ showSubject={false}
130
+ onPreviewAttachment={onPreviewAttachment}
131
+ onDownloadAttachment={onDownloadAttachment}
132
+ />
133
+ ))}
134
+ </details>
135
+ ) : null}
136
+ <div className="eth-inbox-thread__messages">
137
+ {recent.map((message) => (
138
+ <MessagePreview
139
+ key={message.id}
140
+ message={message}
141
+ showSubject={false}
142
+ onPreviewAttachment={onPreviewAttachment}
143
+ onDownloadAttachment={onDownloadAttachment}
144
+ />
145
+ ))}
146
+ </div>
147
+ </article>
148
+ );
149
+ }
150
+
151
+ function countParticipants(messages: ThreadMessage[]) {
152
+ const participants = new Set<string>();
153
+
154
+ messages.forEach((message) => {
155
+ participants.add(message.from);
156
+ message.to.forEach((recipient) => participants.add(recipient));
157
+ message.cc?.forEach((recipient) => participants.add(recipient));
158
+ message.bcc?.forEach((recipient) => participants.add(recipient));
159
+ });
160
+
161
+ return participants.size;
162
+ }
163
+
164
+ function formatCount(value: number, singular: string) {
165
+ return `${value} ${value === 1 ? singular : `${singular}s`}`;
166
+ }
167
+
168
+ function statusToLabel(status: EthOperationalStatus) {
169
+ return status
170
+ .split("-")
171
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
172
+ .join(" ");
173
+ }
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+ import type { InboxThread } from "../types";
3
+ import { MessageList } from "./MessageList";
4
+
5
+ export interface PriorityInboxGroup {
6
+ id: string;
7
+ title: string;
8
+ description?: React.ReactNode;
9
+ threads: InboxThread[];
10
+ }
11
+
12
+ export interface PriorityInboxViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
13
+ groups?: PriorityInboxGroup[];
14
+ selectedThreadId?: string;
15
+ onSelect?: (threadId: string) => void;
16
+ }
17
+
18
+ export function PriorityInboxView({
19
+ groups = [],
20
+ selectedThreadId,
21
+ onSelect,
22
+ className,
23
+ ...props
24
+ }: PriorityInboxViewProps) {
25
+ const hasGroups = groups.length > 0;
26
+ const idBase = React.useId();
27
+
28
+ return (
29
+ <div
30
+ {...props}
31
+ className={`eth-inbox-priority ${className ?? ""}`}
32
+ data-eth-component="PriorityInboxView"
33
+ >
34
+ {hasGroups ? (
35
+ groups.map((group, index) => {
36
+ const headingId = `${idBase}-group-${index}-heading`;
37
+
38
+ return (
39
+ <section
40
+ key={group.id}
41
+ className="eth-inbox-priority__group"
42
+ aria-labelledby={headingId}
43
+ >
44
+ <header className="eth-inbox-priority__group-header">
45
+ <div className="eth-inbox-priority__heading">
46
+ <h3 id={headingId}>{group.title}</h3>
47
+ {group.description ? (
48
+ <p className="eth-inbox-priority__description">{group.description}</p>
49
+ ) : null}
50
+ </div>
51
+ <span className="eth-inbox-priority__count">
52
+ {formatThreadCount(group.threads.length)}
53
+ </span>
54
+ </header>
55
+ <MessageList
56
+ threads={group.threads}
57
+ selectedThreadId={selectedThreadId}
58
+ onSelect={onSelect}
59
+ />
60
+ </section>
61
+ );
62
+ })
63
+ ) : (
64
+ <div className="eth-inbox-priority__empty" role="status" aria-label="No priority threads">
65
+ No priority threads
66
+ </div>
67
+ )}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function formatThreadCount(count: number) {
73
+ return `${count} ${count === 1 ? "thread" : "threads"}`;
74
+ }
@@ -0,0 +1,181 @@
1
+ import * as React from "react";
2
+ import { Button, SearchInput, Tag, TextInput } from "@echothink-ui/core";
3
+ import type { RecipientSuggestion } from "../types";
4
+
5
+ export interface RecipientPickerProps
6
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
7
+ value?: string[];
8
+ onChange?: (value: string[]) => void;
9
+ onSearch?: (q: string) => void;
10
+ suggestions?: RecipientSuggestion[];
11
+ defaultSuggestionsOpen?: boolean;
12
+ }
13
+
14
+ export function RecipientPicker({
15
+ value = [],
16
+ onChange,
17
+ onSearch,
18
+ suggestions = [],
19
+ defaultSuggestionsOpen = false,
20
+ className,
21
+ ...props
22
+ }: RecipientPickerProps) {
23
+ const listboxId = React.useId().replace(/:/g, "");
24
+ const [query, setQuery] = React.useState("");
25
+ const [manualValue, setManualValue] = React.useState("");
26
+ const [suggestionsOpen, setSuggestionsOpen] = React.useState(defaultSuggestionsOpen);
27
+
28
+ const normalizedQuery = query.trim().toLocaleLowerCase();
29
+ const visibleSuggestions = normalizedQuery
30
+ ? suggestions.filter((suggestion) =>
31
+ [suggestion.label, suggestion.email].some((field) =>
32
+ field.toLocaleLowerCase().includes(normalizedQuery)
33
+ )
34
+ )
35
+ : suggestions;
36
+ const showSuggestions = suggestionsOpen && (visibleSuggestions.length > 0 || Boolean(query));
37
+
38
+ const add = (email: string) => {
39
+ const normalizedEmail = email.trim();
40
+
41
+ if (!normalizedEmail || value.includes(normalizedEmail)) return false;
42
+
43
+ onChange?.([...value, normalizedEmail]);
44
+ setManualValue("");
45
+ setQuery("");
46
+ return true;
47
+ };
48
+
49
+ const updateQuery = (nextQuery: string) => {
50
+ setQuery(nextQuery);
51
+ onSearch?.(nextQuery);
52
+ setSuggestionsOpen(Boolean(nextQuery || suggestions.length));
53
+ };
54
+
55
+ return (
56
+ <div
57
+ {...props}
58
+ className={`eth-inbox-recipient-picker ${className ?? ""}`}
59
+ data-eth-component="RecipientPicker"
60
+ onBlur={(event) => {
61
+ props.onBlur?.(event);
62
+
63
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
64
+ setSuggestionsOpen(false);
65
+ }
66
+ }}
67
+ onKeyDown={(event) => {
68
+ props.onKeyDown?.(event);
69
+
70
+ if (!event.defaultPrevented && event.key === "Escape") {
71
+ setSuggestionsOpen(false);
72
+ }
73
+ }}
74
+ >
75
+ <div className="eth-inbox-recipient-picker__selected" aria-label="Selected recipients">
76
+ <span className="eth-inbox-recipient-picker__label">To</span>
77
+ <div className="eth-inbox-recipient-picker__chips">
78
+ {value.length ? (
79
+ value.map((email) => (
80
+ <Tag
81
+ key={email}
82
+ removable
83
+ onRemove={() => onChange?.(value.filter((item) => item !== email))}
84
+ >
85
+ {email}
86
+ </Tag>
87
+ ))
88
+ ) : (
89
+ <span className="eth-inbox-recipient-picker__placeholder">No recipients selected</span>
90
+ )}
91
+ </div>
92
+ </div>
93
+ <SearchInput
94
+ aria-controls={showSuggestions ? listboxId : undefined}
95
+ aria-expanded={showSuggestions}
96
+ aria-haspopup="listbox"
97
+ className="eth-inbox-recipient-picker__search"
98
+ labelText="Search recipients"
99
+ value={query}
100
+ placeholder="Search recipients"
101
+ onFocus={() => setSuggestionsOpen(Boolean(suggestions.length))}
102
+ onChange={(event) => updateQuery(event.currentTarget.value)}
103
+ onClear={() => {
104
+ updateQuery("");
105
+ setSuggestionsOpen(false);
106
+ }}
107
+ />
108
+ <div className="eth-inbox-recipient-picker__manual">
109
+ <TextInput
110
+ className="eth-inbox-recipient-picker__manual-input"
111
+ density="compact"
112
+ hideLabel
113
+ labelText="Recipient email"
114
+ type="email"
115
+ value={manualValue}
116
+ placeholder="name@company.com"
117
+ onChange={(event) => setManualValue(event.currentTarget.value)}
118
+ onKeyDown={(event) => {
119
+ if (event.key === "Enter") {
120
+ event.preventDefault();
121
+ add(manualValue);
122
+ }
123
+ }}
124
+ />
125
+ <Button
126
+ density="compact"
127
+ disabled={!manualValue.trim()}
128
+ intent="primary"
129
+ onClick={() => add(manualValue)}
130
+ >
131
+ Add recipient
132
+ </Button>
133
+ </div>
134
+ {showSuggestions ? (
135
+ <div
136
+ className="eth-inbox-recipient-picker__suggestions"
137
+ id={listboxId}
138
+ role="listbox"
139
+ aria-label="Recipient suggestions"
140
+ aria-multiselectable
141
+ >
142
+ <div className="eth-inbox-recipient-picker__suggestions-header">
143
+ <span>Suggested recipients</span>
144
+ <strong>
145
+ {visibleSuggestions.length} result{visibleSuggestions.length === 1 ? "" : "s"}
146
+ </strong>
147
+ </div>
148
+ {visibleSuggestions.length ? (
149
+ <ul className="eth-inbox-recipient-picker__suggestions-list">
150
+ {visibleSuggestions.map((suggestion) => {
151
+ const isSelected = value.includes(suggestion.email);
152
+
153
+ return (
154
+ <li key={suggestion.id}>
155
+ <button
156
+ type="button"
157
+ role="option"
158
+ aria-selected={isSelected}
159
+ aria-disabled={isSelected ? true : undefined}
160
+ onClick={() => add(suggestion.email)}
161
+ >
162
+ <span className="eth-inbox-recipient-picker__suggestion-copy">
163
+ <strong>{suggestion.label}</strong>
164
+ <span>{suggestion.email}</span>
165
+ </span>
166
+ <span className="eth-inbox-recipient-picker__suggestion-status">
167
+ {isSelected ? "Selected" : "Add"}
168
+ </span>
169
+ </button>
170
+ </li>
171
+ );
172
+ })}
173
+ </ul>
174
+ ) : (
175
+ <p className="eth-inbox-recipient-picker__empty">No matching recipients</p>
176
+ )}
177
+ </div>
178
+ ) : null}
179
+ </div>
180
+ );
181
+ }