@echothink-ui/identity 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 (48) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AccessReviewPanel.d.ts +9 -0
  3. package/dist/components/AccountMenu.d.ts +8 -0
  4. package/dist/components/ApprovalPolicyEditor.d.ts +8 -0
  5. package/dist/components/AuditTrail.d.ts +6 -0
  6. package/dist/components/GroupPicker.d.ts +12 -0
  7. package/dist/components/IdentityCard.d.ts +14 -0
  8. package/dist/components/InviteUserPanel.d.ts +14 -0
  9. package/dist/components/OrganizationSwitcher.d.ts +9 -0
  10. package/dist/components/PermissionMatrix.d.ts +10 -0
  11. package/dist/components/PolicyRuleViewer.d.ts +6 -0
  12. package/dist/components/RoleBadge.d.ts +6 -0
  13. package/dist/components/SessionStatus.d.ts +9 -0
  14. package/dist/components/TeamList.d.ts +13 -0
  15. package/dist/components/UserPicker.d.ts +12 -0
  16. package/dist/components/types.d.ts +77 -0
  17. package/dist/index.cjs +1629 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.css +2238 -0
  20. package/dist/index.css.map +1 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +1610 -0
  23. package/dist/index.js.map +1 -0
  24. package/package.json +43 -0
  25. package/src/components/AccessReviewPanel.tsx +169 -0
  26. package/src/components/AccountMenu.tsx +144 -0
  27. package/src/components/ApprovalPolicyEditor.tsx +131 -0
  28. package/src/components/AuditTrail.tsx +105 -0
  29. package/src/components/GroupPicker.tsx +175 -0
  30. package/src/components/IdentityCard.tsx +78 -0
  31. package/src/components/InviteUserPanel.tsx +162 -0
  32. package/src/components/OrganizationSwitcher.test.tsx +59 -0
  33. package/src/components/OrganizationSwitcher.tsx +161 -0
  34. package/src/components/PermissionMatrix.test.tsx +96 -0
  35. package/src/components/PermissionMatrix.tsx +271 -0
  36. package/src/components/PolicyRuleViewer.test.tsx +29 -0
  37. package/src/components/PolicyRuleViewer.tsx +78 -0
  38. package/src/components/RoleBadge.test.tsx +35 -0
  39. package/src/components/RoleBadge.tsx +38 -0
  40. package/src/components/SessionStatus.test.tsx +40 -0
  41. package/src/components/SessionStatus.tsx +194 -0
  42. package/src/components/TeamList.test.tsx +48 -0
  43. package/src/components/TeamList.tsx +98 -0
  44. package/src/components/UserPicker.test.tsx +52 -0
  45. package/src/components/UserPicker.tsx +174 -0
  46. package/src/components/types.ts +89 -0
  47. package/src/index.tsx +35 -0
  48. package/src/styles.css +2578 -0
@@ -0,0 +1,78 @@
1
+ import { Badge, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import type { PolicyRule } from "./types";
3
+
4
+ export interface PolicyRuleViewerProps extends Omit<SurfaceComponentProps, "children"> {
5
+ rule: PolicyRule;
6
+ }
7
+
8
+ function effectLabel(effect: PolicyRule["effect"]) {
9
+ return effect === "allow" ? "Allow" : "Deny";
10
+ }
11
+
12
+ function renderRuleValues(values: string[], label: string) {
13
+ if (!values.length) {
14
+ return <span className="eth-identity-policy-rule-viewer__empty">Not specified</span>;
15
+ }
16
+
17
+ return (
18
+ <ul className="eth-identity-policy-rule-viewer__values" aria-label={`${label} list`}>
19
+ {values.map((value) => (
20
+ <li key={value}>
21
+ <code>{value}</code>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ );
26
+ }
27
+
28
+ export function PolicyRuleViewer({ rule, title, className, ...props }: PolicyRuleViewerProps) {
29
+ const effect = effectLabel(rule.effect);
30
+
31
+ return (
32
+ <Surface
33
+ {...props}
34
+ title={title ?? `Policy rule ${rule.id}`}
35
+ className={["eth-identity-policy-rule-viewer", className].filter(Boolean).join(" ")}
36
+ data-eth-component="PolicyRuleViewer"
37
+ >
38
+ <dl className="eth-identity-policy-rule-viewer__facet-list">
39
+ <div
40
+ className="eth-identity-policy-rule-viewer__facet eth-identity-policy-rule-viewer__facet--effect"
41
+ >
42
+ <dt>Effect</dt>
43
+ <dd>
44
+ <Badge
45
+ aria-label={`Rule effect: ${effect}`}
46
+ className="eth-identity-policy-rule-viewer__effect"
47
+ severity={rule.effect === "allow" ? "success" : "danger"}
48
+ >
49
+ {effect}
50
+ </Badge>
51
+ </dd>
52
+ </div>
53
+ <div>
54
+ <dt>Subjects</dt>
55
+ <dd>{renderRuleValues(rule.subjects, "Subjects")}</dd>
56
+ </div>
57
+ <div>
58
+ <dt>Actions</dt>
59
+ <dd>{renderRuleValues(rule.actions, "Actions")}</dd>
60
+ </div>
61
+ <div>
62
+ <dt>Resources</dt>
63
+ <dd>{renderRuleValues(rule.resources, "Resources")}</dd>
64
+ </div>
65
+ {rule.conditions ? (
66
+ <div>
67
+ <dt>Conditions</dt>
68
+ <dd>
69
+ <code className="eth-identity-policy-rule-viewer__condition">
70
+ {rule.conditions}
71
+ </code>
72
+ </dd>
73
+ </div>
74
+ ) : null}
75
+ </dl>
76
+ </Surface>
77
+ );
78
+ }
@@ -0,0 +1,35 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { RoleBadge } from "./RoleBadge";
4
+
5
+ describe("RoleBadge", () => {
6
+ it("renders role indicators with semantic attributes and caller props", () => {
7
+ render(
8
+ <RoleBadge
9
+ role="workspace_admin"
10
+ tier="admin"
11
+ id="workspace-admin-role"
12
+ aria-label="Privileged workspace administrator role"
13
+ className="custom-role"
14
+ />
15
+ );
16
+
17
+ const badge = screen
18
+ .getByLabelText("Privileged workspace administrator role")
19
+ .closest(".eth-identity-role-badge");
20
+
21
+ expect(badge?.getAttribute("id")).toBe("workspace-admin-role");
22
+ expect(badge?.getAttribute("data-eth-component")).toBe("RoleBadge");
23
+ expect(badge?.getAttribute("data-tier")).toBe("admin");
24
+ expect(badge?.className).toContain("custom-role");
25
+ expect(screen.getByText("workspace_admin").closest(".eth-badge")?.className).toContain(
26
+ "eth-badge--danger"
27
+ );
28
+ });
29
+
30
+ it("provides a default accessible label for the role", () => {
31
+ render(<RoleBadge role="read_only_viewer" tier="viewer" />);
32
+
33
+ expect(screen.getByLabelText("Role: read_only_viewer")).toBeTruthy();
34
+ });
35
+ });
@@ -0,0 +1,38 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { Badge, type EthSeverity } from "@echothink-ui/core";
3
+
4
+ export interface RoleBadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children" | "role"> {
5
+ role: string;
6
+ tier?: "admin" | "editor" | "viewer" | "custom";
7
+ }
8
+
9
+ export function RoleBadge({
10
+ role,
11
+ tier = "custom",
12
+ className,
13
+ "aria-label": ariaLabel,
14
+ "aria-labelledby": ariaLabelledBy,
15
+ title,
16
+ ...props
17
+ }: RoleBadgeProps) {
18
+ return (
19
+ <span
20
+ {...props}
21
+ aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : `Role: ${role}`)}
22
+ aria-labelledby={ariaLabelledBy}
23
+ className={["eth-identity-role-badge", className].filter(Boolean).join(" ")}
24
+ data-eth-component="RoleBadge"
25
+ data-tier={tier}
26
+ title={title ?? role}
27
+ >
28
+ <Badge severity={severityForTier(tier)}>{role}</Badge>
29
+ </span>
30
+ );
31
+ }
32
+
33
+ function severityForTier(tier: NonNullable<RoleBadgeProps["tier"]>): EthSeverity {
34
+ if (tier === "admin") return "danger";
35
+ if (tier === "editor") return "warning";
36
+ if (tier === "viewer") return "info";
37
+ return "neutral";
38
+ }
@@ -0,0 +1,40 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SessionStatus } from "./SessionStatus";
4
+
5
+ describe("SessionStatus", () => {
6
+ it("renders a security summary with a semantic expiration time", () => {
7
+ render(
8
+ <SessionStatus
9
+ session={{
10
+ expiresAt: "2999-05-27T18:00:00Z",
11
+ mfaEnabled: true,
12
+ lastIp: "10.0.4.12"
13
+ }}
14
+ />
15
+ );
16
+
17
+ expect(screen.getByRole("heading", { name: "Session security" })).toBeTruthy();
18
+ expect(screen.getByText("Session active")).toBeTruthy();
19
+
20
+ const expiration = screen.getByLabelText("Session expiration");
21
+ expect(expiration.tagName).toBe("TIME");
22
+ expect(expiration.getAttribute("dateTime")).toBe("2999-05-27T18:00:00Z");
23
+ expect(screen.getByText("10.0.4.12").closest("code")).toBeTruthy();
24
+ });
25
+
26
+ it("surfaces disabled MFA and missing last IP as reviewable security state", () => {
27
+ render(
28
+ <SessionStatus
29
+ session={{
30
+ expiresAt: "2999-05-27T18:00:00Z",
31
+ mfaEnabled: false
32
+ }}
33
+ />
34
+ );
35
+
36
+ expect(screen.getByText("Review required")).toBeTruthy();
37
+ expect(screen.getByText("No second factor is enforced for this session.")).toBeTruthy();
38
+ expect(screen.getByText("Not recorded")).toBeTruthy();
39
+ });
40
+ });
@@ -0,0 +1,194 @@
1
+ import {
2
+ Badge,
3
+ Surface,
4
+ type EthOperationalStatus,
5
+ type EthSeverity,
6
+ type SurfaceComponentProps
7
+ } from "@echothink-ui/core";
8
+
9
+ const EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export interface SessionStatusProps extends Omit<SurfaceComponentProps, "children"> {
12
+ session: {
13
+ expiresAt: string;
14
+ mfaEnabled: boolean;
15
+ lastIp?: string;
16
+ };
17
+ }
18
+
19
+ export function SessionStatus({
20
+ session,
21
+ title,
22
+ description,
23
+ className,
24
+ severity,
25
+ status,
26
+ ...props
27
+ }: SessionStatusProps) {
28
+ const expires = new Date(session.expiresAt);
29
+ const hasValidExpiration = Number.isFinite(expires.valueOf());
30
+ const expiresAt = hasValidExpiration ? expires.getTime() : null;
31
+ const now = Date.now();
32
+ const expired = expiresAt !== null && expiresAt < now;
33
+ const expiringSoon = expiresAt !== null && !expired && expiresAt - now <= EXPIRING_SOON_MS;
34
+ const needsReview = !session.mfaEnabled || expiringSoon || !hasValidExpiration;
35
+ const tone: EthSeverity = expired ? "danger" : needsReview ? "warning" : "success";
36
+ const resolvedStatus: EthOperationalStatus =
37
+ status ?? (expired ? "stale" : needsReview ? "warning" : "active");
38
+ const sessionState = expired ? "Expired" : expiringSoon ? "Expiring soon" : "Active";
39
+ const summaryTitle = summaryTitleForSession({ expired, needsReview });
40
+ const summaryDetail = summaryForSession({
41
+ expired,
42
+ expiringSoon,
43
+ hasValidExpiration,
44
+ mfaEnabled: session.mfaEnabled
45
+ });
46
+ const summaryClassName = [
47
+ "eth-identity-session-status__summary",
48
+ `eth-identity-session-status__summary--${tone}`
49
+ ].join(" ");
50
+
51
+ return (
52
+ <Surface
53
+ {...props}
54
+ title={title === undefined ? "Session security" : title}
55
+ description={
56
+ description === undefined
57
+ ? "Authentication freshness and recent access signal."
58
+ : description
59
+ }
60
+ severity={severity}
61
+ status={resolvedStatus}
62
+ className={["eth-identity-session-status", className].filter(Boolean).join(" ")}
63
+ data-eth-component="SessionStatus"
64
+ >
65
+ <div className={summaryClassName}>
66
+ <span className="eth-identity-session-status__summary-mark" aria-hidden="true" />
67
+ <div className="eth-identity-session-status__summary-copy">
68
+ <strong>{summaryTitle}</strong>
69
+ <span>{summaryDetail}</span>
70
+ </div>
71
+ </div>
72
+ <dl aria-label="Session details">
73
+ <div>
74
+ <dt>Session</dt>
75
+ <dd className="eth-identity-session-status__value">
76
+ <span className="eth-identity-session-status__value-main">
77
+ <Badge severity={tone}>{sessionState}</Badge>
78
+ </span>
79
+ <span className="eth-identity-session-status__detail">
80
+ {expired ? "Credentials are no longer valid." : "Authenticated session lifecycle."}
81
+ </span>
82
+ </dd>
83
+ </div>
84
+ <div>
85
+ <dt>Expires</dt>
86
+ <dd className="eth-identity-session-status__value">
87
+ <span className="eth-identity-session-status__value-main">
88
+ {hasValidExpiration ? (
89
+ <time
90
+ aria-label="Session expiration"
91
+ dateTime={session.expiresAt}
92
+ title={session.expiresAt}
93
+ >
94
+ {formatSessionDate(expires)}
95
+ </time>
96
+ ) : (
97
+ <span>Unknown</span>
98
+ )}
99
+ </span>
100
+ <span className="eth-identity-session-status__detail">
101
+ {expirationDetail({ expired, expiringSoon, hasValidExpiration })}
102
+ </span>
103
+ </dd>
104
+ </div>
105
+ <div>
106
+ <dt>MFA</dt>
107
+ <dd className="eth-identity-session-status__value">
108
+ <span className="eth-identity-session-status__value-main">
109
+ <Badge severity={session.mfaEnabled ? "success" : "warning"}>
110
+ {session.mfaEnabled ? "Enabled" : "Disabled"}
111
+ </Badge>
112
+ </span>
113
+ <span className="eth-identity-session-status__detail">
114
+ {session.mfaEnabled
115
+ ? "Second factor challenge is enforced."
116
+ : "No second factor is enforced for this session."}
117
+ </span>
118
+ </dd>
119
+ </div>
120
+ <div>
121
+ <dt>Last IP</dt>
122
+ <dd className="eth-identity-session-status__value">
123
+ <span className="eth-identity-session-status__value-main">
124
+ {session.lastIp ? (
125
+ <code className="eth-identity-session-status__code">{session.lastIp}</code>
126
+ ) : (
127
+ <span className="eth-identity-session-status__muted">Not recorded</span>
128
+ )}
129
+ </span>
130
+ <span className="eth-identity-session-status__detail">
131
+ Latest network signal captured for the session.
132
+ </span>
133
+ </dd>
134
+ </div>
135
+ </dl>
136
+ </Surface>
137
+ );
138
+ }
139
+
140
+ function formatSessionDate(value: Date) {
141
+ return new Intl.DateTimeFormat(undefined, {
142
+ day: "2-digit",
143
+ hour: "2-digit",
144
+ minute: "2-digit",
145
+ month: "short",
146
+ timeZoneName: "short",
147
+ year: "numeric"
148
+ }).format(value);
149
+ }
150
+
151
+ function summaryTitleForSession({
152
+ expired,
153
+ needsReview
154
+ }: {
155
+ expired: boolean;
156
+ needsReview: boolean;
157
+ }) {
158
+ if (expired) return "Session expired";
159
+ if (needsReview) return "Review required";
160
+ return "Session active";
161
+ }
162
+
163
+ function summaryForSession({
164
+ expired,
165
+ expiringSoon,
166
+ hasValidExpiration,
167
+ mfaEnabled
168
+ }: {
169
+ expired: boolean;
170
+ expiringSoon: boolean;
171
+ hasValidExpiration: boolean;
172
+ mfaEnabled: boolean;
173
+ }) {
174
+ if (!hasValidExpiration) return "Expiration could not be verified from the provided timestamp.";
175
+ if (expired) return "Access should be refreshed or revoked before more work continues.";
176
+ if (!mfaEnabled) return "MFA is disabled, so this session needs security review.";
177
+ if (expiringSoon) return "The session is valid but expires within the next 24 hours.";
178
+ return "MFA is enabled and the session remains within its validity window.";
179
+ }
180
+
181
+ function expirationDetail({
182
+ expired,
183
+ expiringSoon,
184
+ hasValidExpiration
185
+ }: {
186
+ expired: boolean;
187
+ expiringSoon: boolean;
188
+ hasValidExpiration: boolean;
189
+ }) {
190
+ if (!hasValidExpiration) return "Timestamp could not be parsed.";
191
+ if (expired) return "Past the identity provider expiry.";
192
+ if (expiringSoon) return "Expires within 24 hours.";
193
+ return "Valid until scheduled expiry.";
194
+ }
@@ -0,0 +1,48 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { TeamList } from "./TeamList";
4
+
5
+ const teams = [
6
+ {
7
+ id: "marketing",
8
+ label: "Marketing",
9
+ memberCount: 12,
10
+ lead: { id: "jd", label: "Jane Doe" }
11
+ },
12
+ {
13
+ id: "legal",
14
+ label: "Legal",
15
+ memberCount: 4,
16
+ lead: { id: "pr", label: "Pat R." }
17
+ }
18
+ ];
19
+
20
+ describe("TeamList", () => {
21
+ it("renders teams as an accessible list with contextual open actions", () => {
22
+ const onSelect = vi.fn();
23
+
24
+ render(<TeamList title="Workspace teams" teams={teams} onSelect={onSelect} />);
25
+
26
+ const list = screen.getByRole("list", { name: "Workspace teams" });
27
+ expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(2);
28
+ expect(screen.getByText("12 members").closest(".eth-badge")).toBeTruthy();
29
+ expect(screen.getByText("Jane Doe")).toBeTruthy();
30
+
31
+ fireEvent.click(screen.getByRole("button", { name: "Open Marketing team" }));
32
+
33
+ expect(onSelect).toHaveBeenCalledWith("marketing");
34
+ });
35
+
36
+ it("omits row actions when the list is read-only", () => {
37
+ render(<TeamList teams={teams} />);
38
+
39
+ expect(screen.queryByRole("button", { name: /open/i })).toBeNull();
40
+ });
41
+
42
+ it("renders an empty state for teams without results", () => {
43
+ render(<TeamList title="Workspace teams" teams={[]} />);
44
+
45
+ expect(screen.getByText("No teams available")).toBeTruthy();
46
+ expect(screen.getByText("Add teams to see membership summaries.")).toBeTruthy();
47
+ });
48
+ });
@@ -0,0 +1,98 @@
1
+ import { Badge, Button, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import type { IdentityRef } from "./types";
3
+
4
+ export interface TeamListTeam {
5
+ id: string;
6
+ label: string;
7
+ memberCount: number;
8
+ lead?: IdentityRef;
9
+ }
10
+
11
+ export interface TeamListProps extends Omit<SurfaceComponentProps, "children" | "onSelect"> {
12
+ teams: TeamListTeam[];
13
+ onSelect?: (id: string) => void;
14
+ }
15
+
16
+ export function TeamList({ teams, onSelect, title, className, ...props }: TeamListProps) {
17
+ const listLabel = typeof title === "string" && title.trim() ? title : "Teams";
18
+ const totalMembers = teams.reduce((total, team) => total + team.memberCount, 0);
19
+
20
+ return (
21
+ <Surface
22
+ {...props}
23
+ title={title}
24
+ className={["eth-identity-team-list", className].filter(Boolean).join(" ")}
25
+ data-eth-component="TeamList"
26
+ >
27
+ {teams.length ? (
28
+ <>
29
+ <dl className="eth-identity-team-list__summary" aria-label="Team summary">
30
+ <div>
31
+ <dt>Teams</dt>
32
+ <dd>{teams.length}</dd>
33
+ </div>
34
+ <div>
35
+ <dt>Members</dt>
36
+ <dd>{totalMembers}</dd>
37
+ </div>
38
+ </dl>
39
+ <div className="eth-identity-team-list__items" role="list" aria-label={listLabel}>
40
+ {teams.map((team) => (
41
+ <article key={team.id} className="eth-identity-team-list__item" role="listitem">
42
+ <span className="eth-identity-team-list__avatar" aria-hidden="true">
43
+ {initials(team.label)}
44
+ </span>
45
+ <div className="eth-identity-team-list__main">
46
+ <div className="eth-identity-team-list__heading">
47
+ <h3>{team.label}</h3>
48
+ <Badge severity="neutral">{formatMemberCount(team.memberCount)}</Badge>
49
+ </div>
50
+ <dl
51
+ className="eth-identity-team-list__meta"
52
+ aria-label={`${team.label} membership summary`}
53
+ >
54
+ <div>
55
+ <dt>Lead</dt>
56
+ <dd>{team.lead?.label ?? "Unassigned"}</dd>
57
+ </div>
58
+ </dl>
59
+ </div>
60
+ {onSelect ? (
61
+ <Button
62
+ type="button"
63
+ intent="secondary"
64
+ density="compact"
65
+ aria-label={`Open ${team.label} team`}
66
+ onClick={() => onSelect(team.id)}
67
+ >
68
+ Open
69
+ </Button>
70
+ ) : null}
71
+ </article>
72
+ ))}
73
+ </div>
74
+ </>
75
+ ) : (
76
+ <div className="eth-identity-team-list__empty" role="status">
77
+ <h3>No teams available</h3>
78
+ <p>Add teams to see membership summaries.</p>
79
+ </div>
80
+ )}
81
+ </Surface>
82
+ );
83
+ }
84
+
85
+ function formatMemberCount(count: number) {
86
+ return `${count} ${count === 1 ? "member" : "members"}`;
87
+ }
88
+
89
+ function initials(label: string) {
90
+ const value = label
91
+ .split(/\s+/)
92
+ .filter(Boolean)
93
+ .slice(0, 2)
94
+ .map((part) => part[0]?.toUpperCase())
95
+ .join("");
96
+
97
+ return value || "TM";
98
+ }
@@ -0,0 +1,52 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { UserPicker } from "./UserPicker";
4
+
5
+ const users = [
6
+ { id: "jd", label: "Jane Doe", email: "jane@echothink.example", kind: "user" as const },
7
+ { id: "mk", label: "Mark K.", email: "mark@echothink.example", kind: "user" as const }
8
+ ];
9
+
10
+ describe("@echothink-ui/identity UserPicker", () => {
11
+ it("renders selected users with accessible search suggestions", () => {
12
+ render(<UserPicker value={["jd"]} suggestions={users} defaultOpen multiple />);
13
+
14
+ expect(screen.getByLabelText("Selected users")).toBeTruthy();
15
+ expect(screen.getByPlaceholderText("Search users")).toBeTruthy();
16
+ expect(screen.getByRole("listbox", { name: "User suggestions" })).toBeTruthy();
17
+
18
+ const selected = screen.getByRole("option", {
19
+ name: /Jane Doe jane@echothink\.example/
20
+ });
21
+ const available = screen.getByRole("option", {
22
+ name: /Mark K\. mark@echothink\.example/
23
+ });
24
+
25
+ expect(selected.getAttribute("aria-selected")).toBe("true");
26
+ expect(available.getAttribute("aria-selected")).toBe("false");
27
+ });
28
+
29
+ it("filters suggestions and adds selected users", () => {
30
+ const onChange = vi.fn();
31
+ const onSearch = vi.fn();
32
+
33
+ render(
34
+ <UserPicker
35
+ value={["jd"]}
36
+ onChange={onChange}
37
+ onSearch={onSearch}
38
+ suggestions={users}
39
+ defaultOpen
40
+ multiple
41
+ />
42
+ );
43
+
44
+ fireEvent.change(screen.getByPlaceholderText("Search users"), {
45
+ target: { value: "mark" }
46
+ });
47
+ fireEvent.click(screen.getByRole("option", { name: /Mark K\. mark@echothink\.example/ }));
48
+
49
+ expect(onSearch).toHaveBeenCalledWith("mark");
50
+ expect(onChange).toHaveBeenCalledWith(["jd", "mk"]);
51
+ });
52
+ });