@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.
Files changed (214) hide show
  1. package/CHANGELOG.md +701 -6
  2. package/README.md +138 -0
  3. package/bin/init.mjs +134 -31
  4. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
  5. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
  6. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
  7. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
  8. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
  9. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
  10. package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
  11. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
  12. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
  13. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
  14. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
  15. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  16. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  17. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
  18. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
  19. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
  20. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
  21. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
  22. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
  23. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
  24. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
  25. package/consumer-extras/handbook/HANDBOOK.md +2 -0
  26. package/consumer-extras/handbook/glossary.md +2 -1
  27. package/consumer-extras/handbook/reference-implementations.md +31 -4
  28. package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
  29. package/consumer-extras/patterns/data-views-pattern.md +18 -16
  30. package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
  31. package/dist/components/data-table/index.js +2 -2
  32. package/dist/components/data-table/index.js.map +1 -1
  33. package/dist/components/data-table/pagination.js +3 -3
  34. package/dist/components/data-table/pagination.js.map +1 -1
  35. package/dist/components/data-table/use-table-state.d.ts +1 -1
  36. package/dist/components/data-table/use-table-state.js.map +1 -1
  37. package/dist/components/data-views/data-row-list.js.map +1 -1
  38. package/dist/components/data-views/finder-panel-view.d.ts +1 -1
  39. package/dist/components/data-views/finder-panel-view.js.map +1 -1
  40. package/dist/components/data-views/hub-table.d.ts +9 -3
  41. package/dist/components/data-views/hub-table.js +262 -40
  42. package/dist/components/data-views/hub-table.js.map +1 -1
  43. package/dist/components/data-views/index.js +262 -40
  44. package/dist/components/data-views/index.js.map +1 -1
  45. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
  46. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
  47. package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
  48. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
  49. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
  50. package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
  51. package/dist/components/data-views/os-folder-glyph.js.map +1 -1
  52. package/dist/components/ui/avatar.d.ts +1 -1
  53. package/dist/components/ui/key-metrics.js.map +1 -1
  54. package/dist/index.js +136 -39
  55. package/dist/index.js.map +1 -1
  56. package/package.json +3 -2
  57. package/src/components/data-table/index.tsx +2 -2
  58. package/src/components/data-table/pagination.tsx +5 -1
  59. package/src/components/data-table/use-table-state.ts +1 -1
  60. package/src/components/data-views/data-row-list.tsx +1 -1
  61. package/src/components/data-views/finder-panel-view.tsx +2 -2
  62. package/src/components/data-views/hub-table.tsx +149 -41
  63. package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
  64. package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
  65. package/src/components/data-views/os-folder-glyph.tsx +1 -1
  66. package/src/components/ui/key-metrics.tsx +1 -1
  67. package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
  68. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  69. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  70. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
  71. package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
  72. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
  73. package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
  74. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
  75. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  76. package/template/AGENTS.md +43 -37
  77. package/template/app/(app)/columns/page.tsx +11 -0
  78. package/template/app/(app)/library/all/page.tsx +11 -0
  79. package/template/app/(app)/library/find/page.tsx +12 -0
  80. package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
  81. package/template/app/(app)/library/list/page.tsx +12 -0
  82. package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
  83. package/template/app/(app)/library/page.tsx +11 -0
  84. package/template/app/(app)/tokens-themes/page.tsx +11 -0
  85. package/template/components/ask-leo-composer.tsx +2 -2
  86. package/template/components/columns-client.tsx +158 -0
  87. package/template/components/columns-showcase.tsx +541 -0
  88. package/template/components/data-views/index.ts +32 -6
  89. package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
  90. package/template/components/data-views/table-cells.tsx +673 -0
  91. package/template/components/folder-details-shell.tsx +11 -11
  92. package/template/components/hub-tree-panel-view.tsx +24 -24
  93. package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
  94. package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
  95. package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
  96. package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
  97. package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
  98. package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
  99. package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
  100. package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
  101. package/template/components/library-panel-activator.tsx +8 -0
  102. package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
  103. package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
  104. package/template/components/list-hub-status-badge.tsx +2 -2
  105. package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
  106. package/template/components/sidebar/app-sidebar.tsx +61 -5
  107. package/template/components/sidebar/secondary-panel.tsx +109 -56
  108. package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
  109. package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
  110. package/template/components/table-properties/types.ts +1 -1
  111. package/template/components/templates/discovery-hub-template.tsx +1 -1
  112. package/template/components/templates/new-focus-template.tsx +2 -2
  113. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  114. package/template/components/tokens-secondary-nav.tsx +192 -0
  115. package/template/components/tokens-themes-client.tsx +476 -0
  116. package/template/components/tokens-themes-section.tsx +386 -0
  117. package/template/docs/HANDBOOK.md +187 -0
  118. package/template/docs/blueprints/README.md +1 -1
  119. package/template/docs/blueprints/board-card.md +1 -1
  120. package/template/docs/blueprints/data-table.md +2 -2
  121. package/template/docs/blueprints/list-page-template.md +3 -3
  122. package/template/docs/blueprints/page-header.md +4 -4
  123. package/template/docs/collaboration-access-pattern.md +7 -7
  124. package/template/docs/component-selection-guide.md +1 -1
  125. package/template/docs/data-views-pattern.md +18 -16
  126. package/template/docs/glossary.md +58 -0
  127. package/template/docs/kpi-flat-band-pattern.md +3 -3
  128. package/template/docs/kpi-trend-pattern.md +18 -3
  129. package/template/docs/large-dataset-strategy.md +155 -0
  130. package/template/docs/library-hub-header-pattern.md +25 -0
  131. package/template/docs/migrations/_template.md +1 -1
  132. package/template/docs/reference-implementations.md +151 -0
  133. package/template/docs/token-taxonomy.md +1 -1
  134. package/template/docs/voice-and-tone.md +262 -0
  135. package/template/eslint.config.mjs +9 -39
  136. package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
  137. package/template/lib/ask-leo-route-context.ts +6 -18
  138. package/template/lib/coach-mark-registry.ts +0 -16
  139. package/template/lib/command-menu-config.ts +5 -12
  140. package/template/lib/command-menu-search-data.ts +8 -39
  141. package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
  142. package/template/lib/library-dedicated-search.ts +19 -0
  143. package/template/lib/library-hub-search.ts +90 -0
  144. package/template/lib/library-nav.ts +477 -0
  145. package/template/lib/library-recent-searches.ts +22 -0
  146. package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
  147. package/template/lib/list-status-badges.ts +16 -104
  148. package/template/lib/mock/dashboard.ts +1 -1
  149. package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
  150. package/template/lib/mock/library-header-collaborators.ts +54 -0
  151. package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
  152. package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
  153. package/template/lib/mock/library.ts +249 -0
  154. package/template/lib/mock/navigation.tsx +32 -26
  155. package/template/lib/table-state-lifecycle.ts +1 -1
  156. package/template/next.config.mjs +7 -4
  157. package/template/package.json +0 -1
  158. package/tokens/hooks-index.json +2874 -0
  159. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
  160. package/template/app/(app)/examples/page.tsx +0 -41
  161. package/template/app/(app)/question-bank/find/page.tsx +0 -12
  162. package/template/app/(app)/question-bank/library/page.tsx +0 -11
  163. package/template/app/(app)/question-bank/list/page.tsx +0 -12
  164. package/template/app/(app)/question-bank/page.tsx +0 -11
  165. package/template/components/compliance-board-view.tsx +0 -142
  166. package/template/components/compliance-client.tsx +0 -92
  167. package/template/components/compliance-page-header.tsx +0 -89
  168. package/template/components/compliance-table.tsx +0 -468
  169. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  170. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  171. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  172. package/template/components/new-placement-back-btn.tsx +0 -28
  173. package/template/components/new-placement-form.tsx +0 -942
  174. package/template/components/placement-board-card.tsx +0 -250
  175. package/template/components/placement-detail.tsx +0 -438
  176. package/template/components/placements-board-view.tsx +0 -397
  177. package/template/components/placements-client.tsx +0 -220
  178. package/template/components/placements-list-view.tsx +0 -124
  179. package/template/components/placements-page-header.tsx +0 -166
  180. package/template/components/placements-table-cells.test.tsx +0 -22
  181. package/template/components/placements-table-cells.tsx +0 -173
  182. package/template/components/placements-table-columns.tsx +0 -210
  183. package/template/components/placements-table.tsx +0 -934
  184. package/template/components/question-bank-panel-activator.tsx +0 -8
  185. package/template/components/rotations-empty-state.tsx +0 -50
  186. package/template/components/rotations-panel-activator.tsx +0 -8
  187. package/template/components/sites-board-view.tsx +0 -67
  188. package/template/components/sites-client.tsx +0 -154
  189. package/template/components/sites-table.tsx +0 -249
  190. package/template/components/team-board-view.tsx +0 -122
  191. package/template/components/team-client.tsx +0 -100
  192. package/template/components/team-page-header.tsx +0 -92
  193. package/template/components/team-table.tsx +0 -553
  194. package/template/docs/question-bank-hub-header-pattern.md +0 -25
  195. package/template/lib/compliance-supported-views.ts +0 -10
  196. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  197. package/template/lib/mock/compliance-kpi.ts +0 -61
  198. package/template/lib/mock/compliance.ts +0 -146
  199. package/template/lib/mock/placements-kpi.ts +0 -134
  200. package/template/lib/mock/placements.ts +0 -176
  201. package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
  202. package/template/lib/mock/question-bank.ts +0 -249
  203. package/template/lib/mock/sites-directory.ts +0 -16
  204. package/template/lib/mock/sites-kpi.ts +0 -25
  205. package/template/lib/mock/team-kpi.ts +0 -60
  206. package/template/lib/mock/team.ts +0 -118
  207. package/template/lib/placement-board-card-layout.ts +0 -79
  208. package/template/lib/question-bank-dedicated-search.ts +0 -19
  209. package/template/lib/question-bank-hub-search.ts +0 -90
  210. package/template/lib/question-bank-nav.ts +0 -477
  211. package/template/lib/question-bank-recent-searches.ts +0 -22
  212. package/template/lib/question-bank-supported-views.ts +0 -12
  213. package/template/lib/sites-supported-views.ts +0 -10
  214. 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 }