@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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/contextual-quick-action-launcher.d.ts +0 -6
- package/dist/components/contextual-quick-action-launcher.js +0 -1
- package/dist/components/contextual-quick-action-launcher.js.map +1 -1
- package/dist/components/data-table.js +1 -15
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/linked-entity-cell.d.ts +16 -1
- package/dist/components/linked-entity-cell.js +33 -3
- package/dist/components/linked-entity-cell.js.map +1 -1
- package/dist/components/owner-chips.js +1 -1
- package/dist/components/owner-chips.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/virtualized-data-table.js +7 -11
- package/dist/components/virtualized-data-table.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/entity-color.d.ts +3 -0
- package/dist/lib/entity-color.js +19 -0
- package/dist/lib/entity-color.js.map +1 -0
- package/package.json +1 -1
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +0 -14
- package/src/components/__tests__/linked-entity-cell.test.tsx +143 -0
- package/src/components/__tests__/owner-chips.test.tsx +0 -10
- package/src/components/__tests__/virtualized-data-table.test.tsx +124 -0
- package/src/components/contextual-quick-action-launcher.tsx +0 -7
- package/src/components/data-table.tsx +1 -17
- package/src/components/linked-entity-cell.tsx +44 -2
- package/src/components/owner-chips.tsx +1 -1
- package/src/components/virtualized-data-table.tsx +15 -14
- package/src/index.ts +1 -0
- 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=
|
|
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
|
|
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-
|
|
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(
|
|
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
|
@@ -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
|
+
}
|