@handled-ai/design-system 0.18.11 → 0.18.13
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/account-contacts-popover.d.ts +5 -1
- package/dist/components/account-contacts-popover.js +25 -4
- package/dist/components/account-contacts-popover.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/data-table-condition-filter.d.ts +2 -1
- package/dist/components/data-table-condition-filter.js +23 -41
- package/dist/components/data-table-condition-filter.js.map +1 -1
- package/dist/components/data-table-filter.js +8 -9
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/entity-panel.d.ts +2 -1
- package/dist/components/entity-panel.js +52 -45
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +4 -4
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +9 -3
- package/dist/prototype/prototype-inbox-view.js +19 -6
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-BT6CPYNs.d.ts → signal-priority-popover-B5b-XZ7i.d.ts} +10 -0
- package/package.json +1 -2
- package/src/components/__tests__/account-contacts-popover.test.tsx +79 -0
- package/src/components/__tests__/data-table-condition-filter.test.tsx +96 -0
- package/src/components/__tests__/data-table-filter.test.tsx +45 -0
- package/src/components/__tests__/entity-panel-header.test.tsx +44 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +30 -0
- package/src/components/account-contacts-popover.tsx +29 -1
- package/src/components/data-table-condition-filter.tsx +32 -47
- package/src/components/data-table-filter.tsx +7 -10
- package/src/components/entity-panel.tsx +56 -40
- package/src/components/signal-priority-popover.tsx +15 -4
- package/src/prototype/__tests__/detail-view-title-slots.test.tsx +31 -0
- package/src/prototype/prototype-config.ts +6 -0
- package/src/prototype/prototype-inbox-view.tsx +35 -6
|
@@ -367,4 +367,49 @@ describe("DataTableFilter", () => {
|
|
|
367
367
|
|
|
368
368
|
expect(screen.getByText("Advanced filters")).toBeDefined();
|
|
369
369
|
});
|
|
370
|
+
|
|
371
|
+
it("renders badge with bg-muted and text-foreground when activeCount > 0", () => {
|
|
372
|
+
render(
|
|
373
|
+
<DataTableFilter
|
|
374
|
+
categories={[
|
|
375
|
+
{
|
|
376
|
+
id: "status",
|
|
377
|
+
label: "Status",
|
|
378
|
+
icon: ListFilter,
|
|
379
|
+
options: ["Open", "Closed"],
|
|
380
|
+
},
|
|
381
|
+
]}
|
|
382
|
+
selectedFilters={{ status: ["Open"] }}
|
|
383
|
+
onToggleFilter={() => {}}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const badge = screen.getByText("1");
|
|
388
|
+
expect(badge.tagName).toBe("SPAN");
|
|
389
|
+
expect(badge.className).toContain("bg-muted");
|
|
390
|
+
expect(badge.className).toContain("text-foreground");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("does not render badge when activeCount is 0", () => {
|
|
394
|
+
const { container } = render(
|
|
395
|
+
<DataTableFilter
|
|
396
|
+
categories={[
|
|
397
|
+
{
|
|
398
|
+
id: "status",
|
|
399
|
+
label: "Status",
|
|
400
|
+
icon: ListFilter,
|
|
401
|
+
options: ["Open", "Closed"],
|
|
402
|
+
},
|
|
403
|
+
]}
|
|
404
|
+
selectedFilters={{}}
|
|
405
|
+
onToggleFilter={() => {}}
|
|
406
|
+
/>
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const button = container.querySelector("button");
|
|
410
|
+
expect(button).not.toBeNull();
|
|
411
|
+
// The button should contain "Filter" text but no badge span with a count
|
|
412
|
+
const spans = button!.querySelectorAll("span.bg-muted");
|
|
413
|
+
expect(spans.length).toBe(0);
|
|
414
|
+
});
|
|
370
415
|
});
|
|
@@ -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={() =>
|
|
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"
|
|
@@ -2,16 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import {
|
|
5
|
-
CalendarDays,
|
|
6
5
|
Check,
|
|
7
|
-
DollarSign,
|
|
8
6
|
Eye,
|
|
9
|
-
Hash,
|
|
10
7
|
MoreHorizontal,
|
|
11
8
|
Plus,
|
|
12
9
|
Trash2,
|
|
13
|
-
Type,
|
|
14
|
-
type LucideIcon,
|
|
15
10
|
} from "lucide-react"
|
|
16
11
|
|
|
17
12
|
import { cn } from "../lib/utils"
|
|
@@ -103,6 +98,22 @@ const NUMERIC_OPERATORS: ConditionOperator[] = [
|
|
|
103
98
|
"is_not_null",
|
|
104
99
|
]
|
|
105
100
|
|
|
101
|
+
const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Escape", "Tab"])
|
|
102
|
+
|
|
103
|
+
export function shouldShowOptionSearch(
|
|
104
|
+
searchable: ConditionFieldDef["searchable"],
|
|
105
|
+
optionCount: number,
|
|
106
|
+
defaultThreshold = 8,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (searchable === true) return true
|
|
109
|
+
if (searchable === false) return false
|
|
110
|
+
const threshold =
|
|
111
|
+
typeof searchable === "object"
|
|
112
|
+
? (searchable.threshold ?? defaultThreshold)
|
|
113
|
+
: defaultThreshold
|
|
114
|
+
return optionCount > threshold
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]> = {
|
|
107
118
|
text: ["eq", "neq", "is_null", "is_not_null"],
|
|
108
119
|
number: NUMERIC_OPERATORS,
|
|
@@ -241,19 +252,6 @@ function getCommittedConditions(
|
|
|
241
252
|
.filter((condition) => isCompleteCondition(condition, fields))
|
|
242
253
|
}
|
|
243
254
|
|
|
244
|
-
const FIELD_ICON_BY_TYPE: Record<ConditionFieldDef["type"], LucideIcon> = {
|
|
245
|
-
text: Type,
|
|
246
|
-
number: Hash,
|
|
247
|
-
currency: DollarSign,
|
|
248
|
-
date: CalendarDays,
|
|
249
|
-
select: MoreHorizontal,
|
|
250
|
-
multi_select: MoreHorizontal,
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function getFieldIcon(type: ConditionFieldDef["type"]): LucideIcon {
|
|
254
|
-
return FIELD_ICON_BY_TYPE[type]
|
|
255
|
-
}
|
|
256
|
-
|
|
257
255
|
// ── Condition Row ──────────────────────────────────────────────
|
|
258
256
|
|
|
259
257
|
function getInputType(fieldType: ConditionFieldDef["type"]): "text" | "number" | "date" {
|
|
@@ -278,18 +276,6 @@ interface ConditionValueInputProps {
|
|
|
278
276
|
onCommit: () => void
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
function shouldShowOptionSearch(
|
|
282
|
-
fieldDef: ConditionFieldDef,
|
|
283
|
-
optionCount: number,
|
|
284
|
-
): boolean {
|
|
285
|
-
if (fieldDef.searchable === true) return true
|
|
286
|
-
if (fieldDef.searchable === false) return false
|
|
287
|
-
if (typeof fieldDef.searchable === "object") {
|
|
288
|
-
return optionCount >= (fieldDef.searchable.threshold ?? 8)
|
|
289
|
-
}
|
|
290
|
-
return false
|
|
291
|
-
}
|
|
292
|
-
|
|
293
279
|
function SelectConditionValueInput({
|
|
294
280
|
condition,
|
|
295
281
|
fieldDef,
|
|
@@ -297,11 +283,15 @@ function SelectConditionValueInput({
|
|
|
297
283
|
}: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onSelectValueChange">) {
|
|
298
284
|
const [query, setQuery] = React.useState("")
|
|
299
285
|
const options = normalizeFieldOptions(fieldDef)
|
|
300
|
-
const
|
|
286
|
+
const showSearch = shouldShowOptionSearch(fieldDef.searchable, options.length)
|
|
287
|
+
const normalizedQuery = showSearch ? query.trim().toLowerCase() : ""
|
|
301
288
|
const filteredOptions = normalizedQuery
|
|
302
289
|
? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
|
303
290
|
: options
|
|
304
|
-
|
|
291
|
+
|
|
292
|
+
React.useEffect(() => {
|
|
293
|
+
setQuery("")
|
|
294
|
+
}, [fieldDef.id])
|
|
305
295
|
|
|
306
296
|
return (
|
|
307
297
|
<Select
|
|
@@ -318,7 +308,11 @@ function SelectConditionValueInput({
|
|
|
318
308
|
value={query}
|
|
319
309
|
onChange={(event) => setQuery(event.target.value)}
|
|
320
310
|
onClick={(event) => event.stopPropagation()}
|
|
321
|
-
onKeyDown={(event) =>
|
|
311
|
+
onKeyDown={(event) => {
|
|
312
|
+
if (!NAV_KEYS.has(event.key)) {
|
|
313
|
+
event.stopPropagation()
|
|
314
|
+
}
|
|
315
|
+
}}
|
|
322
316
|
placeholder="Search options..."
|
|
323
317
|
className="h-7 text-xs"
|
|
324
318
|
/>
|
|
@@ -453,8 +447,6 @@ function ConditionRow({
|
|
|
453
447
|
const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
|
|
454
448
|
const operators = getOperators(fieldDef)
|
|
455
449
|
const isUnary = isUnaryOperator(condition.operator)
|
|
456
|
-
const FieldIcon = getFieldIcon(fieldDef.type)
|
|
457
|
-
|
|
458
450
|
const handleFieldChange = (newFieldId: string) => {
|
|
459
451
|
const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
|
|
460
452
|
if (!newFieldDef) return
|
|
@@ -512,21 +504,14 @@ function ConditionRow({
|
|
|
512
504
|
|
|
513
505
|
<Select value={condition.field} onValueChange={handleFieldChange}>
|
|
514
506
|
<SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
|
|
515
|
-
<FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
516
507
|
<SelectValue placeholder={fieldDef.label} />
|
|
517
508
|
</SelectTrigger>
|
|
518
509
|
<SelectContent>
|
|
519
|
-
{fields.map((field) =>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
525
|
-
{field.label}
|
|
526
|
-
</span>
|
|
527
|
-
</SelectItem>
|
|
528
|
-
)
|
|
529
|
-
})}
|
|
510
|
+
{fields.map((field) => (
|
|
511
|
+
<SelectItem key={field.id} value={field.id}>
|
|
512
|
+
{field.label}
|
|
513
|
+
</SelectItem>
|
|
514
|
+
))}
|
|
530
515
|
</SelectContent>
|
|
531
516
|
</Select>
|
|
532
517
|
|
|
@@ -9,6 +9,7 @@ import { cn } from "../lib/utils"
|
|
|
9
9
|
import { Button } from "./button"
|
|
10
10
|
import {
|
|
11
11
|
DataTableConditionFilter,
|
|
12
|
+
shouldShowOptionSearch,
|
|
12
13
|
type ConditionFieldDef,
|
|
13
14
|
type ConditionFilterValue,
|
|
14
15
|
} from "./data-table-condition-filter"
|
|
@@ -160,7 +161,7 @@ export function DataTableFilter({
|
|
|
160
161
|
<ListFilter className="h-3.5 w-3.5" />
|
|
161
162
|
Filter
|
|
162
163
|
{activeCount > 0 ? (
|
|
163
|
-
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
|
|
164
|
+
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold text-foreground">
|
|
164
165
|
{activeCount}
|
|
165
166
|
</span>
|
|
166
167
|
) : null}
|
|
@@ -214,15 +215,11 @@ export function DataTableFilter({
|
|
|
214
215
|
getOptionLabel(opt).toLowerCase().includes(subQuery)
|
|
215
216
|
)
|
|
216
217
|
: category.options
|
|
217
|
-
const shouldShowSubmenuSearch = (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
? (category.searchable.threshold ?? optionSearchThreshold)
|
|
223
|
-
: optionSearchThreshold
|
|
224
|
-
return category.options.length > threshold
|
|
225
|
-
})()
|
|
218
|
+
const shouldShowSubmenuSearch = shouldShowOptionSearch(
|
|
219
|
+
category.searchable,
|
|
220
|
+
category.options.length,
|
|
221
|
+
optionSearchThreshold,
|
|
222
|
+
)
|
|
226
223
|
|
|
227
224
|
return (
|
|
228
225
|
<DropdownMenuSub
|
|
@@ -135,14 +135,16 @@ export function EntityPanelHeader({
|
|
|
135
135
|
icon,
|
|
136
136
|
title,
|
|
137
137
|
badgeLabel,
|
|
138
|
-
subtitle
|
|
138
|
+
subtitle,
|
|
139
139
|
headerAction,
|
|
140
|
+
headerSecondaryAction,
|
|
140
141
|
}: {
|
|
141
142
|
icon?: React.ReactNode
|
|
142
143
|
title: string
|
|
143
144
|
badgeLabel?: string
|
|
144
145
|
subtitle?: string
|
|
145
146
|
headerAction?: React.ReactNode
|
|
147
|
+
headerSecondaryAction?: React.ReactNode
|
|
146
148
|
}) {
|
|
147
149
|
const { panelMode, cyclePanelMode, onClose } = useEntityPanel()
|
|
148
150
|
|
|
@@ -150,49 +152,63 @@ export function EntityPanelHeader({
|
|
|
150
152
|
panelMode === 'default' ? 'Wide' : panelMode === 'wide' ? 'Fullscreen' : 'Exit fullscreen'
|
|
151
153
|
|
|
152
154
|
return (
|
|
153
|
-
<div className="
|
|
154
|
-
<div className="flex items-center
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
<div className="mb-3 space-y-2">
|
|
156
|
+
<div className="flex items-center justify-between">
|
|
157
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
158
|
+
{icon ?? <CalendarDays className="w-5 h-5 text-muted-foreground shrink-0" />}
|
|
159
|
+
<h2 className="text-[16px] font-semibold text-foreground truncate">{title}</h2>
|
|
160
|
+
{badgeLabel && (
|
|
161
|
+
<Badge
|
|
162
|
+
variant="outline"
|
|
163
|
+
className="text-blue-600 border-blue-300 dark:border-blue-700 dark:text-blue-400 shadow-none px-2 py-0.5 text-[11px] font-medium shrink-0"
|
|
164
|
+
>
|
|
165
|
+
{badgeLabel}
|
|
166
|
+
</Badge>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex items-center gap-1 shrink-0 ml-4 text-muted-foreground">
|
|
170
|
+
{headerAction}
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
|
174
|
+
title="Copy Link"
|
|
175
|
+
>
|
|
176
|
+
<LinkIcon className="w-4 h-4" />
|
|
177
|
+
</button>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={cyclePanelMode}
|
|
181
|
+
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
|
182
|
+
title={sizeButtonTitle}
|
|
161
183
|
>
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
{panelMode === 'fullscreen' ? (
|
|
185
|
+
<Minimize2 className="w-4 h-4" />
|
|
186
|
+
) : (
|
|
187
|
+
<Maximize2 className="w-4 h-4" />
|
|
188
|
+
)}
|
|
189
|
+
</button>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={onClose}
|
|
193
|
+
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
|
194
|
+
title="Close"
|
|
195
|
+
>
|
|
196
|
+
<X className="w-4 h-4" />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
165
199
|
</div>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
|
171
|
-
title="Copy Link"
|
|
172
|
-
>
|
|
173
|
-
<LinkIcon className="w-4 h-4" />
|
|
174
|
-
</button>
|
|
175
|
-
<button
|
|
176
|
-
type="button"
|
|
177
|
-
onClick={cyclePanelMode}
|
|
178
|
-
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
|
179
|
-
title={sizeButtonTitle}
|
|
180
|
-
>
|
|
181
|
-
{panelMode === 'fullscreen' ? (
|
|
182
|
-
<Minimize2 className="w-4 h-4" />
|
|
200
|
+
{(subtitle || headerSecondaryAction) && (
|
|
201
|
+
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
202
|
+
{subtitle ? (
|
|
203
|
+
<p className="min-w-0 flex-1 text-xs text-muted-foreground">{subtitle}</p>
|
|
183
204
|
) : (
|
|
184
|
-
<
|
|
205
|
+
<div className="min-w-0 flex-1" />
|
|
185
206
|
)}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
title="Close"
|
|
192
|
-
>
|
|
193
|
-
<X className="w-4 h-4" />
|
|
194
|
-
</button>
|
|
195
|
-
</div>
|
|
207
|
+
{headerSecondaryAction ? (
|
|
208
|
+
<div className="flex shrink-0 items-center gap-2">{headerSecondaryAction}</div>
|
|
209
|
+
) : null}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
196
212
|
</div>
|
|
197
213
|
)
|
|
198
214
|
}
|
|
@@ -40,8 +40,12 @@ export interface PriorityFactor {
|
|
|
40
40
|
tone: "alert" | "warn" | "info"
|
|
41
41
|
/** Explicit semantic label - NOT inferred from score+weight. */
|
|
42
42
|
direction: "raises" | "lowers" | "neutral"
|
|
43
|
+
/** Optional display label for the direction text. Keeps semantic direction icon/color unchanged. */
|
|
44
|
+
directionLabel?: string
|
|
43
45
|
/** 0-100 */
|
|
44
46
|
score: number
|
|
47
|
+
/** Optional display label rendered instead of the numeric score cell. */
|
|
48
|
+
displayValueLabel?: string
|
|
45
49
|
/** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
|
|
46
50
|
rationale: string
|
|
47
51
|
}
|
|
@@ -168,12 +172,13 @@ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: Priori
|
|
|
168
172
|
const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
|
|
169
173
|
const toneClasses = TONE_ICON_CLASSES[factor.tone]
|
|
170
174
|
const directionClasses = DIRECTION_CLASSES[factor.direction]
|
|
171
|
-
const directionLabel =
|
|
175
|
+
const directionLabel = factor.directionLabel ?? (
|
|
172
176
|
factor.direction === "raises"
|
|
173
177
|
? "Raises"
|
|
174
178
|
: factor.direction === "lowers"
|
|
175
179
|
? "Lowers"
|
|
176
180
|
: "Neutral"
|
|
181
|
+
)
|
|
177
182
|
|
|
178
183
|
return (
|
|
179
184
|
<div
|
|
@@ -206,10 +211,16 @@ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: Priori
|
|
|
206
211
|
</span>
|
|
207
212
|
</div>
|
|
208
213
|
|
|
209
|
-
{/* Score number */}
|
|
214
|
+
{/* Score number / display label */}
|
|
210
215
|
<div className="flex items-center text-right">
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
{factor.displayValueLabel ? (
|
|
217
|
+
<span className="text-xs font-semibold text-foreground">{factor.displayValueLabel}</span>
|
|
218
|
+
) : (
|
|
219
|
+
<>
|
|
220
|
+
<span className="text-sm font-bold tabular-nums">{factor.score}</span>
|
|
221
|
+
<span className="text-xs font-normal text-muted-foreground">/100</span>
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
213
224
|
</div>
|
|
214
225
|
|
|
215
226
|
{/* empty grid cell under icon column */}
|
|
@@ -62,4 +62,35 @@ describe("DetailView title slots", () => {
|
|
|
62
62
|
|
|
63
63
|
expect(screen.getByRole("button", { name: "Quick action" })).toBeTruthy()
|
|
64
64
|
})
|
|
65
|
+
it("renders a full-width title action row without replacing title extra or metadata", () => {
|
|
66
|
+
renderDetailView({
|
|
67
|
+
renderTitleExtra: () => <span data-testid="title-extra">Compact extra</span>,
|
|
68
|
+
renderTitleActionRow: () => (
|
|
69
|
+
<button type="button">Full-width quick action</button>
|
|
70
|
+
),
|
|
71
|
+
renderMetadataExtra: () => <span data-testid="metadata-extra">Owner: Lee</span>,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(screen.getByTestId("title-extra").textContent).toBe("Compact extra")
|
|
75
|
+
expect(screen.getByRole("button", { name: "Full-width quick action" })).toBeTruthy()
|
|
76
|
+
expect(screen.getByTestId("metadata-extra").textContent).toBe("Owner: Lee")
|
|
77
|
+
expect(screen.getByTestId("priority-popover-trigger")).toBeTruthy()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("allows consumers to clarify the built-in account details button label", () => {
|
|
81
|
+
renderDetailView({
|
|
82
|
+
accountDetailsButtonLabel: (item) => (
|
|
83
|
+
<>
|
|
84
|
+
<span>View account details</span>
|
|
85
|
+
<span>{item.company}</span>
|
|
86
|
+
</>
|
|
87
|
+
),
|
|
88
|
+
getAccountDetailsButtonAriaLabel: (item) => `View account details for ${item.company}`,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const accountButton = screen.getByRole("button", { name: "View account details for Acme Corp" })
|
|
92
|
+
expect(accountButton.textContent).toContain("View account details")
|
|
93
|
+
expect(accountButton.textContent).toContain("Acme Corp")
|
|
94
|
+
})
|
|
95
|
+
|
|
65
96
|
})
|
|
@@ -229,8 +229,14 @@ export interface InboxViewConfig {
|
|
|
229
229
|
attentionCount?: number
|
|
230
230
|
/** Render extra content inline with the detail title. */
|
|
231
231
|
renderTitleExtra?: (item: QueueItem) => React.ReactNode
|
|
232
|
+
/** Render a full-width action row below the detail title row. */
|
|
233
|
+
renderTitleActionRow?: (item: QueueItem) => React.ReactNode
|
|
232
234
|
/** Render supporting content below the detail title. */
|
|
233
235
|
renderTitleSubtext?: (item: QueueItem) => React.ReactNode
|
|
236
|
+
/** Override the built-in account details metadata button label. */
|
|
237
|
+
accountDetailsButtonLabel?: (item: QueueItem) => React.ReactNode
|
|
238
|
+
/** Accessible label for the built-in account details metadata button. */
|
|
239
|
+
getAccountDetailsButtonAriaLabel?: (item: QueueItem) => string
|
|
234
240
|
/** Sort options for the inbox. When provided, a sort dropdown is rendered in the split view toolbar. */
|
|
235
241
|
sortOptions?: InboxSortOption[]
|
|
236
242
|
/** Currently active sort option id. */
|