@handled-ai/design-system 0.20.34 → 0.21.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 (34) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/contextual-quick-action-launcher.d.ts +0 -6
  4. package/dist/components/contextual-quick-action-launcher.js +0 -1
  5. package/dist/components/contextual-quick-action-launcher.js.map +1 -1
  6. package/dist/components/data-table.js +1 -15
  7. package/dist/components/data-table.js.map +1 -1
  8. package/dist/components/linked-entity-cell.d.ts +16 -1
  9. package/dist/components/linked-entity-cell.js +33 -3
  10. package/dist/components/linked-entity-cell.js.map +1 -1
  11. package/dist/components/owner-chips.js +1 -1
  12. package/dist/components/owner-chips.js.map +1 -1
  13. package/dist/components/pill.d.ts +1 -1
  14. package/dist/components/tabs.d.ts +1 -1
  15. package/dist/components/virtualized-data-table.js +7 -11
  16. package/dist/components/virtualized-data-table.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/entity-color.d.ts +3 -0
  21. package/dist/lib/entity-color.js +19 -0
  22. package/dist/lib/entity-color.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +0 -14
  25. package/src/components/__tests__/linked-entity-cell.test.tsx +143 -0
  26. package/src/components/__tests__/owner-chips.test.tsx +0 -10
  27. package/src/components/__tests__/virtualized-data-table.test.tsx +124 -0
  28. package/src/components/contextual-quick-action-launcher.tsx +0 -7
  29. package/src/components/data-table.tsx +1 -17
  30. package/src/components/linked-entity-cell.tsx +44 -2
  31. package/src/components/owner-chips.tsx +1 -1
  32. package/src/components/virtualized-data-table.tsx +15 -14
  33. package/src/index.ts +1 -0
  34. package/src/lib/entity-color.ts +22 -0
@@ -465,6 +465,130 @@ describe("VirtualizedDataTable — consistent header styling", () => {
465
465
  expect(triggers[0].className).toContain("group-hover/header:opacity-100");
466
466
  });
467
467
 
468
+ it("sortable header label carries text-xs so it does not inherit a larger button font", () => {
469
+ const columns: ColumnDef<TestRow, unknown>[] = [
470
+ {
471
+ accessorKey: "name",
472
+ header: "Name",
473
+ size: 200,
474
+ meta: { sortKey: "name" },
475
+ },
476
+ ];
477
+ const { container } = render(
478
+ <VirtualizedDataTable
479
+ columns={columns}
480
+ data={testData}
481
+ height={300}
482
+ onColumnSort={vi.fn()}
483
+ activeSortColumn="name"
484
+ activeSortDirection="asc"
485
+ />,
486
+ );
487
+
488
+ const sortButton = Array.from(
489
+ container.querySelectorAll('[role="columnheader"] button'),
490
+ ).find((b) => b.getAttribute("aria-label") !== "Column actions")!;
491
+ // The button itself and its inner label span should carry text-xs
492
+ expect(sortButton.className).toContain("text-xs");
493
+ const labelSpan = sortButton.querySelector("span")!;
494
+ expect(labelSpan.className).toContain("text-xs");
495
+ });
496
+
497
+ it("sortable and non-sortable header labels share the same size class", () => {
498
+ const columns: ColumnDef<TestRow, unknown>[] = [
499
+ {
500
+ accessorKey: "name",
501
+ header: "Name",
502
+ size: 200,
503
+ meta: { sortKey: "name" },
504
+ },
505
+ {
506
+ accessorKey: "value",
507
+ header: "Value",
508
+ size: 150,
509
+ // non-sortable, non-hideable → plain span
510
+ enableSorting: false,
511
+ enableHiding: false,
512
+ },
513
+ ];
514
+ const { container } = render(
515
+ <VirtualizedDataTable
516
+ columns={columns}
517
+ data={testData}
518
+ height={300}
519
+ onColumnSort={vi.fn()}
520
+ activeSortColumn="name"
521
+ activeSortDirection="asc"
522
+ />,
523
+ );
524
+
525
+ const headers = container.querySelectorAll('[role="columnheader"]');
526
+ // Sortable label span lives inside the sort button
527
+ const sortButton = Array.from(
528
+ headers[0].querySelectorAll("button"),
529
+ ).find((b) => b.getAttribute("aria-label") !== "Column actions")!;
530
+ const sortableLabel = sortButton.querySelector("span")!;
531
+ // Non-sortable label is the direct span in the header cell
532
+ const nonSortableLabel = headers[1].querySelector("span")!;
533
+
534
+ expect(sortableLabel.className).toContain("text-xs");
535
+ expect(nonSortableLabel.className).toContain("text-xs");
536
+ });
537
+
538
+ it("string header renders a native title equal to the header text", () => {
539
+ const columns: ColumnDef<TestRow, unknown>[] = [
540
+ {
541
+ accessorKey: "name",
542
+ header: "Account Name",
543
+ size: 200,
544
+ meta: { sortKey: "name" },
545
+ },
546
+ ];
547
+ const { container } = render(
548
+ <VirtualizedDataTable
549
+ columns={columns}
550
+ data={testData}
551
+ height={300}
552
+ onColumnSort={vi.fn()}
553
+ activeSortColumn="name"
554
+ activeSortDirection="asc"
555
+ />,
556
+ );
557
+
558
+ const sortButton = Array.from(
559
+ container.querySelectorAll('[role="columnheader"] button'),
560
+ ).find((b) => b.getAttribute("aria-label") !== "Column actions")!;
561
+ const labelSpan = sortButton.querySelector("span")!;
562
+ expect(labelSpan.getAttribute("title")).toBe("Account Name");
563
+ });
564
+
565
+ it("non-string ReactNode header renders no title attribute", () => {
566
+ const columns: ColumnDef<TestRow, unknown>[] = [
567
+ {
568
+ accessorKey: "name",
569
+ header: () => <em>Custom</em>,
570
+ size: 200,
571
+ meta: { sortKey: "name" },
572
+ },
573
+ ];
574
+ const { container } = render(
575
+ <VirtualizedDataTable
576
+ columns={columns}
577
+ data={testData}
578
+ height={300}
579
+ onColumnSort={vi.fn()}
580
+ activeSortColumn="name"
581
+ activeSortDirection="asc"
582
+ />,
583
+ );
584
+
585
+ const sortButton = Array.from(
586
+ container.querySelectorAll('[role="columnheader"] button'),
587
+ ).find((b) => b.getAttribute("aria-label") !== "Column actions")!;
588
+ const labelSpan = sortButton.querySelector("span")!;
589
+ expect(labelSpan.hasAttribute("title")).toBe(false);
590
+ });
591
+
468
592
  it("header cell container uses same classes for all columns regardless of sort state", () => {
469
593
  const columns: ColumnDef<TestRow, unknown>[] = [
470
594
  {
@@ -20,12 +20,6 @@ export interface ContextualQuickActionItem {
20
20
  disabled?: boolean
21
21
  disabledReason?: string
22
22
  meta?: React.ReactNode
23
- /**
24
- * Optional stable test id stamped on the rendered menu item. Lets callers
25
- * preserve an existing testid when an action moves into the launcher (e.g.
26
- * the inbox `case-sync-button`).
27
- */
28
- testId?: string
29
23
  }
30
24
 
31
25
  export type ContextualQuickActionLauncherVariant = "default" | "case-panel"
@@ -199,7 +193,6 @@ function ContextualQuickActionLauncher({
199
193
  <DropdownMenuItem
200
194
  key={item.id}
201
195
  disabled={item.disabled}
202
- data-testid={item.testId}
203
196
  onSelect={(event) => handleSelect(item, event)}
204
197
  className={cn(
205
198
  isCasePanel
@@ -23,6 +23,7 @@ import {
23
23
  } from "@tanstack/react-table"
24
24
 
25
25
  import { cn } from "../lib/utils"
26
+ import { getEntityColor } from "../lib/entity-color"
26
27
  import { Badge } from "./badge"
27
28
  import {
28
29
  DataTableQuickViews,
@@ -407,23 +408,6 @@ const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {
407
408
  opportunityCount: false,
408
409
  }
409
410
 
410
- function getEntityColor(name: string) {
411
- const colors = [
412
- "bg-muted text-muted-foreground",
413
- "bg-gray-100 text-gray-600",
414
- "bg-zinc-100 text-zinc-600",
415
- "bg-blue-50 text-blue-600",
416
- "bg-indigo-50 text-indigo-600",
417
- "bg-violet-50 text-violet-600",
418
- ]
419
-
420
- let hash = 0
421
- for (let i = 0; i < name.length; i += 1) {
422
- hash = name.charCodeAt(i) + ((hash << 5) - hash)
423
- }
424
- return colors[Math.abs(hash) % colors.length]
425
- }
426
-
427
411
  function getIndustryColor(industry: string) {
428
412
  const colors: Record<string, string> = {
429
413
  "E-commerce": "bg-emerald-50 text-emerald-700 border-emerald-100",
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { ExternalLink } from "lucide-react"
5
5
 
6
6
  import { cn } from "../lib/utils"
7
+ import { getEntityColor } from "../lib/entity-color"
7
8
 
8
9
  export interface LinkedEntityCellProps extends React.HTMLAttributes<HTMLDivElement> {
9
10
  name: React.ReactNode
@@ -13,6 +14,21 @@ export interface LinkedEntityCellProps extends React.HTMLAttributes<HTMLDivEleme
13
14
  icon?: React.ReactNode
14
15
  external?: boolean
15
16
  onNavigate?: () => void
17
+ /**
18
+ * When provided, renders a small colored rounded initial badge before the
19
+ * name. The full string is used as the color seed; the displayed letter is
20
+ * the first character. Omit to render no badge.
21
+ */
22
+ avatarLabel?: string
23
+ /**
24
+ * Optional node rendered after the name (a trailing slot), e.g. a separate
25
+ * action link. Does not affect the name's own `href`.
26
+ */
27
+ trailingAction?: React.ReactNode
28
+ /**
29
+ * Escape-hatch class merged onto the name span/link. Defaults to `text-sm`.
30
+ */
31
+ nameClassName?: string
16
32
  }
17
33
 
18
34
  export function LinkedEntityCell({
@@ -23,6 +39,9 @@ export function LinkedEntityCell({
23
39
  icon,
24
40
  external = false,
25
41
  onNavigate,
42
+ avatarLabel,
43
+ trailingAction,
44
+ nameClassName,
26
45
  className,
27
46
  ...props
28
47
  }: LinkedEntityCellProps) {
@@ -39,6 +58,18 @@ export function LinkedEntityCell({
39
58
  className={cn("flex min-w-0 items-center gap-2", className)}
40
59
  {...props}
41
60
  >
61
+ {avatarLabel ? (
62
+ <span
63
+ data-slot="linked-entity-cell-avatar"
64
+ aria-hidden="true"
65
+ className={cn(
66
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded text-[10px] font-bold",
67
+ getEntityColor(avatarLabel),
68
+ )}
69
+ >
70
+ {avatarLabel.slice(0, 1)}
71
+ </span>
72
+ ) : null}
42
73
  {icon ? (
43
74
  <span data-slot="linked-entity-cell-icon" className="shrink-0 text-muted-foreground">
44
75
  {icon}
@@ -52,12 +83,18 @@ export function LinkedEntityCell({
52
83
  target={external ? "_blank" : undefined}
53
84
  rel={external ? "noreferrer" : undefined}
54
85
  onClick={onNavigate}
55
- className="inline-flex max-w-full items-center gap-1 truncate font-medium text-foreground underline-offset-4 hover:text-primary hover:underline"
86
+ className={cn(
87
+ "inline-flex max-w-full items-center gap-1 truncate text-sm font-medium text-foreground underline-offset-4 hover:text-primary hover:underline",
88
+ nameClassName,
89
+ )}
56
90
  >
57
91
  {content}
58
92
  </a>
59
93
  ) : (
60
- <span data-slot="linked-entity-cell-name" className="block truncate font-medium text-foreground">
94
+ <span
95
+ data-slot="linked-entity-cell-name"
96
+ className={cn("block truncate text-sm font-medium text-foreground", nameClassName)}
97
+ >
61
98
  {name}
62
99
  </span>
63
100
  )}
@@ -69,6 +106,11 @@ export function LinkedEntityCell({
69
106
  </div>
70
107
  ) : null}
71
108
  </div>
109
+ {trailingAction ? (
110
+ <span data-slot="linked-entity-cell-trailing" className="shrink-0">
111
+ {trailingAction}
112
+ </span>
113
+ ) : null}
72
114
  </div>
73
115
  )
74
116
  }
@@ -73,7 +73,7 @@ function SalesforceMark({ size = 13 }: { size?: number }) {
73
73
 
74
74
  function OwnerAvatar({ person, size = "sm" }: { person: OwnerPerson; size?: "sm" | "default" }) {
75
75
  return (
76
- <Avatar size={size} className="ring-background ring-1">
76
+ <Avatar size={size} className="ring-background ring-2">
77
77
  {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
78
78
  <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
79
79
  {getInitials({ name: person.name, email: person.email })}
@@ -270,6 +270,13 @@ export function VirtualizedDataTable<TData>({
270
270
  onColumnSort!(sortKey!, newDir)
271
271
  } : undefined
272
272
 
273
+ const headerDef = header.column.columnDef.header
274
+ // When the header is a plain string, expose it as a native title
275
+ // tooltip so truncated headers remain readable. Non-string
276
+ // ReactNode headers render no title.
277
+ const headerTitle =
278
+ typeof headerDef === "string" ? headerDef : undefined
279
+
273
280
  return (
274
281
  <div
275
282
  key={header.id}
@@ -290,25 +297,22 @@ export function VirtualizedDataTable<TData>({
290
297
  {canServerSort ? (
291
298
  <button
292
299
  type="button"
293
- className="flex min-w-0 flex-1 items-center gap-1 hover:text-foreground transition-colors"
300
+ className="flex min-w-0 flex-1 items-center gap-1 text-xs leading-4 hover:text-foreground transition-colors"
294
301
  onClick={handleHeaderClick}
295
302
  >
296
- <span className="min-w-0 truncate">
297
- {flexRender(header.column.columnDef.header, header.getContext())}
303
+ <span className="min-w-0 truncate text-xs leading-4" title={headerTitle}>
304
+ {flexRender(headerDef, header.getContext())}
298
305
  </span>
299
306
  {sortIcon}
300
307
  </button>
301
308
  ) : header.column.getCanSort() ? (
302
309
  <button
303
310
  type="button"
304
- className="flex min-w-0 flex-1 items-center gap-1 hover:text-foreground transition-colors"
311
+ className="flex min-w-0 flex-1 items-center gap-1 text-xs leading-4 hover:text-foreground transition-colors"
305
312
  onClick={header.column.getToggleSortingHandler()}
306
313
  >
307
- <span className="min-w-0 truncate">
308
- {flexRender(
309
- header.column.columnDef.header,
310
- header.getContext(),
311
- )}
314
+ <span className="min-w-0 truncate text-xs leading-4" title={headerTitle}>
315
+ {flexRender(headerDef, header.getContext())}
312
316
  </span>
313
317
  {header.column.getIsSorted() === "asc" ? (
314
318
  <ArrowUp className="w-3 h-3 shrink-0" />
@@ -319,11 +323,8 @@ export function VirtualizedDataTable<TData>({
319
323
  )}
320
324
  </button>
321
325
  ) : (
322
- <span className="min-w-0 flex-1 truncate">
323
- {flexRender(
324
- header.column.columnDef.header,
325
- header.getContext(),
326
- )}
326
+ <span className="min-w-0 flex-1 truncate text-xs leading-4" title={headerTitle}>
327
+ {flexRender(headerDef, header.getContext())}
327
328
  </span>
328
329
  )}
329
330
  {(canServerSort || header.column.getCanSort() || header.column.getCanHide()) && (
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  // Utilities
7
7
  export { cn } from "./lib/utils"
8
+ export { getEntityColor } from "./lib/entity-color"
8
9
  export { BRAND_ICONS, BRAND_GRAPHICS } from "./lib/icons"
9
10
  export { displayName, getInitials, shortName, type ProfileLike } from "./lib/user-display"
10
11
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Deterministically maps an entity name to a muted Tailwind color class pair
3
+ * (background + text) used for entity avatar/initial badges. The same input
4
+ * always yields the same color so a given entity keeps a stable color across
5
+ * the app.
6
+ */
7
+ const COLORS = [
8
+ "bg-muted text-muted-foreground",
9
+ "bg-gray-100 text-gray-600",
10
+ "bg-zinc-100 text-zinc-600",
11
+ "bg-blue-50 text-blue-600",
12
+ "bg-indigo-50 text-indigo-600",
13
+ "bg-violet-50 text-violet-600",
14
+ ]
15
+
16
+ export function getEntityColor(name: string) {
17
+ let hash = 0
18
+ for (let i = 0; i < name.length; i += 1) {
19
+ hash = name.charCodeAt(i) + ((hash << 5) - hash)
20
+ }
21
+ return COLORS[Math.abs(hash) % COLORS.length]
22
+ }