@exxatdesignux/ui 0.3.0 → 0.4.1
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 +701 -6
- package/README.md +138 -0
- package/bin/init.mjs +134 -31
- package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
- package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
- package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
- package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
- package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
- package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
- package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
- package/consumer-extras/handbook/HANDBOOK.md +2 -0
- package/consumer-extras/handbook/glossary.md +2 -1
- package/consumer-extras/handbook/reference-implementations.md +31 -4
- package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
- package/consumer-extras/patterns/data-views-pattern.md +18 -16
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
- package/dist/components/data-table/index.js +2 -2
- package/dist/components/data-table/index.js.map +1 -1
- package/dist/components/data-table/pagination.js +3 -3
- package/dist/components/data-table/pagination.js.map +1 -1
- package/dist/components/data-table/use-table-state.d.ts +1 -1
- package/dist/components/data-table/use-table-state.js.map +1 -1
- package/dist/components/data-views/data-row-list.js.map +1 -1
- package/dist/components/data-views/finder-panel-view.d.ts +1 -1
- package/dist/components/data-views/finder-panel-view.js.map +1 -1
- package/dist/components/data-views/hub-table.d.ts +9 -3
- package/dist/components/data-views/hub-table.js +262 -40
- package/dist/components/data-views/hub-table.js.map +1 -1
- package/dist/components/data-views/index.js +262 -40
- package/dist/components/data-views/index.js.map +1 -1
- package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
- package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
- package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
- package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
- package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
- package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
- package/dist/components/data-views/os-folder-glyph.js.map +1 -1
- package/dist/components/ui/avatar.d.ts +1 -1
- package/dist/components/ui/key-metrics.js.map +1 -1
- package/dist/index.js +136 -39
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/data-table/index.tsx +2 -2
- package/src/components/data-table/pagination.tsx +5 -1
- package/src/components/data-table/use-table-state.ts +1 -1
- package/src/components/data-views/data-row-list.tsx +1 -1
- package/src/components/data-views/finder-panel-view.tsx +2 -2
- package/src/components/data-views/hub-table.tsx +149 -41
- package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
- package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/src/components/data-views/os-folder-glyph.tsx +1 -1
- package/src/components/ui/key-metrics.tsx +1 -1
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
- package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
- package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
- package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
- package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
- package/template/AGENTS.md +43 -37
- package/template/app/(app)/columns/page.tsx +11 -0
- package/template/app/(app)/library/all/page.tsx +11 -0
- package/template/app/(app)/library/find/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
- package/template/app/(app)/library/list/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
- package/template/app/(app)/library/page.tsx +11 -0
- package/template/app/(app)/tokens-themes/page.tsx +11 -0
- package/template/components/ask-leo-composer.tsx +2 -2
- package/template/components/columns-client.tsx +158 -0
- package/template/components/columns-showcase.tsx +541 -0
- package/template/components/data-views/index.ts +32 -6
- package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
- package/template/components/data-views/table-cells.tsx +673 -0
- package/template/components/folder-details-shell.tsx +11 -11
- package/template/components/hub-tree-panel-view.tsx +24 -24
- package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
- package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
- package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
- package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
- package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
- package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
- package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
- package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
- package/template/components/library-panel-activator.tsx +8 -0
- package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
- package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
- package/template/components/list-hub-status-badge.tsx +2 -2
- package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
- package/template/components/sidebar/app-sidebar.tsx +61 -5
- package/template/components/sidebar/secondary-panel.tsx +109 -56
- package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
- package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
- package/template/components/table-properties/types.ts +1 -1
- package/template/components/templates/discovery-hub-template.tsx +1 -1
- package/template/components/templates/new-focus-template.tsx +2 -2
- package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
- package/template/components/tokens-secondary-nav.tsx +192 -0
- package/template/components/tokens-themes-client.tsx +476 -0
- package/template/components/tokens-themes-section.tsx +386 -0
- package/template/docs/HANDBOOK.md +187 -0
- package/template/docs/blueprints/README.md +1 -1
- package/template/docs/blueprints/board-card.md +1 -1
- package/template/docs/blueprints/data-table.md +2 -2
- package/template/docs/blueprints/list-page-template.md +3 -3
- package/template/docs/blueprints/page-header.md +4 -4
- package/template/docs/collaboration-access-pattern.md +7 -7
- package/template/docs/component-selection-guide.md +1 -1
- package/template/docs/data-views-pattern.md +18 -16
- package/template/docs/glossary.md +58 -0
- package/template/docs/kpi-flat-band-pattern.md +3 -3
- package/template/docs/kpi-trend-pattern.md +18 -3
- package/template/docs/large-dataset-strategy.md +155 -0
- package/template/docs/library-hub-header-pattern.md +25 -0
- package/template/docs/migrations/_template.md +1 -1
- package/template/docs/reference-implementations.md +151 -0
- package/template/docs/token-taxonomy.md +1 -1
- package/template/docs/voice-and-tone.md +262 -0
- package/template/eslint.config.mjs +9 -39
- package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
- package/template/lib/ask-leo-route-context.ts +6 -18
- package/template/lib/coach-mark-registry.ts +0 -16
- package/template/lib/command-menu-config.ts +5 -12
- package/template/lib/command-menu-search-data.ts +8 -39
- package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
- package/template/lib/library-dedicated-search.ts +19 -0
- package/template/lib/library-hub-search.ts +90 -0
- package/template/lib/library-nav.ts +477 -0
- package/template/lib/library-recent-searches.ts +22 -0
- package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
- package/template/lib/list-status-badges.ts +16 -104
- package/template/lib/mock/dashboard.ts +1 -1
- package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
- package/template/lib/mock/library-header-collaborators.ts +54 -0
- package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
- package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
- package/template/lib/mock/library.ts +249 -0
- package/template/lib/mock/navigation.tsx +32 -26
- package/template/lib/table-state-lifecycle.ts +1 -1
- package/template/next.config.mjs +7 -4
- package/template/package.json +0 -1
- package/tokens/hooks-index.json +2874 -0
- package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
- package/template/app/(app)/examples/page.tsx +0 -41
- package/template/app/(app)/question-bank/find/page.tsx +0 -12
- package/template/app/(app)/question-bank/library/page.tsx +0 -11
- package/template/app/(app)/question-bank/list/page.tsx +0 -12
- package/template/app/(app)/question-bank/page.tsx +0 -11
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -468
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -942
- package/template/components/placement-board-card.tsx +0 -250
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -397
- package/template/components/placements-client.tsx +0 -220
- package/template/components/placements-list-view.tsx +0 -124
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -210
- package/template/components/placements-table.tsx +0 -934
- package/template/components/question-bank-panel-activator.tsx +0 -8
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-client.tsx +0 -154
- package/template/components/sites-table.tsx +0 -249
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -553
- package/template/docs/question-bank-hub-header-pattern.md +0 -25
- package/template/lib/compliance-supported-views.ts +0 -10
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -176
- package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
- package/template/lib/mock/question-bank.ts +0 -249
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- package/template/lib/question-bank-dedicated-search.ts +0 -19
- package/template/lib/question-bank-hub-search.ts +0 -90
- package/template/lib/question-bank-nav.ts +0 -477
- package/template/lib/question-bank-recent-searches.ts +0 -22
- package/template/lib/question-bank-supported-views.ts +0 -12
- package/template/lib/sites-supported-views.ts +0 -10
- package/template/lib/team-supported-views.ts +0 -10
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reusable `DataTable` / `HubTable` cell primitives — extracted from
|
|
5
|
+
* `columns-showcase.tsx` so every hub composes its grid from the same set of
|
|
6
|
+
* named, accessible, copy-paste-free renderers.
|
|
7
|
+
*
|
|
8
|
+
* **Why this module exists.** Without a shared home, each hub would re-derive
|
|
9
|
+
* progress bars, currency formatting, rating stars, attachment chips, relative
|
|
10
|
+
* times, etc. — drifting in spacing, color, and a11y treatment. These cells
|
|
11
|
+
* pair color + glyph (WCAG 1.4.1), keep tabular numbers right-aligned, and
|
|
12
|
+
* expose a focusable `Tip` for any glyph-only signal.
|
|
13
|
+
*
|
|
14
|
+
* **Composition only.** Every renderer is a pure composition of existing
|
|
15
|
+
* primitives (`@/components/ui/*`, `@/components/list-hub-status-badge`,
|
|
16
|
+
* `Intl` formatters, Font Awesome icon classes). No new design tokens, no new
|
|
17
|
+
* package surface — drop these into any `ColumnDef<TRow>['cell']`.
|
|
18
|
+
*
|
|
19
|
+
* **Live catalog:** `apps/web/components/columns-showcase.tsx` (hosted at
|
|
20
|
+
* `/columns`) renders every export below as its own column so designers,
|
|
21
|
+
* engineers, and AI agents can see the cell in situ before picking it.
|
|
22
|
+
*
|
|
23
|
+
* **Skill reference:** `.cursor/skills/exxat-token-economy/SKILL.md` §3 names
|
|
24
|
+
* each export below in its "primitive aliases" table so the AI imports
|
|
25
|
+
* directly instead of re-implementing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as React from "react"
|
|
29
|
+
import { AvatarGroup, AvatarGroupCount, AvatarInitials } from "@/components/ui/avatar"
|
|
30
|
+
import { Badge } from "@/components/ui/badge"
|
|
31
|
+
import { Button } from "@/components/ui/button"
|
|
32
|
+
import {
|
|
33
|
+
DropdownMenu,
|
|
34
|
+
DropdownMenuContent,
|
|
35
|
+
DropdownMenuItem,
|
|
36
|
+
DropdownMenuSeparator,
|
|
37
|
+
DropdownMenuTrigger,
|
|
38
|
+
} from "@/components/ui/dropdown-menu"
|
|
39
|
+
import { Tip } from "@/components/ui/tip"
|
|
40
|
+
import { ToggleSwitch } from "@/components/ui/toggle-switch"
|
|
41
|
+
import { cn } from "@/lib/utils"
|
|
42
|
+
|
|
43
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
44
|
+
* Shared helpers
|
|
45
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
const EMPTY_DASH = (
|
|
48
|
+
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
|
49
|
+
—
|
|
50
|
+
</span>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** Truthy-only dash with an accessible label so screen-reader users get a hint
|
|
54
|
+
* for "no value" cells across every hub. */
|
|
55
|
+
function EmptyCell({ label = "No value" }: { label?: string }) {
|
|
56
|
+
return (
|
|
57
|
+
<span className="text-sm text-muted-foreground" aria-label={label}>
|
|
58
|
+
—
|
|
59
|
+
</span>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
64
|
+
* Numeric / monetary
|
|
65
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Right-aligned plain numeric cell. Use for counts where the grid benefits
|
|
69
|
+
* from column-aligned digits (attempts, downloads, file size N).
|
|
70
|
+
*/
|
|
71
|
+
export function NumericCell({
|
|
72
|
+
value,
|
|
73
|
+
fractionDigits = 0,
|
|
74
|
+
className,
|
|
75
|
+
}: {
|
|
76
|
+
value: number | null | undefined
|
|
77
|
+
fractionDigits?: number
|
|
78
|
+
className?: string
|
|
79
|
+
}) {
|
|
80
|
+
if (value == null || Number.isNaN(value)) return <EmptyCell />
|
|
81
|
+
return (
|
|
82
|
+
<span className={cn("block text-right text-sm tabular-nums text-foreground", className)}>
|
|
83
|
+
{Number(value).toLocaleString(undefined, {
|
|
84
|
+
minimumFractionDigits: fractionDigits,
|
|
85
|
+
maximumFractionDigits: fractionDigits,
|
|
86
|
+
})}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Currency cell — right-aligned, `tabular-nums`. `Intl.NumberFormat` honors
|
|
93
|
+
* locale + currency; defaults to USD because the product is US-first.
|
|
94
|
+
*/
|
|
95
|
+
export function CurrencyCell({
|
|
96
|
+
value,
|
|
97
|
+
currency = "USD",
|
|
98
|
+
locale = "en-US",
|
|
99
|
+
maximumFractionDigits = 2,
|
|
100
|
+
}: {
|
|
101
|
+
value: number | null | undefined
|
|
102
|
+
currency?: string
|
|
103
|
+
locale?: string
|
|
104
|
+
maximumFractionDigits?: number
|
|
105
|
+
}) {
|
|
106
|
+
if (value == null || Number.isNaN(value)) return <EmptyCell label="No amount" />
|
|
107
|
+
const fmt = new Intl.NumberFormat(locale, {
|
|
108
|
+
style: "currency",
|
|
109
|
+
currency,
|
|
110
|
+
maximumFractionDigits,
|
|
111
|
+
})
|
|
112
|
+
return (
|
|
113
|
+
<span className="block text-right text-sm tabular-nums text-foreground">
|
|
114
|
+
{fmt.format(value)}
|
|
115
|
+
</span>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
120
|
+
* Progress + signal
|
|
121
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
122
|
+
|
|
123
|
+
export type ProgressTone = "auto" | "success" | "warning" | "danger" | "info"
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Progress bar — track + filled fill + numeric label. Auto-tones in thirds:
|
|
127
|
+
* <34% destructive, <67% warning, ≥67% success. Pass an explicit `tone` to
|
|
128
|
+
* override (e.g. "info" for non-judgmental quantity bars).
|
|
129
|
+
*/
|
|
130
|
+
export function ProgressCell({
|
|
131
|
+
value,
|
|
132
|
+
max = 100,
|
|
133
|
+
tone = "auto",
|
|
134
|
+
label,
|
|
135
|
+
className,
|
|
136
|
+
}: {
|
|
137
|
+
value: number | null | undefined
|
|
138
|
+
max?: number
|
|
139
|
+
tone?: ProgressTone
|
|
140
|
+
/** Right-side label. Defaults to `${pct}%`. Pass `false` to hide. */
|
|
141
|
+
label?: React.ReactNode | false
|
|
142
|
+
className?: string
|
|
143
|
+
}) {
|
|
144
|
+
if (value == null || Number.isNaN(value)) return <EmptyCell label="No progress" />
|
|
145
|
+
const pct = Math.max(0, Math.min(100, Math.round((value / max) * 100)))
|
|
146
|
+
const autoTone =
|
|
147
|
+
pct < 34 ? "bg-destructive" :
|
|
148
|
+
pct < 67 ? "bg-amber-500" :
|
|
149
|
+
"bg-emerald-500"
|
|
150
|
+
const toneClass =
|
|
151
|
+
tone === "success" ? "bg-emerald-500" :
|
|
152
|
+
tone === "warning" ? "bg-amber-500" :
|
|
153
|
+
tone === "danger" ? "bg-destructive" :
|
|
154
|
+
tone === "info" ? "bg-primary" :
|
|
155
|
+
autoTone
|
|
156
|
+
const labelNode =
|
|
157
|
+
label === false ? null :
|
|
158
|
+
label ?? <span className="text-[11px] tabular-nums text-muted-foreground">{pct}%</span>
|
|
159
|
+
return (
|
|
160
|
+
<div className={cn("flex min-w-[140px] max-w-[180px] flex-col gap-1.5", className)}>
|
|
161
|
+
<div
|
|
162
|
+
role="progressbar"
|
|
163
|
+
aria-valuemin={0}
|
|
164
|
+
aria-valuemax={100}
|
|
165
|
+
aria-valuenow={pct}
|
|
166
|
+
aria-label={`Progress ${pct} percent`}
|
|
167
|
+
className="h-1.5 overflow-hidden rounded-full bg-muted"
|
|
168
|
+
>
|
|
169
|
+
<div
|
|
170
|
+
className={cn("h-full rounded-full transition-[width]", toneClass)}
|
|
171
|
+
style={{ width: `${pct}%` }}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
{labelNode}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type SignalTone = "success" | "warning" | "danger" | "info" | "neutral"
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Three-bar signal indicator — same metaphor as Wi-Fi / cellular bars. Use
|
|
183
|
+
* for ordinal scales (low/medium/high; easy/medium/hard). Color is *paired*
|
|
184
|
+
* with bar count so the cell still communicates on monochrome + forced-colors.
|
|
185
|
+
*/
|
|
186
|
+
export function SignalBarsCell({
|
|
187
|
+
level,
|
|
188
|
+
max = 3,
|
|
189
|
+
tone = "info",
|
|
190
|
+
label,
|
|
191
|
+
}: {
|
|
192
|
+
/** 1-indexed level. */
|
|
193
|
+
level: number
|
|
194
|
+
/** Total number of bars. Default 3. */
|
|
195
|
+
max?: number
|
|
196
|
+
tone?: SignalTone
|
|
197
|
+
/** Accessible name; also used as the `Tip` content. */
|
|
198
|
+
label: string
|
|
199
|
+
}) {
|
|
200
|
+
const lvl = Math.max(0, Math.min(max, Math.round(level)))
|
|
201
|
+
const toneClass =
|
|
202
|
+
tone === "success" ? "bg-emerald-500" :
|
|
203
|
+
tone === "warning" ? "bg-amber-500" :
|
|
204
|
+
tone === "danger" ? "bg-destructive" :
|
|
205
|
+
tone === "info" ? "bg-primary" :
|
|
206
|
+
"bg-foreground"
|
|
207
|
+
return (
|
|
208
|
+
<Tip side="top" label={label}>
|
|
209
|
+
<span
|
|
210
|
+
className="inline-flex items-end gap-0.5 cursor-default"
|
|
211
|
+
role="img"
|
|
212
|
+
aria-label={label}
|
|
213
|
+
tabIndex={0}
|
|
214
|
+
>
|
|
215
|
+
{Array.from({ length: max }, (_, i) => {
|
|
216
|
+
const bar = i + 1
|
|
217
|
+
const filled = bar <= lvl
|
|
218
|
+
// Stair-step the heights so the metaphor reads visually.
|
|
219
|
+
const heightClass =
|
|
220
|
+
bar === 1 ? "h-2" :
|
|
221
|
+
bar === 2 ? "h-3" :
|
|
222
|
+
bar === 3 ? "h-4" :
|
|
223
|
+
"h-5"
|
|
224
|
+
return (
|
|
225
|
+
<span
|
|
226
|
+
key={bar}
|
|
227
|
+
className={cn("w-1 rounded-sm", filled ? toneClass : "bg-muted", heightClass)}
|
|
228
|
+
aria-hidden="true"
|
|
229
|
+
/>
|
|
230
|
+
)
|
|
231
|
+
})}
|
|
232
|
+
</span>
|
|
233
|
+
</Tip>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
238
|
+
* People
|
|
239
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
240
|
+
|
|
241
|
+
export interface PersonStub {
|
|
242
|
+
name: string
|
|
243
|
+
initials: string
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Face rail — list of people with a `+N more` overflow chip. Each face gets a
|
|
248
|
+
* `Tip` of the person's name; the overflow chip's tip lists the hidden names.
|
|
249
|
+
* Uses non-overlapping avatars (gap, not negative margin) per Exxat DS rule.
|
|
250
|
+
*/
|
|
251
|
+
export function PeopleAvatarRailCell({
|
|
252
|
+
people,
|
|
253
|
+
visibleMax = 3,
|
|
254
|
+
size = "sm",
|
|
255
|
+
emptyLabel = "No people",
|
|
256
|
+
}: {
|
|
257
|
+
people: PersonStub[] | undefined
|
|
258
|
+
/** How many faces to show before `+N`. Default 3. */
|
|
259
|
+
visibleMax?: number
|
|
260
|
+
size?: "sm" | "md"
|
|
261
|
+
emptyLabel?: string
|
|
262
|
+
}) {
|
|
263
|
+
if (!people?.length) return <EmptyCell label={emptyLabel} />
|
|
264
|
+
const visible = people.slice(0, visibleMax)
|
|
265
|
+
const overflow = people.length - visible.length
|
|
266
|
+
const sizeClass = size === "md" ? "size-7 text-[11px]" : "size-6 text-[10px]"
|
|
267
|
+
return (
|
|
268
|
+
<AvatarGroup data-size={size} className="gap-1">
|
|
269
|
+
{visible.map((p) => (
|
|
270
|
+
<Tip key={`${p.name}-${p.initials}`} side="top" label={p.name}>
|
|
271
|
+
<AvatarInitials
|
|
272
|
+
initials={p.initials}
|
|
273
|
+
className={sizeClass}
|
|
274
|
+
fallbackClassName={size === "md" ? "text-[11px]" : "text-[10px]"}
|
|
275
|
+
/>
|
|
276
|
+
</Tip>
|
|
277
|
+
))}
|
|
278
|
+
{overflow > 0 && (
|
|
279
|
+
<Tip side="top" label={people.slice(visibleMax).map((p) => p.name).join(", ")}>
|
|
280
|
+
<AvatarGroupCount
|
|
281
|
+
tabIndex={0}
|
|
282
|
+
aria-label={`${overflow} more${overflow === 1 ? "" : "s"}`}
|
|
283
|
+
className={sizeClass}
|
|
284
|
+
>
|
|
285
|
+
+{overflow}
|
|
286
|
+
</AvatarGroupCount>
|
|
287
|
+
</Tip>
|
|
288
|
+
)}
|
|
289
|
+
</AvatarGroup>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
294
|
+
* Pills + chips
|
|
295
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Outlined pill with a leading FA icon — the "Type" pattern. Use for
|
|
299
|
+
* single-select categorical fields where color isn't carrying meaning
|
|
300
|
+
* (otherwise reach for `ListHubStatusBadge`).
|
|
301
|
+
*/
|
|
302
|
+
export function PillCell({
|
|
303
|
+
label,
|
|
304
|
+
icon,
|
|
305
|
+
iconClassName,
|
|
306
|
+
className,
|
|
307
|
+
}: {
|
|
308
|
+
label: React.ReactNode
|
|
309
|
+
/** FA glyph name without the family prefix, e.g. `"fa-list-check"`. */
|
|
310
|
+
icon?: string
|
|
311
|
+
iconClassName?: string
|
|
312
|
+
className?: string
|
|
313
|
+
}) {
|
|
314
|
+
return (
|
|
315
|
+
<Badge
|
|
316
|
+
variant="outline"
|
|
317
|
+
className={cn(
|
|
318
|
+
"h-6 gap-1.5 border-border bg-background px-2 text-xs font-medium",
|
|
319
|
+
className,
|
|
320
|
+
)}
|
|
321
|
+
>
|
|
322
|
+
{icon ? (
|
|
323
|
+
<i
|
|
324
|
+
className={cn("fa-light text-[11px] text-muted-foreground", icon, iconClassName)}
|
|
325
|
+
aria-hidden="true"
|
|
326
|
+
/>
|
|
327
|
+
) : null}
|
|
328
|
+
<span className="text-foreground">{label}</span>
|
|
329
|
+
</Badge>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Tag list with `+N` overflow. Use for free-form keyword tags (`#tag`). For
|
|
335
|
+
* categorical pills, see `PillCell`; for status, see `ListHubStatusBadge`.
|
|
336
|
+
*/
|
|
337
|
+
export function TagListCell({
|
|
338
|
+
tags,
|
|
339
|
+
visibleMax = 2,
|
|
340
|
+
formatLabel = (t) => `#${t}`,
|
|
341
|
+
}: {
|
|
342
|
+
tags: string[] | undefined
|
|
343
|
+
visibleMax?: number
|
|
344
|
+
formatLabel?: (tag: string) => string
|
|
345
|
+
}) {
|
|
346
|
+
if (!tags?.length) return <EmptyCell label="No tags" />
|
|
347
|
+
const visible = tags.slice(0, visibleMax)
|
|
348
|
+
const overflow = tags.length - visible.length
|
|
349
|
+
return (
|
|
350
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
351
|
+
{visible.map((t) => (
|
|
352
|
+
<Badge
|
|
353
|
+
key={t}
|
|
354
|
+
variant="secondary"
|
|
355
|
+
className="h-5 px-1.5 text-[11px] font-medium leading-none"
|
|
356
|
+
>
|
|
357
|
+
{formatLabel(t)}
|
|
358
|
+
</Badge>
|
|
359
|
+
))}
|
|
360
|
+
{overflow > 0 && (
|
|
361
|
+
<Tip side="top" label={tags.slice(visibleMax).map(formatLabel).join(", ")}>
|
|
362
|
+
<span
|
|
363
|
+
className="inline-flex h-5 cursor-default items-center justify-center rounded-md bg-muted px-1.5 text-[11px] font-medium leading-none text-muted-foreground"
|
|
364
|
+
tabIndex={0}
|
|
365
|
+
aria-label={`${overflow} more tag${overflow === 1 ? "" : "s"}`}
|
|
366
|
+
>
|
|
367
|
+
+{overflow}
|
|
368
|
+
</span>
|
|
369
|
+
</Tip>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
376
|
+
* Rating
|
|
377
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Star rating — N of `max` FA stars + numeric value. Color (amber) + glyph
|
|
381
|
+
* change (solid vs. light) pair so the cell still reads on monochrome /
|
|
382
|
+
* forced-colors modes (WCAG 1.4.1).
|
|
383
|
+
*/
|
|
384
|
+
export function RatingCell({
|
|
385
|
+
value,
|
|
386
|
+
max = 5,
|
|
387
|
+
showValue = true,
|
|
388
|
+
}: {
|
|
389
|
+
value: number | null | undefined
|
|
390
|
+
max?: number
|
|
391
|
+
showValue?: boolean
|
|
392
|
+
}) {
|
|
393
|
+
if (value == null || Number.isNaN(value)) return <EmptyCell label="No rating" />
|
|
394
|
+
const n = Math.max(0, Math.min(max, Math.round(value)))
|
|
395
|
+
const label = `Rated ${n} of ${max}`
|
|
396
|
+
return (
|
|
397
|
+
<Tip side="top" label={label}>
|
|
398
|
+
<span
|
|
399
|
+
role="img"
|
|
400
|
+
aria-label={label}
|
|
401
|
+
tabIndex={0}
|
|
402
|
+
className="inline-flex items-center gap-1 rounded-md cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
403
|
+
>
|
|
404
|
+
<span className="inline-flex items-center gap-0.5" aria-hidden="true">
|
|
405
|
+
{Array.from({ length: max }, (_, i) => {
|
|
406
|
+
const filled = i < n
|
|
407
|
+
return (
|
|
408
|
+
<i
|
|
409
|
+
key={i}
|
|
410
|
+
className={cn(
|
|
411
|
+
filled ? "fa-solid text-amber-500" : "fa-light text-muted-foreground/50",
|
|
412
|
+
"fa-star text-[11px]",
|
|
413
|
+
)}
|
|
414
|
+
/>
|
|
415
|
+
)
|
|
416
|
+
})}
|
|
417
|
+
</span>
|
|
418
|
+
{showValue ? (
|
|
419
|
+
<span className="text-xs tabular-nums text-muted-foreground">{n}.0</span>
|
|
420
|
+
) : null}
|
|
421
|
+
</span>
|
|
422
|
+
</Tip>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
427
|
+
* Booleans
|
|
428
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Inline toggle — `ToggleSwitch` for a boolean lifecycle field (Published,
|
|
432
|
+
* Active, Enabled). The callback receives the *next* checked state; the cell
|
|
433
|
+
* stops row click propagation so toggling never opens the row.
|
|
434
|
+
*
|
|
435
|
+
* `ToggleSwitch` does not currently support a `disabled` state — if you need
|
|
436
|
+
* to lock a row's toggle, render a static badge (`PillCell` with the current
|
|
437
|
+
* state) instead.
|
|
438
|
+
*/
|
|
439
|
+
export function BooleanToggleCell({
|
|
440
|
+
checked,
|
|
441
|
+
onChange,
|
|
442
|
+
labelOn = "On — click to turn off",
|
|
443
|
+
labelOff = "Off — click to turn on",
|
|
444
|
+
}: {
|
|
445
|
+
checked: boolean
|
|
446
|
+
onChange: (next: boolean) => void
|
|
447
|
+
labelOn?: string
|
|
448
|
+
labelOff?: string
|
|
449
|
+
}) {
|
|
450
|
+
return (
|
|
451
|
+
<Tip side="top" label={checked ? labelOn : labelOff}>
|
|
452
|
+
<span
|
|
453
|
+
className="inline-flex items-center"
|
|
454
|
+
onClick={(e) => e.stopPropagation()}
|
|
455
|
+
>
|
|
456
|
+
<ToggleSwitch
|
|
457
|
+
checked={checked}
|
|
458
|
+
onChange={() => onChange(!checked)}
|
|
459
|
+
/>
|
|
460
|
+
</span>
|
|
461
|
+
</Tip>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
466
|
+
* Attachments / links / time
|
|
467
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Attachment indicator — paperclip + count chip; muted dash when zero. A
|
|
471
|
+
* focusable `Tip` exposes the count for screen-reader users; the chip is
|
|
472
|
+
* non-interactive — wire `onClick` from the column def if you need a popover.
|
|
473
|
+
*/
|
|
474
|
+
export function AttachmentCountCell({
|
|
475
|
+
count,
|
|
476
|
+
}: {
|
|
477
|
+
count: number | null | undefined
|
|
478
|
+
}) {
|
|
479
|
+
if (!count) return <EmptyCell label="No files" />
|
|
480
|
+
const labelText = `${count} attachment${count === 1 ? "" : "s"}`
|
|
481
|
+
return (
|
|
482
|
+
<Tip side="top" label={labelText}>
|
|
483
|
+
<span
|
|
484
|
+
className="inline-flex h-6 cursor-default items-center gap-1 rounded-md border border-border bg-background px-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
485
|
+
role="img"
|
|
486
|
+
aria-label={labelText}
|
|
487
|
+
tabIndex={0}
|
|
488
|
+
>
|
|
489
|
+
<i className="fa-light fa-paperclip text-[11px] text-muted-foreground" aria-hidden="true" />
|
|
490
|
+
<span className="tabular-nums">{count}</span>
|
|
491
|
+
</span>
|
|
492
|
+
</Tip>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* External link — truncated host label + `fa-arrow-up-right-from-square` mark.
|
|
498
|
+
* Opens in a new tab with `noopener`; full URL surfaces in the `Tip`. The link
|
|
499
|
+
* stops row click propagation so it never collides with the row's `onClick`.
|
|
500
|
+
*/
|
|
501
|
+
export function ExternalLinkCell({
|
|
502
|
+
url,
|
|
503
|
+
label,
|
|
504
|
+
className,
|
|
505
|
+
}: {
|
|
506
|
+
url: string | null | undefined
|
|
507
|
+
/** Override the host-only label (e.g. "View source"). */
|
|
508
|
+
label?: React.ReactNode
|
|
509
|
+
className?: string
|
|
510
|
+
}) {
|
|
511
|
+
if (!url) return <EmptyCell label="No link" />
|
|
512
|
+
let host = url
|
|
513
|
+
try {
|
|
514
|
+
host = new URL(url).hostname.replace(/^www\./, "")
|
|
515
|
+
} catch {
|
|
516
|
+
/* keep the raw url */
|
|
517
|
+
}
|
|
518
|
+
return (
|
|
519
|
+
<Tip side="top" label={url}>
|
|
520
|
+
<a
|
|
521
|
+
href={url}
|
|
522
|
+
target="_blank"
|
|
523
|
+
rel="noopener noreferrer"
|
|
524
|
+
onClick={(e) => e.stopPropagation()}
|
|
525
|
+
className={cn(
|
|
526
|
+
"inline-flex max-w-[180px] items-center gap-1 truncate rounded text-sm text-foreground transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
527
|
+
className,
|
|
528
|
+
)}
|
|
529
|
+
>
|
|
530
|
+
<span className="truncate">{label ?? host}</span>
|
|
531
|
+
<i className="fa-light fa-arrow-up-right-from-square text-[11px] text-muted-foreground" aria-hidden="true" />
|
|
532
|
+
</a>
|
|
533
|
+
</Tip>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
538
|
+
* Time
|
|
539
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
540
|
+
|
|
541
|
+
const RELATIVE_FMT = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" })
|
|
542
|
+
const ABS_FMT = new Intl.DateTimeFormat("en-US", {
|
|
543
|
+
dateStyle: "medium",
|
|
544
|
+
timeStyle: "short",
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
function formatRelativeAndAbsolute(
|
|
548
|
+
iso: string,
|
|
549
|
+
now: number = Date.now(),
|
|
550
|
+
): { relative: string; absolute: string } | null {
|
|
551
|
+
const d = new Date(iso)
|
|
552
|
+
if (Number.isNaN(d.getTime())) return null
|
|
553
|
+
const diffSec = Math.round((d.getTime() - now) / 1000)
|
|
554
|
+
const abs = Math.abs(diffSec)
|
|
555
|
+
let unit: Intl.RelativeTimeFormatUnit
|
|
556
|
+
let value: number
|
|
557
|
+
if (abs < 60) { unit = "second"; value = diffSec }
|
|
558
|
+
else if (abs < 3600) { unit = "minute"; value = Math.round(diffSec / 60) }
|
|
559
|
+
else if (abs < 86400) { unit = "hour"; value = Math.round(diffSec / 3600) }
|
|
560
|
+
else if (abs < 86400 * 7) { unit = "day"; value = Math.round(diffSec / 86400) }
|
|
561
|
+
else if (abs < 86400 * 30) { unit = "week"; value = Math.round(diffSec / (86400 * 7)) }
|
|
562
|
+
else if (abs < 86400 * 365){ unit = "month"; value = Math.round(diffSec / (86400 * 30)) }
|
|
563
|
+
else { unit = "year"; value = Math.round(diffSec / (86400 * 365)) }
|
|
564
|
+
return { relative: RELATIVE_FMT.format(value, unit), absolute: ABS_FMT.format(d) }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Relative time — "3 hours ago" / "2 days ago" with a `Tip` exposing the
|
|
569
|
+
* absolute timestamp on hover/focus. The visible label is the relative form
|
|
570
|
+
* so scanning readers see recency at a glance.
|
|
571
|
+
*/
|
|
572
|
+
export function RelativeTimeCell({
|
|
573
|
+
iso,
|
|
574
|
+
now,
|
|
575
|
+
}: {
|
|
576
|
+
iso: string | null | undefined
|
|
577
|
+
/** Override "now" for deterministic snapshots. */
|
|
578
|
+
now?: number
|
|
579
|
+
}) {
|
|
580
|
+
if (!iso) return <EmptyCell label="No date" />
|
|
581
|
+
const fmt = formatRelativeAndAbsolute(iso, now)
|
|
582
|
+
if (!fmt) return <EmptyCell label="Invalid date" />
|
|
583
|
+
return (
|
|
584
|
+
<Tip side="top" label={fmt.absolute}>
|
|
585
|
+
<span
|
|
586
|
+
className="inline-block text-sm text-foreground/90 whitespace-nowrap rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
587
|
+
tabIndex={0}
|
|
588
|
+
>
|
|
589
|
+
{fmt.relative}
|
|
590
|
+
</span>
|
|
591
|
+
</Tip>
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
596
|
+
* Row actions ⋯
|
|
597
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
598
|
+
|
|
599
|
+
export interface RowActionDef<TRow> {
|
|
600
|
+
label: string
|
|
601
|
+
/** FA glyph name without the family prefix, e.g. `"fa-pen-to-square"`. */
|
|
602
|
+
icon: string
|
|
603
|
+
onSelect: (row: TRow) => void
|
|
604
|
+
/** Render as the destructive variant — separator + red label. */
|
|
605
|
+
variant?: "destructive"
|
|
606
|
+
/** Optional menu-item keyboard shortcut hint (e.g. `"⌘E"`). */
|
|
607
|
+
shortcut?: string
|
|
608
|
+
/** Disable the item without hiding it. */
|
|
609
|
+
disabled?: boolean
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Row overflow `⋯` menu — generic across hubs. Pass the row and an array of
|
|
614
|
+
* `{ label, icon, onSelect, variant?, shortcut? }`; destructive items
|
|
615
|
+
* automatically gain a separator above. The trigger keeps an `aria-label` so
|
|
616
|
+
* the button is named for screen readers.
|
|
617
|
+
*/
|
|
618
|
+
export function RowActionsCell<TRow>({
|
|
619
|
+
row,
|
|
620
|
+
actions,
|
|
621
|
+
triggerLabel = "More options",
|
|
622
|
+
align = "end",
|
|
623
|
+
}: {
|
|
624
|
+
row: TRow
|
|
625
|
+
actions: RowActionDef<TRow>[]
|
|
626
|
+
/** Both the `Tip` content and the `aria-label` fallback. */
|
|
627
|
+
triggerLabel?: string
|
|
628
|
+
align?: "start" | "center" | "end"
|
|
629
|
+
}) {
|
|
630
|
+
return (
|
|
631
|
+
<DropdownMenu>
|
|
632
|
+
<Tip side="top" label={triggerLabel}>
|
|
633
|
+
<DropdownMenuTrigger asChild>
|
|
634
|
+
<Button
|
|
635
|
+
size="icon-sm"
|
|
636
|
+
variant="ghost"
|
|
637
|
+
aria-label={triggerLabel}
|
|
638
|
+
onClick={(e) => e.stopPropagation()}
|
|
639
|
+
>
|
|
640
|
+
<i className="fa-light fa-ellipsis text-sm" aria-hidden="true" />
|
|
641
|
+
</Button>
|
|
642
|
+
</DropdownMenuTrigger>
|
|
643
|
+
</Tip>
|
|
644
|
+
<DropdownMenuContent align={align}>
|
|
645
|
+
{actions.map((a, i) => {
|
|
646
|
+
const prev = actions[i - 1]
|
|
647
|
+
const needsSeparator =
|
|
648
|
+
a.variant === "destructive" && prev && prev.variant !== "destructive"
|
|
649
|
+
return (
|
|
650
|
+
<React.Fragment key={a.label}>
|
|
651
|
+
{needsSeparator ? <DropdownMenuSeparator /> : null}
|
|
652
|
+
<DropdownMenuItem
|
|
653
|
+
onSelect={() => a.onSelect(row)}
|
|
654
|
+
disabled={a.disabled}
|
|
655
|
+
shortcut={a.shortcut}
|
|
656
|
+
className={a.variant === "destructive" ? "text-destructive focus:text-destructive" : ""}
|
|
657
|
+
>
|
|
658
|
+
<i className={`fa-light ${a.icon}`} aria-hidden="true" />
|
|
659
|
+
{a.label}
|
|
660
|
+
</DropdownMenuItem>
|
|
661
|
+
</React.Fragment>
|
|
662
|
+
)
|
|
663
|
+
})}
|
|
664
|
+
</DropdownMenuContent>
|
|
665
|
+
</DropdownMenu>
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ────────────────────────────────────────────────────────────────────────── *
|
|
670
|
+
* Exports — see `columns-showcase.tsx` for the live catalog.
|
|
671
|
+
* ────────────────────────────────────────────────────────────────────────── */
|
|
672
|
+
|
|
673
|
+
export { EMPTY_DASH }
|