@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.
Files changed (44) 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 +2 -1
  7. package/dist/components/data-table-condition-filter.js +23 -41
  8. package/dist/components/data-table-condition-filter.js.map +1 -1
  9. package/dist/components/data-table-filter.js +8 -9
  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 +9 -3
  26. package/dist/prototype/prototype-inbox-view.js +19 -6
  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-B5b-XZ7i.d.ts} +10 -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 +96 -0
  34. package/src/components/__tests__/data-table-filter.test.tsx +45 -0
  35. package/src/components/__tests__/entity-panel-header.test.tsx +44 -0
  36. package/src/components/__tests__/signal-priority-popover.test.tsx +30 -0
  37. package/src/components/account-contacts-popover.tsx +29 -1
  38. package/src/components/data-table-condition-filter.tsx +32 -47
  39. package/src/components/data-table-filter.tsx +7 -10
  40. package/src/components/entity-panel.tsx +56 -40
  41. package/src/components/signal-priority-popover.tsx +15 -4
  42. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +31 -0
  43. package/src/prototype/prototype-config.ts +6 -0
  44. 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={() => { (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"
@@ -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 normalizedQuery = query.trim().toLowerCase()
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
- const showSearch = shouldShowOptionSearch(fieldDef, options.length)
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) => event.stopPropagation()}
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
- const Icon = getFieldIcon(field.type)
521
- return (
522
- <SelectItem key={field.id} value={field.id}>
523
- <span className="inline-flex items-center gap-2">
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
- if (category.searchable === true) return true
219
- if (category.searchable === false) return false
220
- const threshold =
221
- typeof category.searchable === "object"
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: _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="flex items-center justify-between mb-3">
154
- <div className="flex items-center gap-2 min-w-0">
155
- {icon ?? <CalendarDays className="w-5 h-5 text-muted-foreground shrink-0" />}
156
- <h2 className="text-[16px] font-semibold text-foreground truncate">{title}</h2>
157
- {badgeLabel && (
158
- <Badge
159
- variant="outline"
160
- 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"
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
- {badgeLabel}
163
- </Badge>
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
- <div className="flex items-center gap-1 shrink-0 ml-4 text-muted-foreground">
167
- {headerAction}
168
- <button
169
- type="button"
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
- <Maximize2 className="w-4 h-4" />
205
+ <div className="min-w-0 flex-1" />
185
206
  )}
186
- </button>
187
- <button
188
- type="button"
189
- onClick={onClose}
190
- className="p-1.5 rounded-md hover:bg-secondary transition-colors"
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
- <span className="text-sm font-bold tabular-nums">{factor.score}</span>
212
- <span className="text-xs font-normal text-muted-foreground">/100</span>
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. */