@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.
- package/CHANGELOG.md +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +19 -133
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- 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
|
-
<
|
|
67
|
-
{rows
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|