@handled-ai/design-system 0.18.52 → 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 (47) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/case-panel-activity-timeline.d.ts +2 -0
  4. package/dist/components/case-panel-activity-timeline.js +22 -1
  5. package/dist/components/case-panel-activity-timeline.js.map +1 -1
  6. package/dist/components/comment-composer.d.ts +29 -0
  7. package/dist/components/comment-composer.js +102 -0
  8. package/dist/components/comment-composer.js.map +1 -0
  9. package/dist/components/conversation-panel.d.ts +95 -0
  10. package/dist/components/conversation-panel.js +636 -0
  11. package/dist/components/conversation-panel.js.map +1 -0
  12. package/dist/components/data-table-filter.d.ts +18 -1
  13. package/dist/components/data-table-filter.js +20 -6
  14. package/dist/components/data-table-filter.js.map +1 -1
  15. package/dist/components/entity-panel.js +5 -5
  16. package/dist/components/entity-panel.js.map +1 -1
  17. package/dist/components/owner-chips.d.ts +59 -0
  18. package/dist/components/owner-chips.js +256 -0
  19. package/dist/components/owner-chips.js.map +1 -0
  20. package/dist/components/pill.d.ts +1 -1
  21. package/dist/components/tabs.d.ts +1 -1
  22. package/dist/components/timeline-activity.d.ts +7 -0
  23. package/dist/components/timeline-activity.js +22 -1
  24. package/dist/components/timeline-activity.js.map +1 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +3 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/safe-html.d.ts +11 -0
  29. package/dist/internal/safe-html.js +222 -0
  30. package/dist/internal/safe-html.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/components/__tests__/comment-composer.test.tsx +57 -0
  33. package/src/components/__tests__/conversation-panel.test.tsx +157 -0
  34. package/src/components/__tests__/data-table-filter.test.tsx +72 -0
  35. package/src/components/__tests__/entity-metadata-grid.test.tsx +27 -1
  36. package/src/components/__tests__/owner-chips.test.tsx +100 -0
  37. package/src/components/__tests__/timeline-activity.test.tsx +55 -0
  38. package/src/components/case-panel-activity-timeline.tsx +20 -0
  39. package/src/components/comment-composer.tsx +119 -0
  40. package/src/components/conversation-panel.tsx +790 -0
  41. package/src/components/data-table-filter.tsx +53 -10
  42. package/src/components/entity-panel.tsx +7 -5
  43. package/src/components/owner-chips.tsx +335 -0
  44. package/src/components/timeline-activity.tsx +37 -3
  45. package/src/index.ts +3 -0
  46. package/src/internal/__tests__/safe-html.test.ts +53 -0
  47. package/src/internal/safe-html.ts +284 -0
@@ -0,0 +1,284 @@
1
+ const DANGEROUS_BLOCK_TAGS = 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
+
15
+ const ALLOWED_TAGS = new Set([
16
+ "a",
17
+ "b",
18
+ "blockquote",
19
+ "br",
20
+ "code",
21
+ "del",
22
+ "div",
23
+ "em",
24
+ "hr",
25
+ "i",
26
+ "img",
27
+ "li",
28
+ "ol",
29
+ "p",
30
+ "pre",
31
+ "s",
32
+ "span",
33
+ "strong",
34
+ "table",
35
+ "tbody",
36
+ "td",
37
+ "th",
38
+ "thead",
39
+ "tr",
40
+ "u",
41
+ "ul",
42
+ ])
43
+
44
+ const VOID_TAGS = new Set(["br", "hr", "img"])
45
+ const SAFE_GLOBAL_ATTRS = new Set(["aria-label", "role", "title"])
46
+ const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
47
+
48
+ function escapeHtml(value: string): string {
49
+ return value
50
+ .replace(/&/g, "&")
51
+ .replace(/</g, "&lt;")
52
+ .replace(/>/g, "&gt;")
53
+ }
54
+
55
+ function escapeAttribute(value: string): string {
56
+ return escapeHtml(value).replace(/"/g, "&quot;")
57
+ }
58
+
59
+ function safeCodePoint(value: number): string {
60
+ return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : ""
61
+ }
62
+
63
+ function decodeHtmlEntities(value: string): string {
64
+ const namedEntities: Record<string, string> = {
65
+ amp: "&",
66
+ apos: "'",
67
+ colon: ":",
68
+ gt: ">",
69
+ lt: "<",
70
+ newline: "\n",
71
+ quot: '"',
72
+ tab: "\t",
73
+ }
74
+
75
+ let decoded = value
76
+ for (let i = 0; i < 4; i += 1) {
77
+ const next = decoded
78
+ .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {
79
+ const codePoint = Number.parseInt(hex, 16)
80
+ return safeCodePoint(codePoint)
81
+ })
82
+ .replace(/&#(\d+);?/g, (_match, decimal: string) => {
83
+ const codePoint = Number.parseInt(decimal, 10)
84
+ return safeCodePoint(codePoint)
85
+ })
86
+ .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)
87
+
88
+ if (next === decoded) return decoded
89
+ decoded = next
90
+ }
91
+
92
+ return decoded
93
+ }
94
+
95
+ function isSafeUrl(value: string): boolean {
96
+ const decoded = decodeHtmlEntities(value).replace(/[\u0000-\u001f\u007f\s]+/g, "").trim()
97
+ if (!decoded) return false
98
+ if (decoded.startsWith("//")) return false
99
+ if (decoded.startsWith("#") || decoded.startsWith("/") || decoded.startsWith("./") || decoded.startsWith("../")) {
100
+ return true
101
+ }
102
+
103
+ try {
104
+ return SAFE_URL_PROTOCOLS.has(new URL(decoded, "https://handled.local").protocol)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ function sanitizeClassName(value: string): string | null {
111
+ const safeTokens = value
112
+ .split(/\s+/)
113
+ .map((token) => token.trim())
114
+ .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
115
+
116
+ return safeTokens.length ? safeTokens.join(" ") : null
117
+ }
118
+
119
+ function sanitizeAttribute(tagName: string, name: string, value: string): string | null {
120
+ const attr = name.toLowerCase()
121
+
122
+ if (
123
+ attr.startsWith("on") ||
124
+ attr === "style" ||
125
+ attr === "srcdoc" ||
126
+ attr === "formaction" ||
127
+ attr === "xlink:href" ||
128
+ attr === "xmlns"
129
+ ) {
130
+ return null
131
+ }
132
+
133
+ if (attr === "class") {
134
+ const safeClassName = sanitizeClassName(value)
135
+ return safeClassName ? `class="${escapeAttribute(safeClassName)}"` : null
136
+ }
137
+
138
+ if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith("aria-")) {
139
+ return `${attr}="${escapeAttribute(value)}"`
140
+ }
141
+
142
+ if (tagName === "a" && attr === "href" && isSafeUrl(value)) {
143
+ return `href="${escapeAttribute(value)}"`
144
+ }
145
+
146
+ if (tagName === "img" && attr === "src" && isSafeUrl(value)) {
147
+ return `src="${escapeAttribute(value)}"`
148
+ }
149
+
150
+ if (tagName === "img" && (attr === "alt" || attr === "width" || attr === "height")) {
151
+ return `${attr}="${escapeAttribute(value)}"`
152
+ }
153
+
154
+ if ((tagName === "td" || tagName === "th") && (attr === "colspan" || attr === "rowspan")) {
155
+ return `${attr}="${escapeAttribute(value)}"`
156
+ }
157
+
158
+ return null
159
+ }
160
+
161
+ function sanitizeAttributes(tagName: string, rawAttributes = ""): string {
162
+ const attributes: string[] = []
163
+ const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
164
+ let match: RegExpExecArray | null
165
+
166
+ while ((match = attrPattern.exec(rawAttributes)) !== null) {
167
+ const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match
168
+ const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? ""
169
+ const safeAttribute = sanitizeAttribute(tagName, name, value)
170
+ if (safeAttribute) attributes.push(safeAttribute)
171
+ }
172
+
173
+ if (tagName === "a" && attributes.some((attr) => attr.startsWith("href="))) {
174
+ attributes.push('target="_blank"', 'rel="noopener noreferrer"')
175
+ }
176
+
177
+ return attributes.length ? ` ${attributes.join(" ")}` : ""
178
+ }
179
+
180
+ function findTagEnd(html: string, startIndex: number): number {
181
+ let quote: '"' | "'" | null = null
182
+
183
+ for (let i = startIndex + 1; i < html.length; i += 1) {
184
+ const char = html[i]
185
+ if (quote) {
186
+ if (char === quote) quote = null
187
+ continue
188
+ }
189
+
190
+ if (char === '"' || char === "'") {
191
+ quote = char
192
+ continue
193
+ }
194
+
195
+ if (char === ">") return i
196
+ }
197
+
198
+ return -1
199
+ }
200
+
201
+ function parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {
202
+ const match = rawTag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)\b([\s\S]*?)\/?>$/)
203
+ if (!match) return null
204
+
205
+ return {
206
+ closing: !!match[1],
207
+ name: match[2].toLowerCase(),
208
+ attributes: match[3] ?? "",
209
+ }
210
+ }
211
+
212
+ function findDangerousClose(html: string, tagName: string, fromIndex: number): number {
213
+ const closePattern = new RegExp(`</\\s*${tagName}\\s*>`, "ig")
214
+ closePattern.lastIndex = fromIndex
215
+ const match = closePattern.exec(html)
216
+ return match ? closePattern.lastIndex : -1
217
+ }
218
+
219
+ /**
220
+ * Conservative, deterministic sanitizer for user/email supplied HTML rendered by
221
+ * design-system components. It keeps common email formatting tags while removing
222
+ * executable tags, event handlers, inline styles, and unsafe URLs. This stays
223
+ * dependency-free for the shared package and intentionally favors stripping
224
+ * ambiguous email content over preserving every possible HTML feature.
225
+ */
226
+ export function sanitizeHtml(html: string): string {
227
+ let output = ""
228
+ let cursor = 0
229
+
230
+ while (cursor < html.length) {
231
+ const tagStart = html.indexOf("<", cursor)
232
+ if (tagStart === -1) {
233
+ output += html.slice(cursor)
234
+ break
235
+ }
236
+
237
+ output += html.slice(cursor, tagStart)
238
+
239
+ if (html.startsWith("<!--", tagStart)) {
240
+ const commentEnd = html.indexOf("-->", tagStart + 4)
241
+ cursor = commentEnd === -1 ? html.length : commentEnd + 3
242
+ continue
243
+ }
244
+
245
+ const tagEnd = findTagEnd(html, tagStart)
246
+ if (tagEnd === -1) {
247
+ output += escapeHtml(html.slice(tagStart))
248
+ break
249
+ }
250
+
251
+ const rawTag = html.slice(tagStart, tagEnd + 1)
252
+ const parsed = parseTag(rawTag)
253
+ if (!parsed) {
254
+ cursor = tagEnd + 1
255
+ continue
256
+ }
257
+
258
+ if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {
259
+ const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)
260
+ cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd
261
+ continue
262
+ }
263
+
264
+ if (ALLOWED_TAGS.has(parsed.name)) {
265
+ if (parsed.closing) {
266
+ if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`
267
+ } else {
268
+ output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`
269
+ }
270
+ }
271
+
272
+ cursor = tagEnd + 1
273
+ }
274
+
275
+ return output
276
+ }
277
+
278
+ export function htmlToTextSnippet(html: string, maxLength = 140): string {
279
+ return sanitizeHtml(html)
280
+ .replace(/<[^>]+>/g, " ")
281
+ .replace(/\s+/g, " ")
282
+ .trim()
283
+ .slice(0, maxLength)
284
+ }