@handled-ai/design-system 0.18.53 → 0.19.0-rc.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 (39) hide show
  1. package/dist/components/case-panel-activity-timeline.d.ts +2 -0
  2. package/dist/components/case-panel-activity-timeline.js +22 -1
  3. package/dist/components/case-panel-activity-timeline.js.map +1 -1
  4. package/dist/components/comment-composer.d.ts +29 -0
  5. package/dist/components/comment-composer.js +102 -0
  6. package/dist/components/comment-composer.js.map +1 -0
  7. package/dist/components/conversation-panel.d.ts +95 -0
  8. package/dist/components/conversation-panel.js +636 -0
  9. package/dist/components/conversation-panel.js.map +1 -0
  10. package/dist/components/data-table-filter.d.ts +18 -1
  11. package/dist/components/data-table-filter.js +20 -6
  12. package/dist/components/data-table-filter.js.map +1 -1
  13. package/dist/components/owner-chips.d.ts +59 -0
  14. package/dist/components/owner-chips.js +256 -0
  15. package/dist/components/owner-chips.js.map +1 -0
  16. package/dist/components/timeline-activity.d.ts +7 -0
  17. package/dist/components/timeline-activity.js +22 -1
  18. package/dist/components/timeline-activity.js.map +1 -1
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/safe-html.d.ts +11 -0
  23. package/dist/internal/safe-html.js +222 -0
  24. package/dist/internal/safe-html.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/components/__tests__/comment-composer.test.tsx +57 -0
  27. package/src/components/__tests__/conversation-panel.test.tsx +157 -0
  28. package/src/components/__tests__/data-table-filter.test.tsx +72 -0
  29. package/src/components/__tests__/owner-chips.test.tsx +100 -0
  30. package/src/components/__tests__/timeline-activity.test.tsx +55 -0
  31. package/src/components/case-panel-activity-timeline.tsx +20 -0
  32. package/src/components/comment-composer.tsx +119 -0
  33. package/src/components/conversation-panel.tsx +790 -0
  34. package/src/components/data-table-filter.tsx +53 -10
  35. package/src/components/owner-chips.tsx +335 -0
  36. package/src/components/timeline-activity.tsx +37 -3
  37. package/src/index.ts +3 -0
  38. package/src/internal/__tests__/safe-html.test.ts +53 -0
  39. package/src/internal/safe-html.ts +284 -0
@@ -0,0 +1,222 @@
1
+ const DANGEROUS_BLOCK_TAGS = /* @__PURE__ */ new Set([
2
+ "script",
3
+ "style",
4
+ "iframe",
5
+ "object",
6
+ "embed",
7
+ "svg",
8
+ "math",
9
+ "template",
10
+ "noscript",
11
+ "textarea",
12
+ "select"
13
+ ]);
14
+ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
15
+ "a",
16
+ "b",
17
+ "blockquote",
18
+ "br",
19
+ "code",
20
+ "del",
21
+ "div",
22
+ "em",
23
+ "hr",
24
+ "i",
25
+ "img",
26
+ "li",
27
+ "ol",
28
+ "p",
29
+ "pre",
30
+ "s",
31
+ "span",
32
+ "strong",
33
+ "table",
34
+ "tbody",
35
+ "td",
36
+ "th",
37
+ "thead",
38
+ "tr",
39
+ "u",
40
+ "ul"
41
+ ]);
42
+ const VOID_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img"]);
43
+ const SAFE_GLOBAL_ATTRS = /* @__PURE__ */ new Set(["aria-label", "role", "title"]);
44
+ const SAFE_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
45
+ function escapeHtml(value) {
46
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
47
+ }
48
+ function escapeAttribute(value) {
49
+ return escapeHtml(value).replace(/"/g, "&quot;");
50
+ }
51
+ function safeCodePoint(value) {
52
+ return Number.isInteger(value) && value >= 0 && value <= 1114111 ? String.fromCodePoint(value) : "";
53
+ }
54
+ function decodeHtmlEntities(value) {
55
+ const namedEntities = {
56
+ amp: "&",
57
+ apos: "'",
58
+ colon: ":",
59
+ gt: ">",
60
+ lt: "<",
61
+ newline: "\n",
62
+ quot: '"',
63
+ tab: " "
64
+ };
65
+ let decoded = value;
66
+ for (let i = 0; i < 4; i += 1) {
67
+ const next = decoded.replace(/&#x([0-9a-f]+);?/gi, (_match, hex) => {
68
+ const codePoint = Number.parseInt(hex, 16);
69
+ return safeCodePoint(codePoint);
70
+ }).replace(/&#(\d+);?/g, (_match, decimal) => {
71
+ const codePoint = Number.parseInt(decimal, 10);
72
+ return safeCodePoint(codePoint);
73
+ }).replace(/&([a-z]+);/gi, (match, name) => {
74
+ var _a;
75
+ return (_a = namedEntities[name.toLowerCase()]) != null ? _a : match;
76
+ });
77
+ if (next === decoded) return decoded;
78
+ decoded = next;
79
+ }
80
+ return decoded;
81
+ }
82
+ function isSafeUrl(value) {
83
+ const decoded = decodeHtmlEntities(value).replace(/[\u0000-\u001f\u007f\s]+/g, "").trim();
84
+ if (!decoded) return false;
85
+ if (decoded.startsWith("//")) return false;
86
+ if (decoded.startsWith("#") || decoded.startsWith("/") || decoded.startsWith("./") || decoded.startsWith("../")) {
87
+ return true;
88
+ }
89
+ try {
90
+ return SAFE_URL_PROTOCOLS.has(new URL(decoded, "https://handled.local").protocol);
91
+ } catch (e) {
92
+ return false;
93
+ }
94
+ }
95
+ function sanitizeClassName(value) {
96
+ const safeTokens = value.split(/\s+/).map((token) => token.trim()).filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
97
+ return safeTokens.length ? safeTokens.join(" ") : null;
98
+ }
99
+ function sanitizeAttribute(tagName, name, value) {
100
+ const attr = name.toLowerCase();
101
+ if (attr.startsWith("on") || attr === "style" || attr === "srcdoc" || attr === "formaction" || attr === "xlink:href" || attr === "xmlns") {
102
+ return null;
103
+ }
104
+ if (attr === "class") {
105
+ const safeClassName = sanitizeClassName(value);
106
+ return safeClassName ? `class="${escapeAttribute(safeClassName)}"` : null;
107
+ }
108
+ if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith("aria-")) {
109
+ return `${attr}="${escapeAttribute(value)}"`;
110
+ }
111
+ if (tagName === "a" && attr === "href" && isSafeUrl(value)) {
112
+ return `href="${escapeAttribute(value)}"`;
113
+ }
114
+ if (tagName === "img" && attr === "src" && isSafeUrl(value)) {
115
+ return `src="${escapeAttribute(value)}"`;
116
+ }
117
+ if (tagName === "img" && (attr === "alt" || attr === "width" || attr === "height")) {
118
+ return `${attr}="${escapeAttribute(value)}"`;
119
+ }
120
+ if ((tagName === "td" || tagName === "th") && (attr === "colspan" || attr === "rowspan")) {
121
+ return `${attr}="${escapeAttribute(value)}"`;
122
+ }
123
+ return null;
124
+ }
125
+ function sanitizeAttributes(tagName, rawAttributes = "") {
126
+ var _a, _b;
127
+ const attributes = [];
128
+ const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
129
+ let match;
130
+ while ((match = attrPattern.exec(rawAttributes)) !== null) {
131
+ const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match;
132
+ const value = (_b = (_a = doubleQuotedValue != null ? doubleQuotedValue : singleQuotedValue) != null ? _a : unquotedValue) != null ? _b : "";
133
+ const safeAttribute = sanitizeAttribute(tagName, name, value);
134
+ if (safeAttribute) attributes.push(safeAttribute);
135
+ }
136
+ if (tagName === "a" && attributes.some((attr) => attr.startsWith("href="))) {
137
+ attributes.push('target="_blank"', 'rel="noopener noreferrer"');
138
+ }
139
+ return attributes.length ? ` ${attributes.join(" ")}` : "";
140
+ }
141
+ function findTagEnd(html, startIndex) {
142
+ let quote = null;
143
+ for (let i = startIndex + 1; i < html.length; i += 1) {
144
+ const char = html[i];
145
+ if (quote) {
146
+ if (char === quote) quote = null;
147
+ continue;
148
+ }
149
+ if (char === '"' || char === "'") {
150
+ quote = char;
151
+ continue;
152
+ }
153
+ if (char === ">") return i;
154
+ }
155
+ return -1;
156
+ }
157
+ function parseTag(rawTag) {
158
+ var _a;
159
+ const match = rawTag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)\b([\s\S]*?)\/?>$/);
160
+ if (!match) return null;
161
+ return {
162
+ closing: !!match[1],
163
+ name: match[2].toLowerCase(),
164
+ attributes: (_a = match[3]) != null ? _a : ""
165
+ };
166
+ }
167
+ function findDangerousClose(html, tagName, fromIndex) {
168
+ const closePattern = new RegExp(`</\\s*${tagName}\\s*>`, "ig");
169
+ closePattern.lastIndex = fromIndex;
170
+ const match = closePattern.exec(html);
171
+ return match ? closePattern.lastIndex : -1;
172
+ }
173
+ function sanitizeHtml(html) {
174
+ let output = "";
175
+ let cursor = 0;
176
+ while (cursor < html.length) {
177
+ const tagStart = html.indexOf("<", cursor);
178
+ if (tagStart === -1) {
179
+ output += html.slice(cursor);
180
+ break;
181
+ }
182
+ output += html.slice(cursor, tagStart);
183
+ if (html.startsWith("<!--", tagStart)) {
184
+ const commentEnd = html.indexOf("-->", tagStart + 4);
185
+ cursor = commentEnd === -1 ? html.length : commentEnd + 3;
186
+ continue;
187
+ }
188
+ const tagEnd = findTagEnd(html, tagStart);
189
+ if (tagEnd === -1) {
190
+ output += escapeHtml(html.slice(tagStart));
191
+ break;
192
+ }
193
+ const rawTag = html.slice(tagStart, tagEnd + 1);
194
+ const parsed = parseTag(rawTag);
195
+ if (!parsed) {
196
+ cursor = tagEnd + 1;
197
+ continue;
198
+ }
199
+ if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {
200
+ const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1);
201
+ cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd;
202
+ continue;
203
+ }
204
+ if (ALLOWED_TAGS.has(parsed.name)) {
205
+ if (parsed.closing) {
206
+ if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`;
207
+ } else {
208
+ output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`;
209
+ }
210
+ }
211
+ cursor = tagEnd + 1;
212
+ }
213
+ return output;
214
+ }
215
+ function htmlToTextSnippet(html, maxLength = 140) {
216
+ return sanitizeHtml(html).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
217
+ }
218
+ export {
219
+ htmlToTextSnippet,
220
+ sanitizeHtml
221
+ };
222
+ //# sourceMappingURL=safe-html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \"&quot;\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"style\" ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"alt\" || attr === \"width\" || attr === \"height\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"AAAA,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,eAAe,oBAAI,IAAI;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,YAAY,oBAAI,IAAI,CAAC,MAAM,MAAM,KAAK,CAAC;AAC7C,MAAM,oBAAoB,oBAAI,IAAI,CAAC,cAAc,QAAQ,OAAO,CAAC;AACjE,MAAM,qBAAqB,oBAAI,IAAI,CAAC,SAAS,UAAU,WAAW,MAAM,CAAC;AAEzE,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,WAAW,KAAK,EAAE,QAAQ,MAAM,QAAQ;AACjD;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,UAAW,OAAO,cAAc,KAAK,IAAI;AACpG;AAEA,SAAS,mBAAmB,OAAuB;AACjD,QAAM,gBAAwC;AAAA,IAC5C,KAAK;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,MAAM;AAAA,IACN,KAAK;AAAA,EACP;AAEA,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG;AAC7B,UAAM,OAAO,QACV,QAAQ,sBAAsB,CAAC,QAAQ,QAAgB;AACtD,YAAM,YAAY,OAAO,SAAS,KAAK,EAAE;AACzC,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,cAAc,CAAC,QAAQ,YAAoB;AAClD,YAAM,YAAY,OAAO,SAAS,SAAS,EAAE;AAC7C,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,gBAAgB,CAAC,OAAO,SAAc;AArFrD;AAqFwD,iCAAc,KAAK,YAAY,CAAC,MAAhC,YAAqC;AAAA,KAAK;AAE9F,QAAI,SAAS,QAAS,QAAO;AAC7B,cAAU;AAAA,EACZ;AAEA,SAAO;AACT;AAEA,SAAS,UAAU,OAAwB;AACzC,QAAM,UAAU,mBAAmB,KAAK,EAAE,QAAQ,6BAA6B,EAAE,EAAE,KAAK;AACxF,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,WAAW,IAAI,EAAG,QAAO;AACrC,MAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,IAAI,KAAK,QAAQ,WAAW,KAAK,GAAG;AAC/G,WAAO;AAAA,EACT;AAEA,MAAI;AACF,WAAO,mBAAmB,IAAI,IAAI,IAAI,SAAS,uBAAuB,EAAE,QAAQ;AAAA,EAClF,SAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,OAA8B;AACvD,QAAM,aAAa,MAChB,MAAM,KAAK,EACX,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,mBAAmB,KAAK,KAAK,CAAC;AAEnD,SAAO,WAAW,SAAS,WAAW,KAAK,GAAG,IAAI;AACpD;AAEA,SAAS,kBAAkB,SAAiB,MAAc,OAA8B;AACtF,QAAM,OAAO,KAAK,YAAY;AAE9B,MACE,KAAK,WAAW,IAAI,KACpB,SAAS,WACT,SAAS,YACT,SAAS,gBACT,SAAS,gBACT,SAAS,SACT;AACA,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,SAAS;AACpB,UAAM,gBAAgB,kBAAkB,KAAK;AAC7C,WAAO,gBAAgB,UAAU,gBAAgB,aAAa,CAAC,MAAM;AAAA,EACvE;AAEA,MAAI,kBAAkB,IAAI,IAAI,KAAK,KAAK,WAAW,OAAO,GAAG;AAC3D,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,MAAI,YAAY,OAAO,SAAS,UAAU,UAAU,KAAK,GAAG;AAC1D,WAAO,SAAS,gBAAgB,KAAK,CAAC;AAAA,EACxC;AAEA,MAAI,YAAY,SAAS,SAAS,SAAS,UAAU,KAAK,GAAG;AAC3D,WAAO,QAAQ,gBAAgB,KAAK,CAAC;AAAA,EACvC;AAEA,MAAI,YAAY,UAAU,SAAS,SAAS,SAAS,WAAW,SAAS,WAAW;AAClF,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,OAAK,YAAY,QAAQ,YAAY,UAAU,SAAS,aAAa,SAAS,YAAY;AACxF,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,SAAO;AACT;AAEA,SAAS,mBAAmB,SAAiB,gBAAgB,IAAY;AAhKzE;AAiKE,QAAM,aAAuB,CAAC;AAC9B,QAAM,cAAc;AACpB,MAAI;AAEJ,UAAQ,QAAQ,YAAY,KAAK,aAAa,OAAO,MAAM;AACzD,UAAM,CAAC,EAAE,MAAM,mBAAmB,mBAAmB,aAAa,IAAI;AACtE,UAAM,SAAQ,2DAAqB,sBAArB,YAA0C,kBAA1C,YAA2D;AACzE,UAAM,gBAAgB,kBAAkB,SAAS,MAAM,KAAK;AAC5D,QAAI,cAAe,YAAW,KAAK,aAAa;AAAA,EAClD;AAEA,MAAI,YAAY,OAAO,WAAW,KAAK,CAAC,SAAS,KAAK,WAAW,OAAO,CAAC,GAAG;AAC1E,eAAW,KAAK,mBAAmB,2BAA2B;AAAA,EAChE;AAEA,SAAO,WAAW,SAAS,IAAI,WAAW,KAAK,GAAG,CAAC,KAAK;AAC1D;AAEA,SAAS,WAAW,MAAc,YAA4B;AAC5D,MAAI,QAA0B;AAE9B,WAAS,IAAI,aAAa,GAAG,IAAI,KAAK,QAAQ,KAAK,GAAG;AACpD,UAAM,OAAO,KAAK,CAAC;AACnB,QAAI,OAAO;AACT,UAAI,SAAS,MAAO,SAAQ;AAC5B;AAAA,IACF;AAEA,QAAI,SAAS,OAAO,SAAS,KAAK;AAChC,cAAQ;AACR;AAAA,IACF;AAEA,QAAI,SAAS,IAAK,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;AAEA,SAAS,SAAS,QAA+E;AAxMjG;AAyME,QAAM,QAAQ,OAAO,MAAM,wDAAwD;AACnF,MAAI,CAAC,MAAO,QAAO;AAEnB,SAAO;AAAA,IACL,SAAS,CAAC,CAAC,MAAM,CAAC;AAAA,IAClB,MAAM,MAAM,CAAC,EAAE,YAAY;AAAA,IAC3B,aAAY,WAAM,CAAC,MAAP,YAAY;AAAA,EAC1B;AACF;AAEA,SAAS,mBAAmB,MAAc,SAAiB,WAA2B;AACpF,QAAM,eAAe,IAAI,OAAO,SAAS,OAAO,SAAS,IAAI;AAC7D,eAAa,YAAY;AACzB,QAAM,QAAQ,aAAa,KAAK,IAAI;AACpC,SAAO,QAAQ,aAAa,YAAY;AAC1C;AASO,SAAS,aAAa,MAAsB;AACjD,MAAI,SAAS;AACb,MAAI,SAAS;AAEb,SAAO,SAAS,KAAK,QAAQ;AAC3B,UAAM,WAAW,KAAK,QAAQ,KAAK,MAAM;AACzC,QAAI,aAAa,IAAI;AACnB,gBAAU,KAAK,MAAM,MAAM;AAC3B;AAAA,IACF;AAEA,cAAU,KAAK,MAAM,QAAQ,QAAQ;AAErC,QAAI,KAAK,WAAW,QAAQ,QAAQ,GAAG;AACrC,YAAM,aAAa,KAAK,QAAQ,OAAO,WAAW,CAAC;AACnD,eAAS,eAAe,KAAK,KAAK,SAAS,aAAa;AACxD;AAAA,IACF;AAEA,UAAM,SAAS,WAAW,MAAM,QAAQ;AACxC,QAAI,WAAW,IAAI;AACjB,gBAAU,WAAW,KAAK,MAAM,QAAQ,CAAC;AACzC;AAAA,IACF;AAEA,UAAM,SAAS,KAAK,MAAM,UAAU,SAAS,CAAC;AAC9C,UAAM,SAAS,SAAS,MAAM;AAC9B,QAAI,CAAC,QAAQ;AACX,eAAS,SAAS;AAClB;AAAA,IACF;AAEA,QAAI,qBAAqB,IAAI,OAAO,IAAI,GAAG;AACzC,YAAM,WAAW,OAAO,UAAU,KAAK,mBAAmB,MAAM,OAAO,MAAM,SAAS,CAAC;AACvF,eAAS,aAAa,KAAK,SAAS,IAAI;AACxC;AAAA,IACF;AAEA,QAAI,aAAa,IAAI,OAAO,IAAI,GAAG;AACjC,UAAI,OAAO,SAAS;AAClB,YAAI,CAAC,UAAU,IAAI,OAAO,IAAI,EAAG,WAAU,KAAK,OAAO,IAAI;AAAA,MAC7D,OAAO;AACL,kBAAU,IAAI,OAAO,IAAI,GAAG,mBAAmB,OAAO,MAAM,OAAO,UAAU,CAAC;AAAA,MAChF;AAAA,IACF;AAEA,aAAS,SAAS;AAAA,EACpB;AAEA,SAAO;AACT;AAEO,SAAS,kBAAkB,MAAc,YAAY,KAAa;AACvE,SAAO,aAAa,IAAI,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,QAAQ,GAAG,EACnB,KAAK,EACL,MAAM,GAAG,SAAS;AACvB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.53",
3
+ "version": "0.19.0-rc.0",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import React from "react";
3
+ import { render, screen, fireEvent } from "@testing-library/react";
4
+ import { CommentComposer } from "../comment-composer";
5
+
6
+ describe("CommentComposer", () => {
7
+ it("renders the input with the default placeholder", () => {
8
+ render(<CommentComposer onPost={() => {}} />);
9
+ expect(screen.getByPlaceholderText("Add a comment or internal note…")).toBeDefined();
10
+ });
11
+
12
+ it("is collapsed until focused (no footer actions)", () => {
13
+ render(<CommentComposer onPost={() => {}} />);
14
+ expect(screen.queryByText("Comment")).toBeNull();
15
+ fireEvent.focus(screen.getByRole("textbox"));
16
+ expect(screen.getByText("Comment")).toBeDefined();
17
+ expect(screen.getByText(/Internal note/i)).toBeDefined();
18
+ });
19
+
20
+ it("disables Comment until there is non-whitespace text", () => {
21
+ render(<CommentComposer onPost={() => {}} />);
22
+ const input = screen.getByRole("textbox");
23
+ fireEvent.focus(input);
24
+ const btn = screen.getByText("Comment").closest("button")!;
25
+ expect(btn.disabled).toBe(true);
26
+ fireEvent.change(input, { target: { value: " hi " } });
27
+ expect(btn.disabled).toBe(false);
28
+ });
29
+
30
+ it("posts the trimmed text and clears the input", () => {
31
+ const onPost = vi.fn();
32
+ render(<CommentComposer onPost={onPost} />);
33
+ const input = screen.getByRole("textbox") as HTMLTextAreaElement;
34
+ fireEvent.change(input, { target: { value: " please follow up " } });
35
+ fireEvent.click(screen.getByText("Comment").closest("button")!);
36
+ expect(onPost).toHaveBeenCalledWith("please follow up");
37
+ expect(input.value).toBe("");
38
+ });
39
+
40
+ it("posts on ⌘↵ / Ctrl↵", () => {
41
+ const onPost = vi.fn();
42
+ render(<CommentComposer onPost={onPost} />);
43
+ const input = screen.getByRole("textbox");
44
+ fireEvent.change(input, { target: { value: "quick note" } });
45
+ fireEvent.keyDown(input, { key: "Enter", metaKey: true });
46
+ expect(onPost).toHaveBeenCalledWith("quick note");
47
+ });
48
+
49
+ it("does not post empty/whitespace text", () => {
50
+ const onPost = vi.fn();
51
+ render(<CommentComposer onPost={onPost} />);
52
+ const input = screen.getByRole("textbox");
53
+ fireEvent.change(input, { target: { value: " " } });
54
+ fireEvent.keyDown(input, { key: "Enter", metaKey: true });
55
+ expect(onPost).not.toHaveBeenCalled();
56
+ });
57
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import React from "react";
3
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
4
+ import {
5
+ ConversationPanel,
6
+ type ConversationThread,
7
+ type ConvParticipant,
8
+ } from "../conversation-panel";
9
+
10
+ const me: ConvParticipant = { name: "Dana Okafor", email: "dana@handled.ai" };
11
+ const priya: ConvParticipant = { name: "Priya Raman", email: "priya@northwind.io" };
12
+
13
+ function thread(overrides: Partial<ConversationThread> = {}): ConversationThread {
14
+ return {
15
+ threadId: "t1",
16
+ subject: "Want to connect?",
17
+ status: "responded",
18
+ lastWhen: "2h ago",
19
+ contact: priya,
20
+ messages: [
21
+ {
22
+ id: "m1",
23
+ direction: "outbound",
24
+ from: me,
25
+ to: priya,
26
+ date: "Jun 1",
27
+ body: "Hi Priya, can we talk?",
28
+ },
29
+ {
30
+ id: "m2",
31
+ direction: "inbound",
32
+ from: priya,
33
+ to: me,
34
+ date: "Today",
35
+ bodyHtml: '<p>Sure, see the <a href="https://ex.io/x">doc</a>.</p>',
36
+ },
37
+ ],
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe("ConversationPanel", () => {
43
+ it("renders nothing with no threads", () => {
44
+ const { container } = render(<ConversationPanel threads={[]} />);
45
+ expect(container.querySelector('[data-slot="conversation-panel"]')).toBeNull();
46
+ });
47
+
48
+ it("shows the 'Email response detected' badge when a thread has responded", () => {
49
+ render(<ConversationPanel threads={[thread()]} me={me} />);
50
+ expect(screen.getByText("Email response detected")).toBeDefined();
51
+ });
52
+
53
+ it("shows the 'Awaiting response' badge when nothing has responded", () => {
54
+ render(<ConversationPanel threads={[thread({ status: "awaiting" })]} me={me} />);
55
+ expect(screen.getByText("Awaiting response")).toBeDefined();
56
+ });
57
+
58
+ it("auto-opens the first responded thread and renders its newest message as HTML", () => {
59
+ const { container } = render(<ConversationPanel threads={[thread()]} me={me} />);
60
+ // newest (inbound) message expanded -> its anchor is in the DOM
61
+ const link = container.querySelector('a[href="https://ex.io/x"]');
62
+ expect(link).not.toBeNull();
63
+ });
64
+
65
+
66
+ it("sanitizes HTML message bodies and quoted history before rendering", () => {
67
+ const unsafe = thread({
68
+ messages: [
69
+ {
70
+ id: "m1",
71
+ direction: "inbound",
72
+ from: priya,
73
+ to: me,
74
+ date: "Today",
75
+ bodyHtml:
76
+ '<p onclick="alert(1)">Hello <a href="javascript:alert(1)">bad</a><script>alert(1)</script><img src="https://example.com/x.png" onerror="alert(1)"></p>',
77
+ quoted: {
78
+ attr: '<span onclick="alert(1)">On Tuesday</span>',
79
+ html: '<blockquote class="gmail_quote" style="color:red"><a href="data:text/html,boom">quoted</a></blockquote>',
80
+ },
81
+ },
82
+ ],
83
+ });
84
+
85
+ const { container } = render(<ConversationPanel threads={[unsafe]} me={me} />);
86
+ const message = container.querySelector('[data-slot="conv-message"]')!;
87
+ expect(message.innerHTML).not.toContain("script");
88
+ expect(message.innerHTML).not.toContain("onclick");
89
+ expect(message.innerHTML).not.toContain("onerror");
90
+ expect(message.innerHTML).not.toContain("javascript:");
91
+ expect(message.querySelector("img")?.getAttribute("src")).toBe("https://example.com/x.png");
92
+
93
+ fireEvent.click(screen.getByTitle("Show quoted text"));
94
+ const quoted = container.querySelector("blockquote.gmail_quote")!;
95
+ expect(quoted).not.toBeNull();
96
+ expect(quoted.outerHTML).not.toContain("style=");
97
+ expect(quoted.outerHTML).not.toContain("data:text/html");
98
+ });
99
+
100
+ it("keeps the reply composer open and shows an error when async send fails", async () => {
101
+ const onSendReply = vi.fn().mockRejectedValue(new Error("Gmail send failed"));
102
+ render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onSendReply={onSendReply} />);
103
+
104
+ fireEvent.click(screen.getByText("Reply"));
105
+ fireEvent.click(screen.getByText("Send"));
106
+ fireEvent.click(screen.getByText("Send now"));
107
+
108
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Gmail send failed"));
109
+ expect(screen.getByPlaceholderText("Write your reply…")).toBeDefined();
110
+ expect(screen.queryByText(/Reply sent/i)).toBeNull();
111
+ });
112
+
113
+ it("shows sent state only after async send succeeds", async () => {
114
+ const onSendReply = vi.fn().mockResolvedValue(undefined);
115
+ render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onSendReply={onSendReply} />);
116
+
117
+ fireEvent.click(screen.getByText("Reply"));
118
+ fireEvent.click(screen.getByText("Send"));
119
+ fireEvent.click(screen.getByText("Send now"));
120
+
121
+ await waitFor(() => expect(screen.getByText(/Reply sent/i)).toBeDefined());
122
+ });
123
+
124
+ it("offers a Reply affordance for participant threads", () => {
125
+ render(<ConversationPanel threads={[thread()]} me={me} />);
126
+ expect(screen.getByText("Reply")).toBeDefined();
127
+ });
128
+
129
+ it("renders the read-only notice and no Reply when canReply is false", () => {
130
+ render(
131
+ <ConversationPanel
132
+ threads={[thread({ canReply: false, status: "viewing", defaultOpenThreadId: "t1" } as Partial<ConversationThread>)]}
133
+ me={me}
134
+ defaultOpenThreadId="t1"
135
+ />,
136
+ );
137
+ expect(screen.getByText(/Viewing only/i)).toBeDefined();
138
+ expect(screen.queryByText("Reply")).toBeNull();
139
+ });
140
+
141
+ it("shows the paused-playbook banner when a thread is paused", () => {
142
+ render(
143
+ <ConversationPanel
144
+ threads={[thread({ paused: { playbook: "Retention Outreach" } })]}
145
+ me={me}
146
+ tenantName="Mercury OS"
147
+ />,
148
+ );
149
+ expect(screen.getByText(/Follow-up actions stopped/i)).toBeDefined();
150
+ });
151
+
152
+ it("opens the reply composer when Reply is clicked", () => {
153
+ render(<ConversationPanel threads={[thread()]} me={me} />);
154
+ fireEvent.click(screen.getByText("Reply"));
155
+ expect(screen.getByPlaceholderText("Write your reply…")).toBeDefined();
156
+ });
157
+ });
@@ -442,6 +442,78 @@ describe("DataTableFilter", () => {
442
442
  expect(screen.getByPlaceholderText("Search...")).toBeDefined();
443
443
  });
444
444
 
445
+ it("fires onOptionSearch and skips client-side filtering for remoteSearch categories", () => {
446
+ const onOptionSearch = vi.fn();
447
+ const category: DataTableFilterCategory = {
448
+ id: "callsign",
449
+ label: "Callsign",
450
+ icon: ListFilter,
451
+ remoteSearch: true,
452
+ // Parent-supplied (already server-filtered) options.
453
+ options: [{ label: "acme-corp-0001", value: "acme-corp-0001" }],
454
+ };
455
+
456
+ render(
457
+ <DataTableFilter
458
+ categories={[category]}
459
+ selectedFilters={{}}
460
+ onToggleFilter={() => {}}
461
+ onOptionSearch={onOptionSearch}
462
+ />
463
+ );
464
+
465
+ // Search box is always shown for remote categories, regardless of option count.
466
+ const input = screen.getByPlaceholderText("Search...");
467
+ // Typing a value that does NOT match the option must NOT remove it — the parent
468
+ // owns filtering — and must notify the parent with the typed query.
469
+ fireEvent.change(input, { target: { value: "zzz-no-client-match" } });
470
+ expect(onOptionSearch).toHaveBeenCalledWith("callsign", "zzz-no-client-match");
471
+ expect(screen.getByText("acme-corp-0001")).toBeDefined();
472
+ });
473
+
474
+ it("shows a loading row while a remoteSearch category is fetching", () => {
475
+ const category: DataTableFilterCategory = {
476
+ id: "callsign",
477
+ label: "Callsign",
478
+ icon: ListFilter,
479
+ remoteSearch: true,
480
+ options: [],
481
+ };
482
+
483
+ render(
484
+ <DataTableFilter
485
+ categories={[category]}
486
+ selectedFilters={{}}
487
+ onToggleFilter={() => {}}
488
+ onOptionSearch={() => {}}
489
+ optionSearchLoading={{ callsign: true }}
490
+ />
491
+ );
492
+
493
+ expect(screen.getByText("Searching…")).toBeDefined();
494
+ });
495
+
496
+ it("prompts to type before searching when a remoteSearch category is empty", () => {
497
+ const category: DataTableFilterCategory = {
498
+ id: "callsign",
499
+ label: "Callsign",
500
+ icon: ListFilter,
501
+ remoteSearch: true,
502
+ options: [],
503
+ };
504
+
505
+ render(
506
+ <DataTableFilter
507
+ categories={[category]}
508
+ selectedFilters={{}}
509
+ onToggleFilter={() => {}}
510
+ onOptionSearch={() => {}}
511
+ />
512
+ );
513
+
514
+ expect(screen.getByText("Type to search")).toBeDefined();
515
+ });
516
+
445
517
  it("exposes a condition builder popover entry point when condition fields are provided", () => {
446
518
  const conditionFields: ConditionFieldDef[] = [
447
519
  { id: "balance", label: "Account Balance", type: "currency" },
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import React from "react";
3
+ import { render, screen } from "@testing-library/react";
4
+ import {
5
+ SignalOwnerChip,
6
+ AccountOwnerChip,
7
+ OwnerChips,
8
+ type OwnerPerson,
9
+ } from "../owner-chips";
10
+
11
+ const dana: OwnerPerson = { id: "u1", name: "Dana Okafor", role: "Senior RM" };
12
+ const marcus: OwnerPerson = { id: "u2", name: "Marcus Lee", role: "Treasury" };
13
+
14
+ describe("SignalOwnerChip", () => {
15
+ it("renders data-slot and the 'Signal owner' label", () => {
16
+ const { container } = render(<SignalOwnerChip owner={null} />);
17
+ expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
18
+ expect(screen.getByText("Signal owner")).toBeDefined();
19
+ });
20
+
21
+ it("shows 'Unassigned' (data-empty) with no owner", () => {
22
+ const { container } = render(<SignalOwnerChip owner={null} />);
23
+ const el = container.querySelector('[data-slot="signal-owner-chip"]');
24
+ expect(el?.getAttribute("data-empty")).toBe("true");
25
+ expect(screen.getByText("Unassigned")).toBeDefined();
26
+ });
27
+
28
+ it("shows the owner's first name when assigned", () => {
29
+ render(<SignalOwnerChip owner={dana} />);
30
+ expect(screen.getByText("Dana")).toBeDefined();
31
+ });
32
+
33
+ it("renders a static span (no button) when read-only / no handlers", () => {
34
+ const { container } = render(<SignalOwnerChip owner={dana} />);
35
+ const el = container.querySelector('[data-slot="signal-owner-chip"]');
36
+ expect(el?.tagName.toLowerCase()).toBe("span");
37
+ });
38
+
39
+ it("renders a button trigger when assignment handlers are provided", () => {
40
+ const { container } = render(
41
+ <SignalOwnerChip owner={null} assignableOwners={[dana]} onAssign={() => {}} />,
42
+ );
43
+ const el = container.querySelector('[data-slot="signal-owner-chip"]');
44
+ expect(el?.tagName.toLowerCase()).toBe("button");
45
+ });
46
+ // NOTE: the assignment menu opens via Radix (pointer events + a body portal),
47
+ // which doesn't drive cleanly under happy-dom; the open/select interaction is
48
+ // covered by Radix's own tests. Here we assert the trigger affordance only.
49
+ });
50
+
51
+ describe("AccountOwnerChip", () => {
52
+ it("renders nothing when there are no owners", () => {
53
+ const { container } = render(<AccountOwnerChip owners={[]} />);
54
+ expect(container.querySelector('[data-slot="account-owner-chip"]')).toBeNull();
55
+ });
56
+
57
+ it("single owner -> static chip, no dropdown (no data-multi)", () => {
58
+ const { container } = render(<AccountOwnerChip owners={[dana]} />);
59
+ const el = container.querySelector('[data-slot="account-owner-chip"]');
60
+ expect(el).not.toBeNull();
61
+ expect(el?.getAttribute("data-multi")).toBeNull();
62
+ expect(el?.tagName.toLowerCase()).toBe("span");
63
+ expect(screen.getByText("Dana")).toBeDefined();
64
+ });
65
+
66
+ it("single owner with href -> renders an external link", () => {
67
+ const { container } = render(
68
+ <AccountOwnerChip owners={[{ ...dana, href: "https://sf.example/u1" }]} />,
69
+ );
70
+ const el = container.querySelector('[data-slot="account-owner-chip"]') as HTMLAnchorElement;
71
+ expect(el.tagName.toLowerCase()).toBe("a");
72
+ expect(el.getAttribute("href")).toBe("https://sf.example/u1");
73
+ expect(el.getAttribute("rel")).toContain("noopener");
74
+ });
75
+
76
+ it("multiple owners -> a button with a ×N badge and data-multi", () => {
77
+ const { container } = render(<AccountOwnerChip owners={[dana, marcus]} />);
78
+ const el = container.querySelector('[data-slot="account-owner-chip"]');
79
+ expect(el?.getAttribute("data-multi")).toBe("true");
80
+ expect(el?.tagName.toLowerCase()).toBe("button");
81
+ expect(screen.getByText("×2")).toBeDefined();
82
+ });
83
+ });
84
+
85
+
86
+ describe("OwnerChips", () => {
87
+ it("renders the composite with accountOwners and without the legacy owners prop", () => {
88
+ const { container } = render(<OwnerChips owner={dana} accountOwners={[marcus]} />);
89
+ expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
90
+ expect(container.querySelector('[data-slot="account-owner-chip"]')).not.toBeNull();
91
+ expect(screen.getByText("Dana")).toBeDefined();
92
+ expect(screen.getByText("Marcus")).toBeDefined();
93
+ });
94
+
95
+ it("allows accountOwners to be omitted", () => {
96
+ const { container } = render(<OwnerChips owner={dana} />);
97
+ expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
98
+ expect(container.querySelector('[data-slot="account-owner-chip"]')).toBeNull();
99
+ });
100
+ });