@handled-ai/design-system 0.18.10 → 0.18.12

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 (43) hide show
  1. package/dist/components/account-contacts-popover.d.ts +5 -1
  2. package/dist/components/account-contacts-popover.js +25 -4
  3. package/dist/components/account-contacts-popover.js.map +1 -1
  4. package/dist/components/badge.d.ts +1 -1
  5. package/dist/components/button.d.ts +1 -1
  6. package/dist/components/data-table-condition-filter.d.ts +15 -3
  7. package/dist/components/data-table-condition-filter.js +199 -52
  8. package/dist/components/data-table-condition-filter.js.map +1 -1
  9. package/dist/components/data-table-filter.js +7 -8
  10. package/dist/components/data-table-filter.js.map +1 -1
  11. package/dist/components/entity-panel.d.ts +2 -1
  12. package/dist/components/entity-panel.js +52 -45
  13. package/dist/components/entity-panel.js.map +1 -1
  14. package/dist/components/pill.d.ts +1 -1
  15. package/dist/components/score-why-chips.d.ts +1 -1
  16. package/dist/components/signal-priority-popover.d.ts +1 -1
  17. package/dist/components/signal-priority-popover.js +4 -4
  18. package/dist/components/signal-priority-popover.js.map +1 -1
  19. package/dist/components/tabs.d.ts +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/dist/prototype/index.d.ts +1 -1
  22. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  23. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  24. package/dist/prototype/prototype-config.d.ts +1 -1
  25. package/dist/prototype/prototype-inbox-view.d.ts +5 -3
  26. package/dist/prototype/prototype-inbox-view.js +11 -5
  27. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  28. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  29. package/dist/prototype/prototype-shell.d.ts +1 -1
  30. package/dist/{signal-priority-popover-BT6CPYNs.d.ts → signal-priority-popover-BEDoPsNE.d.ts} +6 -0
  31. package/package.json +1 -2
  32. package/src/components/__tests__/account-contacts-popover.test.tsx +79 -0
  33. package/src/components/__tests__/data-table-condition-filter.test.tsx +239 -7
  34. package/src/components/__tests__/entity-panel-header.test.tsx +44 -0
  35. package/src/components/__tests__/signal-priority-popover.test.tsx +30 -0
  36. package/src/components/account-contacts-popover.tsx +29 -1
  37. package/src/components/data-table-condition-filter.tsx +278 -68
  38. package/src/components/data-table-filter.tsx +6 -9
  39. package/src/components/entity-panel.tsx +56 -40
  40. package/src/components/signal-priority-popover.tsx +15 -4
  41. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +15 -0
  42. package/src/prototype/prototype-config.ts +2 -0
  43. package/src/prototype/prototype-inbox-view.tsx +17 -5
@@ -103,6 +103,23 @@ const dateField: ConditionFieldDef = {
103
103
  type: "date",
104
104
  };
105
105
 
106
+ const selectField: ConditionFieldDef = {
107
+ id: "stage",
108
+ label: "Stage",
109
+ type: "select",
110
+ options: [
111
+ { label: "Qualified", value: "qualified" },
112
+ { label: "Disqualified", value: "disqualified" },
113
+ ],
114
+ };
115
+
116
+ const multiSelectField: ConditionFieldDef = {
117
+ id: "industry",
118
+ label: "Industry",
119
+ type: "multi_select",
120
+ options: ["Finance", { label: "Healthcare", value: "healthcare" }, "Education"],
121
+ };
122
+
106
123
  const allFields: ConditionFieldDef[] = [
107
124
  textField,
108
125
  numberField,
@@ -110,6 +127,12 @@ const allFields: ConditionFieldDef[] = [
110
127
  dateField,
111
128
  ];
112
129
 
130
+ const optionFields: ConditionFieldDef[] = [
131
+ textField,
132
+ selectField,
133
+ multiSelectField,
134
+ ];
135
+
113
136
  describe("DataTableConditionFilter", () => {
114
137
  let onConditionsChange: ReturnType<typeof vi.fn<(conditions: ConditionFilterValue[]) => void>>;
115
138
 
@@ -117,6 +140,35 @@ describe("DataTableConditionFilter", () => {
117
140
  onConditionsChange = vi.fn<(conditions: ConditionFilterValue[]) => void>();
118
141
  });
119
142
 
143
+ const renderOptionFilter = () => {
144
+ const result = render(
145
+ <DataTableConditionFilter
146
+ fields={optionFields}
147
+ conditions={[]}
148
+ onConditionsChange={onConditionsChange}
149
+ />,
150
+ );
151
+
152
+ fireEvent.click(screen.getByText("Add filter"));
153
+ return result;
154
+ };
155
+
156
+ const chooseField = (container: HTMLElement, label: string) => {
157
+ const fieldOption = Array.from(container.querySelectorAll('[data-slot="select-item"]')).find(
158
+ (opt) => opt.textContent?.includes(label),
159
+ );
160
+ expect(fieldOption).not.toBeUndefined();
161
+ fireEvent.click(fieldOption!);
162
+ };
163
+
164
+ const expectFieldTriggerHasNoFieldTypeIcon = (container: HTMLElement) => {
165
+ const fieldTrigger = container.querySelector('[data-slot="condition-row"] [data-slot="select-trigger"]');
166
+ expect(fieldTrigger).not.toBeNull();
167
+ expect(fieldTrigger?.querySelector("svg.lucide-ellipsis")).toBeNull();
168
+ expect(fieldTrigger?.querySelector("svg.lucide-list-filter")).toBeNull();
169
+ expect(fieldTrigger?.querySelector("svg.lucide-list-checks")).toBeNull();
170
+ };
171
+
120
172
  it("renders a polished empty panel with Add filter, disabled Add filter group, and quiet Clear filters", () => {
121
173
  const { container } = render(
122
174
  <DataTableConditionFilter
@@ -151,7 +203,6 @@ describe("DataTableConditionFilter", () => {
151
203
  expect(onConditionsChange).toHaveBeenCalledWith([]);
152
204
  });
153
205
 
154
-
155
206
  it("preserves an in-progress draft when fields are recreated with the same definition", () => {
156
207
  const { rerender } = render(
157
208
  <DataTableConditionFilter
@@ -197,6 +248,182 @@ describe("DataTableConditionFilter", () => {
197
248
  expect(committed?.[0]).toMatchObject({ field: "name", operator: "eq", value: "Acme" });
198
249
  });
199
250
 
251
+ it("renders select options and commits an eq condition", () => {
252
+ const { container } = renderOptionFilter();
253
+ chooseField(container, "Stage");
254
+
255
+ expectFieldTriggerHasNoFieldTypeIcon(container);
256
+
257
+ expect(getOperators(selectField)).toEqual(["eq", "neq", "is_null", "is_not_null"]);
258
+ fireEvent.click(screen.getByText("Qualified"));
259
+ fireEvent.click(screen.getByText("Apply"));
260
+
261
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
262
+ expect(committed).toHaveLength(1);
263
+ expect(committed?.[0]).toMatchObject({
264
+ field: "stage",
265
+ operator: "eq",
266
+ value: "qualified",
267
+ });
268
+ });
269
+
270
+ it("filters searchable select options before committing a selected value", () => {
271
+ const searchableOwnerField: ConditionFieldDef = {
272
+ id: "owner",
273
+ label: "Owner",
274
+ type: "select",
275
+ searchable: true,
276
+ options: [
277
+ { label: "Alice Adams", value: "alice@example.com" },
278
+ { label: "Bob Brown", value: "bob@example.com" },
279
+ ],
280
+ };
281
+
282
+ render(
283
+ <DataTableConditionFilter
284
+ fields={[searchableOwnerField]}
285
+ conditions={[]}
286
+ onConditionsChange={onConditionsChange}
287
+ />,
288
+ );
289
+
290
+ fireEvent.click(screen.getByText("Add filter"));
291
+ fireEvent.change(screen.getByPlaceholderText("Search options..."), {
292
+ target: { value: "bob" },
293
+ });
294
+
295
+ expect(screen.queryByText("Alice Adams")).toBeNull();
296
+ fireEvent.click(screen.getByText("Bob Brown"));
297
+ fireEvent.click(screen.getByText("Apply"));
298
+
299
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
300
+ expect(committed).toHaveLength(1);
301
+ expect(committed?.[0]).toMatchObject({
302
+ field: "owner",
303
+ operator: "eq",
304
+ value: "bob@example.com",
305
+ });
306
+ });
307
+
308
+ it("uses DataTableFilter threshold semantics for option search", () => {
309
+ const thresholdField: ConditionFieldDef = {
310
+ id: "threshold",
311
+ label: "Threshold",
312
+ type: "select",
313
+ searchable: { threshold: 2 },
314
+ options: [
315
+ { label: "One", value: "one" },
316
+ { label: "Two", value: "two" },
317
+ ],
318
+ };
319
+ const aboveThresholdField: ConditionFieldDef = {
320
+ ...thresholdField,
321
+ id: "above_threshold",
322
+ options: [
323
+ { label: "One", value: "one" },
324
+ { label: "Two", value: "two" },
325
+ { label: "Three", value: "three" },
326
+ ],
327
+ };
328
+
329
+ const { rerender } = render(
330
+ <DataTableConditionFilter
331
+ fields={[thresholdField]}
332
+ conditions={[]}
333
+ onConditionsChange={onConditionsChange}
334
+ />,
335
+ );
336
+
337
+ fireEvent.click(screen.getByText("Add filter"));
338
+ expect(screen.queryByPlaceholderText("Search options...")).toBeNull();
339
+
340
+ rerender(
341
+ <DataTableConditionFilter
342
+ fields={[aboveThresholdField]}
343
+ conditions={[]}
344
+ onConditionsChange={onConditionsChange}
345
+ />,
346
+ );
347
+ expect(screen.getByPlaceholderText("Search options...")).toBeDefined();
348
+ });
349
+
350
+ it("does not apply a stale search query after switching to a non-searchable select field", () => {
351
+ const searchableOwnerField: ConditionFieldDef = {
352
+ id: "owner",
353
+ label: "Owner",
354
+ type: "select",
355
+ searchable: true,
356
+ options: [
357
+ { label: "Alice Adams", value: "alice@example.com" },
358
+ { label: "Bob Brown", value: "bob@example.com" },
359
+ ],
360
+ };
361
+ const statusField: ConditionFieldDef = {
362
+ id: "status",
363
+ label: "Status",
364
+ type: "select",
365
+ searchable: false,
366
+ options: [
367
+ { label: "Active", value: "active" },
368
+ { label: "Inactive", value: "inactive" },
369
+ ],
370
+ };
371
+
372
+ const { container } = render(
373
+ <DataTableConditionFilter
374
+ fields={[searchableOwnerField, statusField]}
375
+ conditions={[]}
376
+ onConditionsChange={onConditionsChange}
377
+ />,
378
+ );
379
+
380
+ fireEvent.click(screen.getByText("Add filter"));
381
+ fireEvent.change(screen.getByPlaceholderText("Search options..."), {
382
+ target: { value: "bob" },
383
+ });
384
+ chooseField(container, "Status");
385
+
386
+ expect(screen.queryByPlaceholderText("Search options...")).toBeNull();
387
+ expect(screen.getByText("Active")).toBeDefined();
388
+ expect(screen.getByText("Inactive")).toBeDefined();
389
+ expect(screen.queryByText("No options")).toBeNull();
390
+ });
391
+
392
+ it("renders multi-select checkboxes, toggles multiple options, and commits an in condition", () => {
393
+ const { container } = renderOptionFilter();
394
+ chooseField(container, "Industry");
395
+
396
+ expectFieldTriggerHasNoFieldTypeIcon(container);
397
+
398
+ expect(getOperators(multiSelectField)).toEqual(["in", "is_null", "is_not_null"]);
399
+
400
+ const financeCheckbox = screen.getByRole("checkbox", { name: "Finance" });
401
+ const healthcareCheckbox = screen.getByRole("checkbox", { name: "Healthcare" });
402
+ fireEvent.click(financeCheckbox);
403
+ fireEvent.click(healthcareCheckbox);
404
+
405
+ expect(financeCheckbox.getAttribute("aria-checked")).toBe("true");
406
+ expect(healthcareCheckbox.getAttribute("aria-checked")).toBe("true");
407
+
408
+ fireEvent.click(screen.getByText("Apply"));
409
+
410
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
411
+ expect(committed).toHaveLength(1);
412
+ expect(committed?.[0]).toMatchObject({
413
+ field: "industry",
414
+ operator: "in",
415
+ value: ["Finance", "healthcare"],
416
+ });
417
+ });
418
+
419
+ it("omits incomplete multi-select conditions until at least one option is selected", () => {
420
+ const { container } = renderOptionFilter();
421
+ chooseField(container, "Industry");
422
+ fireEvent.click(screen.getByText("Apply"));
423
+
424
+ expect(onConditionsChange.mock.calls.at(-1)?.[0]).toEqual([]);
425
+ });
426
+
200
427
  it("Enter in the value field commits the current draft row", () => {
201
428
  render(
202
429
  <DataTableConditionFilter
@@ -380,9 +607,13 @@ describe("DataTableConditionFilter", () => {
380
607
  expect(screen.getAllByText("And")).toHaveLength(2);
381
608
  });
382
609
 
383
- it("operator options are scalar only and exclude in/contains", () => {
610
+ it("operator options respect the selected field definition", () => {
384
611
  expect(getOperators(textField)).toEqual(["eq", "neq", "is_null", "is_not_null"]);
385
- expect(getOperators({ ...textField, operators: ["eq", "in", "is_null"] })).toEqual(["eq", "is_null"]);
612
+ expect(getOperators({ ...textField, operators: ["eq", "in", "is_null"] })).toEqual([
613
+ "eq",
614
+ "in",
615
+ "is_null",
616
+ ]);
386
617
 
387
618
  const { container } = render(
388
619
  <DataTableConditionFilter
@@ -392,9 +623,10 @@ describe("DataTableConditionFilter", () => {
392
623
  />,
393
624
  );
394
625
 
395
- const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
396
- .map((el) => el.textContent);
397
- expect(optionTexts).not.toContain("contains");
626
+ const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]')).map(
627
+ (el) => el.textContent,
628
+ );
629
+ expect(optionTexts).not.toContain("is any of");
398
630
  expect(optionTexts).toContain("is empty");
399
631
  });
400
632
 
@@ -413,7 +645,7 @@ describe("DataTableConditionFilter", () => {
413
645
  expect(optionTexts).toContain("≥");
414
646
  expect(optionTexts).toContain("<");
415
647
  expect(optionTexts).toContain("≤");
416
- expect(optionTexts).not.toContain("contains");
648
+ expect(optionTexts).not.toContain("is any of");
417
649
  });
418
650
 
419
651
  it("generateConditionId returns unique values", () => {
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import React from "react"
3
+ import { render, screen } from "@testing-library/react"
4
+
5
+ import { EntityPanel, EntityPanelHeader } from "../entity-panel"
6
+
7
+ function renderHeader(node: React.ReactNode) {
8
+ return render(
9
+ <EntityPanel isOpen onClose={vi.fn()}>
10
+ {node}
11
+ </EntityPanel>,
12
+ )
13
+ }
14
+
15
+ describe("EntityPanelHeader", () => {
16
+ it("preserves headerAction and panel controls", () => {
17
+ renderHeader(
18
+ <EntityPanelHeader
19
+ title="Acme Corp"
20
+ headerAction={<button type="button">Mercury Admin</button>}
21
+ />,
22
+ )
23
+
24
+ expect(screen.getByText("Acme Corp")).toBeTruthy()
25
+ expect(screen.getByRole("button", { name: "Mercury Admin" })).toBeTruthy()
26
+ expect(screen.getByTitle("Copy Link")).toBeTruthy()
27
+ expect(screen.getByTitle("Wide")).toBeTruthy()
28
+ expect(screen.getByTitle("Close")).toBeTruthy()
29
+ })
30
+
31
+ it("renders subtitle and secondary action on the secondary row", () => {
32
+ renderHeader(
33
+ <EntityPanelHeader
34
+ title="Acme Corp"
35
+ subtitle="Preferred account · Treasury"
36
+ headerSecondaryAction={<button type="button">Quick action</button>}
37
+ />,
38
+ )
39
+
40
+ expect(screen.getByText("Preferred account · Treasury")).toBeTruthy()
41
+ expect(screen.getByRole("button", { name: "Quick action" })).toBeTruthy()
42
+ expect(screen.getByTitle("Copy Link")).toBeTruthy()
43
+ })
44
+ })
@@ -157,6 +157,36 @@ describe("SignalPriorityPopover", () => {
157
157
  expect(neutralRow.textContent).toContain("50")
158
158
  })
159
159
 
160
+
161
+ it("keeps default direction and score labels unchanged", () => {
162
+ render(<SignalPriorityPopover {...defaultProps} />)
163
+ fireEvent.click(screen.getByTestId("priority-popover-trigger"))
164
+
165
+ const row = screen.getByTestId("factor-row-test_severity")
166
+ expect(row.textContent).toContain("Raises")
167
+ expect(row.textContent).toContain("85")
168
+ expect(row.textContent).toContain("/100")
169
+ })
170
+
171
+ it("renders custom direction and display value labels", () => {
172
+ const customFactors: PriorityFactor[] = [
173
+ {
174
+ ...mockFactors[0],
175
+ directionLabel: "Raises urgency",
176
+ displayValueLabel: "Open window",
177
+ score: 0,
178
+ },
179
+ ]
180
+
181
+ render(<SignalPriorityPopover {...defaultProps} factors={customFactors} />)
182
+ fireEvent.click(screen.getByTestId("priority-popover-trigger"))
183
+
184
+ const row = screen.getByTestId("factor-row-test_severity")
185
+ expect(row.textContent).toContain("Raises urgency")
186
+ expect(row.textContent).toContain("Open window")
187
+ expect(row.textContent).not.toContain("Raises0/100")
188
+ })
189
+
160
190
  it("renders Contributing factors section label", () => {
161
191
  render(<SignalPriorityPopover {...defaultProps} />)
162
192
  fireEvent.click(screen.getByTestId("priority-popover-trigger"))
@@ -32,6 +32,10 @@ export interface AccountContactsPopoverProps {
32
32
  onSelectTo?: (contact: SuggestedContact) => void
33
33
  onSelectCc?: (contact: SuggestedContact) => void
34
34
  onSelectBcc?: (contact: SuggestedContact) => void
35
+ /** Label for the default contact row action. Defaults to "Add" or "Switch" when onSelectSwitch is provided. */
36
+ defaultSelectLabel?: string
37
+ /** Optional replacement-selection callback. When provided, row clicks call this instead of additive onSelect/onSelectTo. */
38
+ onSelectSwitch?: (contact: SuggestedContact) => void
35
39
  onViewAll?: () => void
36
40
  onOpenRecentActivity?: () => void
37
41
  trigger: React.ReactNode
@@ -44,6 +48,8 @@ export function AccountContactsPopover({
44
48
  onSelectTo,
45
49
  onSelectCc,
46
50
  onSelectBcc,
51
+ defaultSelectLabel,
52
+ onSelectSwitch,
47
53
  onViewAll,
48
54
  onOpenRecentActivity,
49
55
  trigger,
@@ -52,6 +58,15 @@ export function AccountContactsPopover({
52
58
  const [open, setOpen] = React.useState(false)
53
59
  const triggerRef = React.useRef<HTMLDivElement>(null)
54
60
  const [popoverStyle, setPopoverStyle] = React.useState<React.CSSProperties>({})
61
+ const resolvedDefaultSelectLabel = defaultSelectLabel ?? (onSelectSwitch ? "Switch" : undefined)
62
+ const handleDefaultSelect = React.useCallback((contact: SuggestedContact) => {
63
+ if (onSelectSwitch) {
64
+ onSelectSwitch(contact)
65
+ } else {
66
+ (onSelectTo ?? onSelect)(contact)
67
+ }
68
+ setOpen(false)
69
+ }, [onSelect, onSelectSwitch, onSelectTo])
55
70
 
56
71
  React.useEffect(() => {
57
72
  if (open && triggerRef.current) {
@@ -84,7 +99,8 @@ export function AccountContactsPopover({
84
99
  <div
85
100
  key={i}
86
101
  role="button"
87
- onClick={() => { (onSelectTo ?? onSelect)(c); setOpen(false) }}
102
+ onClick={() => handleDefaultSelect(c)}
103
+ aria-label={resolvedDefaultSelectLabel ? `${resolvedDefaultSelectLabel} ${c.name}` : undefined}
88
104
  className="flex items-center gap-3 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors cursor-pointer"
89
105
  >
90
106
  <div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium text-muted-foreground shrink-0">
@@ -115,6 +131,18 @@ export function AccountContactsPopover({
115
131
  )}
116
132
  </div>
117
133
  <div className="ml-2 flex items-center gap-1.5 shrink-0">
134
+ {resolvedDefaultSelectLabel && (
135
+ <button
136
+ type="button"
137
+ onClick={(e) => {
138
+ e.stopPropagation()
139
+ handleDefaultSelect(c)
140
+ }}
141
+ className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
142
+ >
143
+ {resolvedDefaultSelectLabel}
144
+ </button>
145
+ )}
118
146
  {onSelectTo && (
119
147
  <button
120
148
  type="button"