@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -0,0 +1,344 @@
1
+ "use client"
2
+
3
+ /**
4
+ * BrandColorPicker — single-anchor palette popover.
5
+ *
6
+ * Why "one anchor per family" instead of 16 shades:
7
+ * The product theme system derives **all** chrome tints (sidebar bg, accent,
8
+ * secondary, muted, hover, ring, etc.) from a single `--custom-product-brand-color`
9
+ * variable using OKLCH `from var(…)` formulas. Letting users pick a specific
10
+ * shade (Pink 500 vs Pink 700) was misleading because the renderer
11
+ * re-tints everything anyway. One anchor per family is what actually maps
12
+ * to product reality: "this product is the blue one", not "this product is
13
+ * Blue 750".
14
+ *
15
+ * Layout:
16
+ * - Header: preview chip + family label + hex + optional Reset link.
17
+ * - Body: 10 anchor tiles (one per palette family), 5 across, 2 rows.
18
+ * Each tile carries a small "used by X" pip overlay when another product
19
+ * already claims that anchor — see `usedBy`.
20
+ * - Footer: always-visible Custom hex / CSS input for off-palette brands.
21
+ *
22
+ * `usedBy` is a `{ [anchorHex]: productLabel }` map. Callers compute it from
23
+ * the active brand registry / overrides so users can see at a glance that
24
+ * picking Pink would clash with another product. The pip uses the same color
25
+ * as the anchor on a contrasting background, with a tooltip naming the
26
+ * product.
27
+ */
28
+
29
+ import * as React from "react"
30
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
31
+ import { Button } from "@/components/ui/button"
32
+ import { Input } from "@/components/ui/input"
33
+ import { Tip } from "@/components/ui/tip"
34
+ import {
35
+ EXXAT_PALETTE_BY_FAMILY,
36
+ type ExxatPaletteFamilyId,
37
+ type ExxatPaletteSwatch,
38
+ } from "@/lib/exxat-palette"
39
+ import { cn } from "@/lib/utils"
40
+
41
+ /** Anchor shade — `500` is the canonical "true" tone for each palette family. */
42
+ const ANCHOR_SHADE = "500"
43
+
44
+ /**
45
+ * Palette families that are **brand-color candidates** for the picker.
46
+ *
47
+ * Excludes `sapphireGrayBlack` and `neutral` because both are gray-scale
48
+ * families (chroma ≈ 0.005–0.04) intended for UI text / borders, not for
49
+ * product identity. When the theme chrome formula
50
+ * (`oklch(from var(--custom-product-brand-color) 0.965 0.018 h)`) is fed a
51
+ * near-gray colour, the resulting tint is visually indistinguishable from
52
+ * white — which is what the user was hitting with "Sapphire doesn't apply
53
+ * like the rest of the colours". The Custom hex input still accepts any CSS
54
+ * colour, so off-palette greys remain reachable.
55
+ */
56
+ const BRAND_FAMILY_IDS: ReadonlyArray<ExxatPaletteFamilyId> = [
57
+ "exxatPink",
58
+ "exxatBlue",
59
+ "exxatIndigo",
60
+ "purple",
61
+ "teal",
62
+ "green",
63
+ "orange",
64
+ "red",
65
+ ]
66
+
67
+ /**
68
+ * Resolved anchor list — one swatch per **brand** palette family at
69
+ * `ANCHOR_SHADE`. Kept as a module constant so multiple picker instances share
70
+ * the same array identity (cheap referential equality for downstream memo).
71
+ */
72
+ export const BRAND_PICKER_ANCHORS: ReadonlyArray<ExxatPaletteSwatch> =
73
+ EXXAT_PALETTE_BY_FAMILY.filter(family => BRAND_FAMILY_IDS.includes(family.id)).flatMap(
74
+ family => family.swatches.filter(swatch => swatch.shade === ANCHOR_SHADE),
75
+ )
76
+
77
+ /** Find the anchor whose stored value matches `value` (hex or OKLCH). */
78
+ function findAnchor(value: string | null | undefined): ExxatPaletteSwatch | undefined {
79
+ if (!value) return undefined
80
+ const v = value.trim().toLowerCase()
81
+ if (!v) return undefined
82
+ return BRAND_PICKER_ANCHORS.find(
83
+ s => s.hex.toLowerCase() === v || s.oklch.toLowerCase() === v,
84
+ )
85
+ }
86
+
87
+ export interface BrandColorPickerProps {
88
+ /** Current brand color (any CSS color — palette OKLCH, hex, or free text). */
89
+ value: string
90
+ /** Fired when the user selects a palette swatch or commits a custom value. */
91
+ onChange: (value: string) => void
92
+ /**
93
+ * Optional registry / theme default. When provided and different from
94
+ * `value`, the popover surfaces a "Reset" link in the header that calls
95
+ * `onChange(defaultValue)`.
96
+ */
97
+ defaultValue?: string
98
+ /**
99
+ * Anchors already claimed by other products. Key by lower-case hex **or**
100
+ * lower-case OKLCH string (both forms checked). The matching tile shows a
101
+ * small pip + tooltip naming the product.
102
+ */
103
+ usedBy?: Readonly<Record<string, string>>
104
+ /** Trigger id — wire to a `<label htmlFor>` so the field stays accessible. */
105
+ id?: string
106
+ className?: string
107
+ }
108
+
109
+ export function BrandColorPicker({
110
+ value,
111
+ onChange,
112
+ defaultValue,
113
+ usedBy,
114
+ id,
115
+ className,
116
+ }: BrandColorPickerProps) {
117
+ const [open, setOpen] = React.useState(false)
118
+ const matchedAnchor = React.useMemo(() => findAnchor(value), [value])
119
+ const [customDraft, setCustomDraft] = React.useState(value)
120
+
121
+ React.useEffect(() => {
122
+ setCustomDraft(value)
123
+ }, [value])
124
+
125
+ const triggerLabel =
126
+ matchedAnchor?.familyLabel ?? (value?.trim() ? value : "Choose color")
127
+ const previewColor = value?.trim() || "transparent"
128
+ const showReset = Boolean(
129
+ defaultValue && value?.trim() && defaultValue.trim() !== value.trim(),
130
+ )
131
+
132
+ const handleSelectSwatch = (swatch: ExxatPaletteSwatch) => {
133
+ onChange(swatch.oklch)
134
+ setOpen(false)
135
+ }
136
+
137
+ const handleApplyCustom = () => {
138
+ const next = customDraft.trim()
139
+ if (!next || next === value.trim()) return
140
+ onChange(next)
141
+ setOpen(false)
142
+ }
143
+
144
+ /** Match `swatch` against the `usedBy` map (case-insensitive, hex or OKLCH). */
145
+ const claimedBy = React.useCallback(
146
+ (swatch: ExxatPaletteSwatch): string | undefined => {
147
+ if (!usedBy) return undefined
148
+ const hex = swatch.hex.toLowerCase()
149
+ const oklch = swatch.oklch.toLowerCase()
150
+ for (const [key, label] of Object.entries(usedBy)) {
151
+ const k = key.toLowerCase()
152
+ if (k === hex || k === oklch) return label
153
+ }
154
+ return undefined
155
+ },
156
+ [usedBy],
157
+ )
158
+
159
+ return (
160
+ <Popover open={open} onOpenChange={setOpen}>
161
+ <PopoverTrigger asChild>
162
+ <Button
163
+ id={id}
164
+ type="button"
165
+ variant="outline"
166
+ aria-haspopup="dialog"
167
+ aria-expanded={open}
168
+ className={cn("h-9 w-full justify-start gap-2 px-2.5 font-normal", className)}
169
+ >
170
+ <span
171
+ aria-hidden="true"
172
+ className="inline-flex size-5 shrink-0 rounded-full border border-border"
173
+ style={{ background: previewColor }}
174
+ />
175
+ <span className="min-w-0 flex-1 truncate text-left text-sm">{triggerLabel}</span>
176
+ <i className="fa-light fa-chevron-down text-xs text-muted-foreground" aria-hidden="true" />
177
+ </Button>
178
+ </PopoverTrigger>
179
+ <PopoverContent
180
+ align="start"
181
+ sideOffset={6}
182
+ // 5 tiles × ~3.25rem + paddings ≈ 18rem. Clamp on tiny viewports.
183
+ className="w-[min(20rem,calc(100vw-1.5rem))] p-0"
184
+ >
185
+ {/* Header */}
186
+ <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
187
+ <div className="flex min-w-0 items-center gap-2">
188
+ <span
189
+ aria-hidden="true"
190
+ className="inline-flex size-5 shrink-0 rounded-full border border-border"
191
+ style={{ background: previewColor }}
192
+ />
193
+ <div className="min-w-0">
194
+ <p className="truncate text-xs font-medium text-foreground">
195
+ {matchedAnchor?.familyLabel ?? "Custom color"}
196
+ </p>
197
+ <p className="truncate font-mono text-[10px] uppercase text-muted-foreground">
198
+ {matchedAnchor?.hex ?? value?.trim() ?? "—"}
199
+ </p>
200
+ </div>
201
+ </div>
202
+ {showReset ? (
203
+ <Button
204
+ type="button"
205
+ size="sm"
206
+ variant="ghost"
207
+ className="h-7 px-2 text-xs"
208
+ onClick={() => {
209
+ if (defaultValue) onChange(defaultValue)
210
+ }}
211
+ >
212
+ Reset
213
+ </Button>
214
+ ) : null}
215
+ </div>
216
+
217
+ {/* Body — one anchor per brand family, 4 across (8 → symmetric 4×2). */}
218
+ <div
219
+ className="grid grid-cols-4 gap-1.5 p-3"
220
+ role="listbox"
221
+ aria-label="Brand color palette"
222
+ >
223
+ {BRAND_PICKER_ANCHORS.map(swatch => {
224
+ const selected =
225
+ matchedAnchor?.family === swatch.family && matchedAnchor.shade === swatch.shade
226
+ const claim = claimedBy(swatch)
227
+ const tipBody = (
228
+ <span className="flex flex-col">
229
+ <span>{swatch.familyLabel}</span>
230
+ <span className="text-[11px] text-muted-foreground">{swatch.hex}</span>
231
+ {claim ? (
232
+ <span className="mt-0.5 text-[11px] text-foreground">
233
+ Used by {claim}
234
+ </span>
235
+ ) : null}
236
+ </span>
237
+ )
238
+ return (
239
+ <Tip key={`${swatch.family}-${swatch.shade}`} label={tipBody}>
240
+ <button
241
+ type="button"
242
+ role="option"
243
+ aria-selected={selected}
244
+ aria-label={
245
+ claim
246
+ ? `${swatch.familyLabel} (${swatch.hex}) — used by ${claim}`
247
+ : `${swatch.familyLabel} (${swatch.hex})`
248
+ }
249
+ onClick={() => handleSelectSwatch(swatch)}
250
+ className={cn(
251
+ "group relative flex flex-col items-center gap-1 rounded-md px-1 py-1.5",
252
+ "transition-transform hover:scale-[1.03]",
253
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
254
+ selected && "bg-accent/40",
255
+ )}
256
+ >
257
+ <span
258
+ aria-hidden="true"
259
+ className={cn(
260
+ "relative inline-flex size-8 shrink-0 rounded-full border border-black/10",
261
+ selected &&
262
+ "ring-2 ring-ring ring-offset-2 ring-offset-popover",
263
+ )}
264
+ style={{ background: swatch.hex }}
265
+ >
266
+ {selected ? (
267
+ <i
268
+ className="fa-solid fa-check absolute inset-0 m-auto text-[10px] text-white drop-shadow-[0_0_2px_rgba(0,0,0,0.6)]"
269
+ aria-hidden="true"
270
+ />
271
+ ) : null}
272
+ {claim ? (
273
+ // Pip indicates this anchor is already claimed by
274
+ // another product. Sits at the top-right of the
275
+ // swatch, contrasting ring for legibility on any
276
+ // background.
277
+ <span
278
+ aria-hidden="true"
279
+ className="absolute -right-0.5 -top-0.5 inline-flex size-3 items-center justify-center rounded-full bg-background ring-1 ring-border"
280
+ >
281
+ <span
282
+ className="size-2 rounded-full"
283
+ style={{ background: swatch.hex }}
284
+ />
285
+ </span>
286
+ ) : null}
287
+ </span>
288
+ <span
289
+ className={cn(
290
+ "max-w-full truncate text-[10px] leading-none text-muted-foreground",
291
+ selected && "text-foreground",
292
+ )}
293
+ >
294
+ {swatch.familyLabel}
295
+ </span>
296
+ </button>
297
+ </Tip>
298
+ )
299
+ })}
300
+ </div>
301
+
302
+ {/* Footer — custom hex / CSS input (always visible) */}
303
+ <div className="space-y-1.5 border-t border-border px-3 py-2">
304
+ <label
305
+ htmlFor={`${id ?? "brand-color"}-custom`}
306
+ className="text-[11px] font-medium text-muted-foreground"
307
+ >
308
+ Custom (hex / rgb / oklch)
309
+ </label>
310
+ <div className="flex gap-2">
311
+ <span
312
+ aria-hidden="true"
313
+ className="inline-flex size-9 shrink-0 rounded-md border border-border"
314
+ style={{ background: customDraft.trim() || "transparent" }}
315
+ />
316
+ <Input
317
+ id={`${id ?? "brand-color"}-custom`}
318
+ value={customDraft}
319
+ onChange={event => setCustomDraft(event.target.value)}
320
+ placeholder="#E31C79"
321
+ autoComplete="off"
322
+ className="font-mono text-xs"
323
+ onKeyDown={event => {
324
+ if (event.key === "Enter") {
325
+ event.preventDefault()
326
+ handleApplyCustom()
327
+ }
328
+ }}
329
+ />
330
+ <Button
331
+ type="button"
332
+ size="sm"
333
+ variant="outline"
334
+ disabled={!customDraft.trim() || customDraft.trim() === value.trim()}
335
+ onClick={handleApplyCustom}
336
+ >
337
+ Apply
338
+ </Button>
339
+ </div>
340
+ </div>
341
+ </PopoverContent>
342
+ </Popover>
343
+ )
344
+ }
@@ -1,8 +1,8 @@
1
1
  "use client"
2
2
 
3
- import * as React from "react"
4
3
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
5
4
  import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
5
+ import { DataRowList } from "@/components/data-views/data-row-list"
6
6
  import {
7
7
  COMPLIANCE_STATUS_BADGE_CLASS,
8
8
  COMPLIANCE_STATUS_ICON,
@@ -10,43 +10,6 @@ import {
10
10
  } from "@/lib/list-status-badges"
11
11
  import type { ComplianceItem } from "@/lib/mock/compliance"
12
12
 
13
- function ComplianceListRow({
14
- row,
15
- onRowActivate,
16
- }: {
17
- row: ComplianceItem
18
- onRowActivate?: (row: ComplianceItem) => void
19
- }) {
20
- return (
21
- <li>
22
- <ListPageBoardCard
23
- layout="row"
24
- rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
25
- onClick={onRowActivate ? () => onRowActivate(row) : undefined}
26
- rowEnd={
27
- <div className="flex shrink-0 items-center gap-2">
28
- <ListHubStatusBadge
29
- surface="board"
30
- label={COMPLIANCE_STATUS_LABEL[row.status]}
31
- tintClassName={COMPLIANCE_STATUS_BADGE_CLASS[row.status]}
32
- icon={COMPLIANCE_STATUS_ICON[row.status]}
33
- />
34
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
35
- </div>
36
- }
37
- >
38
- <div className="space-y-0.5">
39
- <p className="text-sm font-semibold text-foreground">{row.title}</p>
40
- <p className="text-xs text-muted-foreground">
41
- {row.category} · Due {row.dueDate}
42
- </p>
43
- <p className="text-xs text-muted-foreground">Owner: {row.owner}</p>
44
- </div>
45
- </ListPageBoardCard>
46
- </li>
47
- )
48
- }
49
-
50
13
  export function ComplianceListView({
51
14
  rows,
52
15
  onRowActivate,
@@ -54,19 +17,38 @@ export function ComplianceListView({
54
17
  rows: ComplianceItem[]
55
18
  onRowActivate?: (row: ComplianceItem) => void
56
19
  }) {
57
- if (rows.length === 0) {
58
- return (
59
- <div className="px-4 py-16 text-center lg:px-6">
60
- <p className="text-sm text-muted-foreground">No compliance items match your filters.</p>
61
- </div>
62
- )
63
- }
64
-
65
20
  return (
66
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
67
- {rows.map(row => (
68
- <ComplianceListRow key={row.id} row={row} onRowActivate={onRowActivate} />
69
- ))}
70
- </ul>
21
+ <DataRowList<ComplianceItem>
22
+ rows={rows}
23
+ getRowId={row => row.id}
24
+ emptyState="No compliance items match your filters."
25
+ ariaLabel="Compliance items"
26
+ renderRow={row => (
27
+ <ListPageBoardCard
28
+ layout="row"
29
+ rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
30
+ onClick={onRowActivate ? () => onRowActivate(row) : undefined}
31
+ rowEnd={
32
+ <div className="flex shrink-0 items-center gap-2">
33
+ <ListHubStatusBadge
34
+ surface="board"
35
+ label={COMPLIANCE_STATUS_LABEL[row.status]}
36
+ tintClassName={COMPLIANCE_STATUS_BADGE_CLASS[row.status]}
37
+ icon={COMPLIANCE_STATUS_ICON[row.status]}
38
+ />
39
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
40
+ </div>
41
+ }
42
+ >
43
+ <div className="space-y-0.5">
44
+ <p className="text-sm font-semibold text-foreground">{row.title}</p>
45
+ <p className="text-xs text-muted-foreground">
46
+ {row.category} · Due {row.dueDate}
47
+ </p>
48
+ <p className="text-xs text-muted-foreground">Owner: {row.owner}</p>
49
+ </div>
50
+ </ListPageBoardCard>
51
+ )}
52
+ />
71
53
  )
72
54
  }
@@ -374,6 +374,10 @@ export const ComplianceTable = React.forwardRef<
374
374
  openPropertiesDrawer: () => {
375
375
  tableState.setSheetOpen(true)
376
376
  },
377
+ // `tableState` is freshly returned each render by useTableState; depending on
378
+ // it would re-create the imperative handle on every render. Only the React
379
+ // setter is needed (and is referentially stable).
380
+ // eslint-disable-next-line react-hooks/exhaustive-deps
377
381
  }), [tableState.setSheetOpen])
378
382
 
379
383
  const complianceBoardGroupKey = COMPLIANCE_BOARD_GROUP_OPTIONS.some(