@lovett/ui 0.0.11 → 0.2.0

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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,960 @@
1
+ /**
2
+ * ChartFrame — the shared chrome every chart primitive composes.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer). It exists so the title row, legend
5
+ * slot, y-gutter, gridlines, baseline, x-labels, the four states and the
6
+ * table view are written once rather than once per chart.
7
+ *
8
+ * CONSUMERS — measured 2026-09-06.
9
+ * TODAY, inside `@lovett/ui` (2): `line-chart.tsx` (`LineChart` /
10
+ * `AreaChart`) and `bar-chart.tsx` (`BarChart`).
11
+ * `DonutChart` and `RankedBars` do NOT compose it — a donut has no axes and
12
+ * ranked bars are their own rows — so the earlier claim that this is "every
13
+ * chart in the set" was simply wrong.
14
+ * TODAY, in product code (0): no file under `apps/workspace/src` imports
15
+ * `ChartFrame`. It reaches the product only through the two charts above.
16
+ * Tracked in `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
17
+ *
18
+ * ANATOMY (Metoric spec §6.2, mapped onto our tokens — real measurements,
19
+ * not eyeballed)
20
+ * -----------------------------------------------------------------------
21
+ * frame opaque panel, `--surface-card`, `--radius-md`, pad `--space-4`
22
+ * y-labels left column, `--text-tertiary`, 14px, ls -0.5, tabular-nums,
23
+ * each centred on its own gridline. HOW MANY is decided by the
24
+ * plot's own height, not by a fixed number: a tick needs about
25
+ * two line boxes of room, so a short panel gets a shorter ladder
26
+ * rather than five labels stacked into 40px of gutter.
27
+ * gridlines `rgb(var(--chart-grid))`, one per tick, count from `fitTicks`
28
+ * baseline `rgb(var(--chart-axis))` — **deliberately a darker rule than
29
+ * the gridlines.** Every chart in the kit does this, and it is
30
+ * one line of CSS that does more for legibility than any colour
31
+ * decision. It is the detail that makes a chart read as a chart.
32
+ * x-labels a row that MIRRORS the plot row — same reserved gutter, same
33
+ * gap — so its track is exactly the plot's width. Each label is
34
+ * absolutely positioned at its own `xLabelFractions` fraction
35
+ * and centred there, so it sits under the point or band it
36
+ * names; the first and last anchor to the plot edges instead of
37
+ * overhanging them.
38
+ *
39
+ * The frame is a plain `Card`-style panel with **no tray** (ADR-146 D1): a
40
+ * tray appears only when a widget holds more than one internal panel, which
41
+ * is what stops every chart growing a redundant 4px frame.
42
+ *
43
+ * THE FOUR STATES, ALL IN RESERVED SPACE (D10.7)
44
+ * ----------------------------------------------
45
+ * `loaded` / `loading` / `empty` / `error`. The plot box carries an
46
+ * `aspect-ratio`, so its height is fixed by its width and **async data cannot
47
+ * shift the page** — no white gap, no spinner in a collapsed box. `loading`
48
+ * is a correctly-sized skeleton occupying the same box the plot will.
49
+ *
50
+ * THE TABLE VIEW (D10.5)
51
+ * ----------------------
52
+ * Every chart that composes `ChartFrame` gets an accessible `<table>` of the
53
+ * same data for free. Pass `table`; the toggle appears and swaps the plot for
54
+ * the table. Omit it and no toggle renders — a control that leads nowhere is
55
+ * worse than no control. The table is the chart's accessible representation:
56
+ * a hand-rolled SVG plot is not readable by assistive tech, and this is how
57
+ * the contract is met rather than asserted.
58
+ *
59
+ * Token discipline: every colour is `rgb(var(--token))`; every spacing,
60
+ * radius and control height is a token. `--chart-grid` / `--chart-axis` carry
61
+ * their own alpha, so they are consumed as `rgb(var(--chart-grid))` and
62
+ * **never** given a second one — `rgb(var(--chart-grid) / 0.5)` expands to
63
+ * `rgb(0 0 0 / 0.08 / 0.5)`, which is invalid and silently discarded.
64
+ * `--muted` is used only for the skeleton well and the table header strip —
65
+ * flat opaque greys INSIDE an opaque panel, which is required structure, not
66
+ * a panel surface (CLAUDE.md §2 data-display recipe).
67
+ */
68
+
69
+ import {
70
+ useLayoutEffect,
71
+ useState,
72
+ type ReactNode,
73
+ } from 'react'
74
+ import { ChartLine, Inbox, Table2, TriangleAlert } from 'lucide-react'
75
+
76
+ import { cn } from './lib/utils'
77
+ import {
78
+ estimateTextWidth,
79
+ fitLabelCount,
80
+ fitTicks,
81
+ strideIndices,
82
+ } from './lib/chart'
83
+
84
+ /**
85
+ * The room an x label needs beside its neighbour — the `--space-2` gutter the
86
+ * slot already subtracts, kept here so the capacity fit and the paint agree.
87
+ * The type size itself is `X_LABEL_FONT_PX`, declared with the other axis
88
+ * metrics below.
89
+ */
90
+ const X_LABEL_GUTTER_PX = 8
91
+
92
+ /**
93
+ * Y-axis metrics. The font size is what the tick row renders (`text-[14px]`);
94
+ * `SPACE_BASE_PX` is the 4px base of the `--space` scale, so a derived gutter
95
+ * still snaps to a rung; `Y_GUTTER_MIN_PX` is `var(--space-10)`, the width the
96
+ * gutter had when it was fixed — kept as a FLOOR so short-label axes do not
97
+ * tighten up now that the width adapts.
98
+ */
99
+ const Y_TICK_FONT_PX = 14
100
+ const SPACE_BASE_PX = 4
101
+ const Y_GUTTER_MIN_PX = 40
102
+
103
+ /** Which of the four states the frame is rendering. */
104
+ export type ChartFrameState = 'loaded' | 'loading' | 'empty' | 'error'
105
+
106
+ /** Chart or table. The table is the accessible representation (D10.5). */
107
+ export type ChartFrameView = 'chart' | 'table'
108
+
109
+ export interface ChartTableRow {
110
+ /** Stable identity key — the React key, never the array index. */
111
+ key: string
112
+ /**
113
+ * One pre-formatted string per column, in `columns` order. Formatting is
114
+ * the chart's job (`@lovett/ui/format`); the table only sets numbers in
115
+ * `tabular-nums`.
116
+ */
117
+ cells: readonly string[]
118
+ }
119
+
120
+ export interface ChartTableData {
121
+ /** Header cells. The first is the category column, the rest are measures. */
122
+ columns: readonly string[]
123
+ /** Body rows. */
124
+ rows: readonly ChartTableRow[]
125
+ /**
126
+ * Screen-reader caption describing the table. Defaults to the frame's
127
+ * `title`. Visually hidden — the figure caption already shows the title.
128
+ */
129
+ caption?: string
130
+ }
131
+
132
+ export interface ChartFrameProps {
133
+ /** Chart title. With a single series this also names it, so no legend
134
+ * box is needed (D10.2). */
135
+ title?: string
136
+ /** Optional supporting line under the title. */
137
+ subtitle?: string
138
+ /** Trailing slot in the title row — a range picker, a menu. Sits before
139
+ * the table toggle. */
140
+ action?: ReactNode
141
+ /**
142
+ * Legend node, rendered under the title row. Pass a `<ChartLegend>`.
143
+ * Two or more series always render one (D10.1); a single series does not
144
+ * (D10.2).
145
+ */
146
+ legend?: ReactNode
147
+ /** Which state to render. Default `'loaded'`. */
148
+ state?: ChartFrameState
149
+ /** Message for `state="empty"`. Default `'No data for this range.'` */
150
+ emptyMessage?: string
151
+ /** Message for `state="error"`. Default `'This chart could not load.'` */
152
+ errorMessage?: string
153
+ /** Adds a retry button to the error state. Omit for a non-retryable error. */
154
+ onRetry?: () => void
155
+ /**
156
+ * Explicit y-axis tick labels, **top to bottom** (max first). Takes
157
+ * precedence over `yDomain`. Use when the axis is categorical or already
158
+ * formatted upstream.
159
+ */
160
+ yLabels?: readonly string[]
161
+ /**
162
+ * `[min, max]` for a generated axis. Ticks come from `niceTicks` — the
163
+ * nice-number generator that replaces the literal `[0.25, 0.5, 0.75]`
164
+ * fraction list this repo used to inline. Ignored when `yLabels` is set.
165
+ */
166
+ yDomain?: readonly [number, number]
167
+ /** Target tick count for `yDomain`. Default `5`. */
168
+ yTickCount?: number
169
+ /**
170
+ * Formats a generated tick. Default `String`. **`formatPercent` takes
171
+ * percent units, not a 0-1 fraction** (ADR-146 D11) — convert at the call
172
+ * site.
173
+ */
174
+ formatYTick?: (value: number) => string
175
+ /** x-axis labels, left to right. */
176
+ xLabels?: readonly string[]
177
+ /**
178
+ * Where each `xLabels` entry actually SITS, as a fraction `0..1` of the
179
+ * plot's width. Same length as `xLabels`.
180
+ *
181
+ * Without this the row can only spread its labels evenly, which is a
182
+ * different axis from the one the marks are drawn on: a band scale centres
183
+ * its first category half a band in, and a thinned point axis keeps
184
+ * positions 0, 2, 4 … — neither is an even spread. Evenly spreading them
185
+ * put "Paid search" 25px, and the 3-category case 65px, away from the bar
186
+ * it names (measured in Chromium at 1440, 2026-09-06).
187
+ *
188
+ * Omit it and the labels fall back to an even point spread
189
+ * (`i / (n - 1)`), which is right for an unthinned line axis and wrong for
190
+ * everything else — so charts pass it.
191
+ */
192
+ xLabelFractions?: readonly number[]
193
+ /**
194
+ * Plot width ÷ height. The plot reserves its height from this, so async
195
+ * data cannot reflow the page. Default `16 / 5`, the kit's 459×108 plot.
196
+ * Pass `1` for a donut.
197
+ */
198
+ aspectRatio?: number
199
+ /**
200
+ * Reserved width of the y-label gutter, and the x-label row's left pad.
201
+ *
202
+ * Omit it and the gutter is DERIVED from the widest tick actually drawn,
203
+ * snapped up to the `--space` scale with `var(--space-10)` (40px) as the
204
+ * floor. That is the default because a fixed gutter cannot hold a label it
205
+ * has never seen: the labels are `right-0` and `whitespace-nowrap`, so a
206
+ * `$60,000` in a 40px box does not wrap or clip — it overflows LEFTWARD out
207
+ * of the gutter and through the panel's padding, and the axis ends up 2px
208
+ * from the panel edge while a `350` on the next chart sits 31px in.
209
+ *
210
+ * Pass a token-derived length to override it, and own the fit yourself.
211
+ */
212
+ yGutterWidth?: string
213
+ /** The same data as an accessible table. Omit to hide the view toggle. */
214
+ table?: ChartTableData
215
+ /** Controlled view. Omit for uncontrolled (starts on `'chart'`). */
216
+ view?: ChartFrameView
217
+ /** Called when the toggle changes the view. */
218
+ onViewChange?: (view: ChartFrameView) => void
219
+ /** The plot itself — an SVG, positioned absolutely inside the plot box.
220
+ * The box is `position: relative`, which is what `ChartTooltip` needs. */
221
+ children?: ReactNode
222
+ /** Optional className on the outer panel. */
223
+ className?: string
224
+ }
225
+
226
+ /** Gap between the y-gutter and the plot, and the x-label row's extra pad. */
227
+ const GUTTER_GAP = 'var(--space-3)'
228
+
229
+ /**
230
+ * Line box of one y-tick label, in pixels.
231
+ *
232
+ * The gutter labels are `text-[14px]` against the frame's inherited 1.5 line
233
+ * height, and Chromium reports exactly `21px` for every one of them
234
+ * (`getBoundingClientRect().height`, measured off the shipped gallery
235
+ * 2026-09-06, light and dark, 390/900/1440). Pixel arithmetic cannot read a
236
+ * CSS custom property, so this is a number that names what it mirrors — the
237
+ * same contract as `bar-chart.tsx`'s `GAP_FULL_PX`.
238
+ *
239
+ * Not exported on its own: what the charts need is the SPACING below, and
240
+ * two different line-height numbers in two files is a chart whose axis
241
+ * disagrees with its own gutter.
242
+ */
243
+ const Y_TICK_LINE_PX = 21
244
+
245
+ /**
246
+ * The room two adjacent y ticks need between them: two line boxes.
247
+ *
248
+ * One line box apart is two labels touching; two is a ladder that reads as a
249
+ * ladder. Every y axis in the set — this frame's generated one, LineChart's
250
+ * and BarChart's — fits against THIS number, so the gutter and the scale can
251
+ * never disagree about how many ticks there are.
252
+ */
253
+ export const Y_TICK_MIN_SPACING_PX = 2 * Y_TICK_LINE_PX
254
+
255
+ /**
256
+ * Font size of an x-axis label, in pixels — `text-[14px]` on the row below.
257
+ * Charts estimate label widths against it when deciding how many to keep.
258
+ */
259
+ export const X_LABEL_FONT_PX = 14
260
+
261
+ /**
262
+ * Measure a box's height, and keep measuring it. `null` means NOT MEASURED —
263
+ * which is a different fact from "measured, and it is zero", and the
264
+ * difference is load-bearing.
265
+ *
266
+ * A zero-height plot is real: at a 390 viewport the design-system gallery's
267
+ * own shell collapses its content column, and the plot box measures 0 x 0.
268
+ * An axis in a 0px box has room for nothing, so its NUMBERS come off — but
269
+ * only when we actually know the box is zero. Reading an unmeasured box as
270
+ * zero would strip the numbers off every chart's first paint and then put
271
+ * them back, which is a worse defect than the one being fixed.
272
+ *
273
+ * The ONLY reading trusted is one a `ResizeObserver` delivered. That is the
274
+ * discriminator: jsdom ships a no-op observer stub whose callback never
275
+ * fires, so `clientHeight` there — always `0` — is never mistaken for a
276
+ * measurement, while a real browser's observer fires on `observe()` before
277
+ * the first paint and hands over the true size, zero or not.
278
+ *
279
+ * Only armed when the caller needs it (`enabled`), so a frame with no axis
280
+ * does not observe anything.
281
+ */
282
+ function usePlotBox(
283
+ node: HTMLDivElement | null,
284
+ enabled: boolean,
285
+ ): { width: number; height: number } | null {
286
+ const [box, setBox] = useState<{ width: number; height: number } | null>(null)
287
+
288
+ useLayoutEffect(() => {
289
+ if (node === null || !enabled || typeof ResizeObserver === 'undefined') {
290
+ return
291
+ }
292
+
293
+ const observer = new ResizeObserver(() => {
294
+ const width = node.clientWidth
295
+ const height = node.clientHeight
296
+ setBox((prev) =>
297
+ prev !== null && prev.width === width && prev.height === height
298
+ ? prev
299
+ : { width, height },
300
+ )
301
+ })
302
+ observer.observe(node)
303
+ return () => observer.disconnect()
304
+ }, [node, enabled])
305
+
306
+ return box
307
+ }
308
+
309
+ /**
310
+ * Where label `index` sits along the x axis, as a fraction `0..1`.
311
+ *
312
+ * The fallback when no `xLabelFractions` is given: an even POINT spread, so
313
+ * the first label sits on the plot's left edge and the last on its right.
314
+ */
315
+ function xFraction(
316
+ fractions: readonly number[] | undefined,
317
+ index: number,
318
+ count: number,
319
+ ): number {
320
+ const given = fractions?.[index]
321
+ if (given !== undefined && Number.isFinite(given)) {
322
+ return Math.max(0, Math.min(1, given))
323
+ }
324
+ if (count <= 1) return 0.5
325
+ return index / (count - 1)
326
+ }
327
+
328
+ /**
329
+ * Vertical position of tick `index` of `count`, as a percentage from the top.
330
+ *
331
+ * A single tick sits at the **middle**, not the top or bottom: `niceTicks`
332
+ * returns one tick for a flat series, and `linearScale` maps a flat series to
333
+ * the middle of the range — so the label has to agree with where the line
334
+ * actually draws, or the axis lies.
335
+ */
336
+ function tickPercent(index: number, count: number): number {
337
+ if (count <= 1) return 50
338
+ return (index / (count - 1)) * 100
339
+ }
340
+
341
+ export function ChartFrame({
342
+ title,
343
+ subtitle,
344
+ action,
345
+ legend,
346
+ state = 'loaded',
347
+ emptyMessage = 'No data for this range.',
348
+ errorMessage = 'This chart could not load.',
349
+ onRetry,
350
+ yLabels,
351
+ yDomain,
352
+ yTickCount = 5,
353
+ formatYTick = String,
354
+ xLabels,
355
+ xLabelFractions,
356
+ aspectRatio = 16 / 5,
357
+ yGutterWidth,
358
+ table,
359
+ view,
360
+ onViewChange,
361
+ children,
362
+ className,
363
+ }: ChartFrameProps) {
364
+ const [uncontrolledView, setUncontrolledView] = useState<ChartFrameView>('chart')
365
+ const activeView = view ?? uncontrolledView
366
+
367
+ function changeView(next: ChartFrameView) {
368
+ if (view === undefined) setUncontrolledView(next)
369
+ onViewChange?.(next)
370
+ }
371
+
372
+ // A generated axis has to FIT the plot it is drawn in, so the plot box is
373
+ // measured. Only when this frame is the one generating the ladder: a chart
374
+ // that passes `yLabels` has already fitted them against the same
375
+ // `Y_TICK_MIN_SPACING_PX`, and observing the box again would be a second
376
+ // ResizeObserver measuring the same thing to no effect.
377
+ // A CALLBACK ref, not `useRef`. The plot box unmounts and remounts — the
378
+ // table view swaps the whole plot row out, and `children` only render in the
379
+ // `loaded` state, so a chart that starts in `loading` or `empty` (the normal
380
+ // async path) paints its plot for the first time on a LATER render. A
381
+ // `useRef` + `[ref]` effect cannot see any of that: the ref object is stable
382
+ // for the component's life, so the observer binds once, to whatever node
383
+ // existed then, and never rebinds. The fit would then silently fall back to
384
+ // nominal sizes — which is the exact label overlap the measured fit exists
385
+ // to prevent. Node state re-fires the effect on every remount.
386
+ const [plotNode, setPlotNode] = useState<HTMLDivElement | null>(null)
387
+ const hasAxis = yLabels !== undefined || yDomain !== undefined
388
+ // The x-label row needs the plot's WIDTH for the same reason the y ladder
389
+ // needs its height, so the box is observed whenever either axis exists.
390
+ const plotBox = usePlotBox(plotNode, hasAxis || xLabels !== undefined)
391
+ const plotHeight = plotBox?.height ?? null
392
+
393
+ // Ticks are top-to-bottom (max first). `fitTicks` is ascending, so a
394
+ // generated axis is reversed for display.
395
+ const ticks: string[] =
396
+ yLabels !== undefined
397
+ ? [...yLabels]
398
+ : yDomain !== undefined
399
+ ? fitTicks(
400
+ yDomain[0],
401
+ yDomain[1],
402
+ // `null` (unmeasured) reads as 0, which `fitTicks` already
403
+ // treats as "leave the requested count alone".
404
+ plotHeight ?? 0,
405
+ Y_TICK_MIN_SPACING_PX,
406
+ yTickCount,
407
+ )
408
+ .map((value) => formatYTick(value))
409
+ .reverse()
410
+ : []
411
+
412
+ /**
413
+ * Below one line box of plot, two labels at the two extremes overlap
414
+ * whatever the tick count is — the floor of two ticks cannot go lower.
415
+ * Measured: an 8px plot painted "$50,000" over "$0" by 12.9px, and a
416
+ * 0px one (the gallery shell's collapsed column at a 390 viewport) painted
417
+ * every pair of a three-rung ladder on top of each other.
418
+ *
419
+ * So under that floor the axis keeps its STRUCTURE — the gutter's reserved
420
+ * width and the gridlines, which are what make the plot readable as a plot
421
+ * — and drops the NUMBERS, which at that size are not readable as numbers.
422
+ * It is the same "keep the structure, drop the lie" move `BarChart` already
423
+ * makes for an all-zero range, and it is strictly better than two figures
424
+ * printed on top of each other.
425
+ *
426
+ * An UNMEASURED plot (`null`) keeps its numbers: not knowing the height is
427
+ * not the same as knowing it is too small.
428
+ */
429
+ const tickNumbersFit = plotHeight === null || plotHeight >= Y_TICK_LINE_PX
430
+
431
+ /**
432
+ * The gutter has to hold the LONGEST label it will actually draw, or the
433
+ * axis crowds the panel edge — `$60,000` measured 2px from it while `350`
434
+ * on a neighbouring chart sat 31px in, because a fixed box right-aligns its
435
+ * contents and lets the overflow escape leftward.
436
+ *
437
+ * Snapped UP to the 4px `--space` base so an adaptive width still lands on
438
+ * the scale, and floored at `--space-10` so a short-label axis keeps the
439
+ * same generous gutter it has today rather than tightening.
440
+ */
441
+ const derivedGutterPx = (() => {
442
+ if (ticks.length === 0) return 0
443
+ const widest = ticks.reduce(
444
+ (max, label) => Math.max(max, estimateTextWidth(label, Y_TICK_FONT_PX)),
445
+ 0,
446
+ )
447
+ return Math.max(
448
+ Y_GUTTER_MIN_PX,
449
+ Math.ceil(widest / SPACE_BASE_PX) * SPACE_BASE_PX,
450
+ )
451
+ })()
452
+
453
+ const hasGutter = ticks.length > 0
454
+ const gutterWidth = hasGutter
455
+ ? (yGutterWidth ?? `${derivedGutterPx}px`)
456
+ : '0px'
457
+ const showPlotChrome = state === 'loaded' || state === 'loading'
458
+ const showTable = table !== undefined && activeView === 'table'
459
+
460
+ const captionText = table?.caption ?? title ?? 'Chart data'
461
+
462
+ /**
463
+ * The narrowest gap between two adjacent x labels, as a percentage of the
464
+ * plot. It is what each label may occupy before it starts eating its
465
+ * neighbour's room — with a `--space-2` gutter subtracted at the call site
466
+ * so two full-width labels never touch.
467
+ *
468
+ * Derived from the fractions rather than from the count, because a band
469
+ * axis and a thinned point axis do not have the same spacing.
470
+ */
471
+ /**
472
+ * Which x labels actually get drawn.
473
+ *
474
+ * Constraining each label to its own slot stops neighbours overlapping, but
475
+ * on a narrow plot every slot is a few pixels wide and the whole axis
476
+ * truncates to nothing — measured on this component's own demo at a 382px
477
+ * panel: twelve "Wk n" labels at 5px each, the first and last at 0. An axis
478
+ * of empty slots is worse than a sparser one, so the row thins by a uniform
479
+ * STRIDE (`strideIndices`, which always keeps the first and the last) to
480
+ * the count the measured width can actually hold.
481
+ *
482
+ * `LineChart` already thins before it calls in; this is the same rule
483
+ * applied one level down, so a frame used directly gets it too and the two
484
+ * paths cannot disagree.
485
+ */
486
+ const keptXIndices = (() => {
487
+ const count = xLabels?.length ?? 0
488
+ if (count === 0) return []
489
+ const width = plotBox?.width ?? 0
490
+ if (!(width > 0)) return Array.from({ length: count }, (_, i) => i)
491
+ const widest = xLabels!.reduce(
492
+ (max, label) => Math.max(max, estimateTextWidth(label, X_LABEL_FONT_PX)),
493
+ 0,
494
+ )
495
+ // The EDGE labels are the binding constraint, not the middle ones. A
496
+ // middle label is centred, so it spends half its width on each side of
497
+ // its tick and a full slot holds it. An edge label is anchored to the
498
+ // plot edge and spends its whole width on one side, so it only has the
499
+ // room up to its neighbour's near edge — half a slot plus half the
500
+ // gutter. Fitting against the middle case alone is what let "Wk 1" and
501
+ // "Wk 12" truncate at 20px on a 608px axis while every middle label sat
502
+ // comfortably in 47px.
503
+ //
504
+ // Inverting `widest <= slot / 2 + gutter / 2` gives the extent below.
505
+ const extent = Math.max(
506
+ widest + X_LABEL_GUTTER_PX,
507
+ 2 * widest - X_LABEL_GUTTER_PX,
508
+ )
509
+ const capacity = fitLabelCount(width, extent, count)
510
+ return strideIndices(count, capacity)
511
+ })()
512
+
513
+ const keptXSet = new Set(keptXIndices)
514
+
515
+ /**
516
+ * The narrowest gap between two adjacent DRAWN x labels, as a percentage of
517
+ * the plot. It is what each label may occupy before it starts eating its
518
+ * neighbour's room — with a `--space-2` gutter subtracted at the call site
519
+ * so two full-width labels never touch.
520
+ *
521
+ * Derived from the fractions rather than from the count, because a band
522
+ * axis and a thinned point axis do not have the same spacing — and from the
523
+ * KEPT labels, because thinning is what widens the slot.
524
+ */
525
+ const slotPercent = (() => {
526
+ const count = xLabels?.length ?? 0
527
+ if (count <= 1 || keptXIndices.length <= 1) return 100
528
+ let narrowest = Number.POSITIVE_INFINITY
529
+ for (let i = 1; i < keptXIndices.length; i++) {
530
+ const gap =
531
+ xFraction(xLabelFractions, keptXIndices[i]!, count) -
532
+ xFraction(xLabelFractions, keptXIndices[i - 1]!, count)
533
+ if (gap < narrowest) narrowest = gap
534
+ }
535
+ if (!Number.isFinite(narrowest) || narrowest <= 0) {
536
+ return 100 / keptXIndices.length
537
+ }
538
+ return narrowest * 100
539
+ })()
540
+
541
+ return (
542
+ <figure
543
+ className={cn('flex flex-col', className)}
544
+ style={{
545
+ margin: 0,
546
+ gap: 'var(--space-3)',
547
+ background: 'rgb(var(--surface-card))',
548
+ border: '1px solid rgb(var(--border))',
549
+ borderRadius: 'var(--radius-md)',
550
+ padding: 'var(--space-4)',
551
+ }}
552
+ >
553
+ {(title !== undefined || subtitle !== undefined || action !== undefined ||
554
+ table !== undefined) && (
555
+ <figcaption className="flex items-start justify-between gap-3">
556
+ <div className="min-w-0">
557
+ {title !== undefined && (
558
+ <div
559
+ className="truncate text-[14px] font-semibold"
560
+ style={{ color: 'rgb(var(--foreground))' }}
561
+ >
562
+ {title}
563
+ </div>
564
+ )}
565
+ {subtitle !== undefined && (
566
+ <div
567
+ className="truncate text-[12px]"
568
+ style={{
569
+ marginTop: 'var(--space-1)',
570
+ color: 'rgb(var(--text-tertiary))',
571
+ }}
572
+ >
573
+ {subtitle}
574
+ </div>
575
+ )}
576
+ </div>
577
+ <div className="flex shrink-0 items-center gap-2">
578
+ {action}
579
+ {table !== undefined && (
580
+ <button
581
+ type="button"
582
+ onClick={() => changeView(showTable ? 'chart' : 'table')}
583
+ aria-label={showTable ? 'Chart view' : 'Table view'}
584
+ className={cn(
585
+ 'inline-flex cursor-pointer items-center gap-2 text-[12px] font-medium',
586
+ 'motion-safe:transition-colors hover:border-[rgb(var(--border-strong))]',
587
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
588
+ )}
589
+ style={{
590
+ height: 'var(--ctrl-sm)',
591
+ paddingInline: 'var(--space-3)',
592
+ // A bordered secondary control, never a ghost button: a
593
+ // lesser action still has to read as a button (CLAUDE.md §2).
594
+ background: 'rgb(var(--surface-overlay))',
595
+ border: '1px solid rgb(var(--border))',
596
+ borderRadius: 'var(--radius-xs)',
597
+ color: 'rgb(var(--text-secondary))',
598
+ }}
599
+ >
600
+ {showTable ? (
601
+ <ChartLine aria-hidden="true" className="h-4 w-4" />
602
+ ) : (
603
+ <Table2 aria-hidden="true" className="h-4 w-4" />
604
+ )}
605
+ {showTable ? 'Chart' : 'Table'}
606
+ </button>
607
+ )}
608
+ </div>
609
+ </figcaption>
610
+ )}
611
+
612
+ {legend}
613
+
614
+ {showTable ? (
615
+ <ChartDataTable data={table} caption={captionText} />
616
+ ) : (
617
+ <>
618
+ <div className="flex items-stretch" style={{ gap: GUTTER_GAP }}>
619
+ {/* y-gutter. Always reserved, even in empty/error, so the plot
620
+ does not change width between states. */}
621
+ <div
622
+ className="relative shrink-0"
623
+ style={{ width: gutterWidth }}
624
+ aria-hidden="true"
625
+ >
626
+ {state === 'loaded' &&
627
+ ticks.map((label, index) => (
628
+ <span
629
+ key={`${index}-${label}`}
630
+ className="absolute right-0 text-[14px] tabular-nums tracking-[-0.5px] whitespace-nowrap"
631
+ style={{
632
+ top: `${tickPercent(index, ticks.length)}%`,
633
+ transform: 'translateY(-50%)',
634
+ color: 'rgb(var(--text-tertiary))',
635
+ }}
636
+ >
637
+ {tickNumbersFit ? label : ''}
638
+ </span>
639
+ ))}
640
+ </div>
641
+
642
+ {/* Plot box. `relative` is load-bearing: ChartTooltip and every
643
+ gridline position against it, and `aspect-ratio` is what
644
+ reserves the height. */}
645
+ <div
646
+ ref={setPlotNode}
647
+ className="relative min-w-0 flex-1"
648
+ style={{ aspectRatio: String(aspectRatio) }}
649
+ >
650
+ {showPlotChrome && (
651
+ <>
652
+ {ticks.map((label, index) => {
653
+ const pct = tickPercent(index, ticks.length)
654
+ // The bottom tick IS the baseline, which is drawn below
655
+ // in the darker --chart-axis. Drawing both would stack a
656
+ // light rule on a dark one.
657
+ if (pct >= 100) return null
658
+ return (
659
+ <div
660
+ key={`grid-${index}-${label}`}
661
+ aria-hidden="true"
662
+ className="pointer-events-none absolute right-0 left-0"
663
+ style={{
664
+ top: `${pct}%`,
665
+ height: '1px',
666
+ background: 'rgb(var(--chart-grid))',
667
+ }}
668
+ />
669
+ )
670
+ })}
671
+ {/* The baseline: a DARKER rule than the gridlines. */}
672
+ <div
673
+ aria-hidden="true"
674
+ className="pointer-events-none absolute right-0 bottom-0 left-0"
675
+ style={{
676
+ height: '1px',
677
+ background: 'rgb(var(--chart-axis))',
678
+ }}
679
+ />
680
+ </>
681
+ )}
682
+
683
+ {state === 'loaded' && children}
684
+
685
+ {state === 'loading' && (
686
+ <div
687
+ role="status"
688
+ aria-label="Loading chart"
689
+ className="absolute inset-0 motion-safe:animate-pulse"
690
+ style={{
691
+ background: 'rgb(var(--muted))',
692
+ borderRadius: 'var(--radius-sm)',
693
+ }}
694
+ />
695
+ )}
696
+
697
+ {state === 'empty' && (
698
+ <ChartFrameMessage
699
+ icon={
700
+ <Inbox
701
+ aria-hidden="true"
702
+ className="h-5 w-5"
703
+ style={{ color: 'rgb(var(--text-muted))' }}
704
+ />
705
+ }
706
+ message={emptyMessage}
707
+ />
708
+ )}
709
+
710
+ {state === 'error' && (
711
+ <ChartFrameMessage
712
+ role="alert"
713
+ icon={
714
+ <TriangleAlert
715
+ aria-hidden="true"
716
+ className="h-5 w-5"
717
+ style={{ color: 'rgb(var(--destructive))' }}
718
+ />
719
+ }
720
+ message={errorMessage}
721
+ action={
722
+ onRetry !== undefined ? (
723
+ <button
724
+ type="button"
725
+ onClick={onRetry}
726
+ className={cn(
727
+ 'cursor-pointer text-[12px] font-medium',
728
+ 'motion-safe:transition-colors hover:border-[rgb(var(--border-strong))]',
729
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
730
+ )}
731
+ style={{
732
+ height: 'var(--ctrl-sm)',
733
+ paddingInline: 'var(--space-3)',
734
+ background: 'rgb(var(--surface-overlay))',
735
+ border: '1px solid rgb(var(--border))',
736
+ borderRadius: 'var(--radius-xs)',
737
+ color: 'rgb(var(--foreground))',
738
+ }}
739
+ >
740
+ Try again
741
+ </button>
742
+ ) : undefined
743
+ }
744
+ />
745
+ )}
746
+ </div>
747
+ </div>
748
+
749
+ {/* x-labels.
750
+ The row MIRRORS the plot row above it — the same reserved
751
+ gutter, the same gap — so the label track is exactly as wide as
752
+ the plot and a fraction of it means the same thing in both. It
753
+ used to be one padded flex row with `justify-between`, which
754
+ spreads labels EVENLY whatever their real x: measured in
755
+ Chromium at 1440, that put BarChart's "Paid search" 25px from
756
+ its bar and, on the 3-category demo, 65px. Every label now sits
757
+ at its own fraction and is centred there; the first and last
758
+ anchor to the plot edges instead of overhanging them.
759
+ The row keeps its height in every state so a load does not
760
+ shift the panel. */}
761
+ <div
762
+ className="flex items-stretch"
763
+ style={{ minHeight: 'var(--space-5)', gap: GUTTER_GAP }}
764
+ aria-hidden="true"
765
+ >
766
+ <div className="shrink-0" style={{ width: gutterWidth }} />
767
+ <div className="relative min-w-0 flex-1">
768
+ {state === 'loaded' &&
769
+ xLabels?.map((label, index) => {
770
+ const count = xLabels.length
771
+ // Thinned out at this width — see `keptXIndices`. The row
772
+ // keeps its height either way, so dropping a label never
773
+ // moves the plot.
774
+ if (!keptXSet.has(index)) return null
775
+ const fraction = xFraction(xLabelFractions, index, count)
776
+ // Edge labels anchor to the edge they sit on. Centring them
777
+ // would hang half a label outside the plot; this is the
778
+ // same start / middle / end rule the direct value labels
779
+ // inside the plot already use.
780
+ const anchor =
781
+ fraction <= 0 ? 'start' : fraction >= 1 ? 'end' : 'middle'
782
+ return (
783
+ <span
784
+ key={`${index}-${label}`}
785
+ className="absolute top-0 truncate text-[14px] tabular-nums tracking-[-0.5px]"
786
+ style={{
787
+ left: `${fraction * 100}%`,
788
+ transform:
789
+ anchor === 'start'
790
+ ? undefined
791
+ : anchor === 'end'
792
+ ? 'translateX(-100%)'
793
+ : 'translateX(-50%)',
794
+ // The label may claim its own share of the axis and
795
+ // no more, so `truncate` still has something to
796
+ // truncate against now that the row is not a flex
797
+ // track. `slotPercent` is the narrowest gap between
798
+ // two labels; two CENTRED neighbours each spend half
799
+ // a slot on the side that faces the other, so a full
800
+ // slot is exactly their non-overlap bound. An
801
+ // ANCHORED edge label spends its whole width on one
802
+ // side, so it gets half — measured: two edge labels
803
+ // at a full slot each overlapped by 10px on a 26px
804
+ // axis.
805
+ // A middle label is centred, so a full slot less the
806
+ // gutter is exactly its non-overlap bound. An edge
807
+ // label is anchored and spends its whole width on one
808
+ // side, so its bound is the distance to its
809
+ // neighbour's near edge: half a slot, plus back the
810
+ // half-gutter the neighbour's own box already gave
811
+ // up. Subtracting a WHOLE gutter here instead was the
812
+ // 20px truncation.
813
+ maxWidth:
814
+ anchor === 'middle'
815
+ ? `calc(${slotPercent}% - var(--space-2))`
816
+ : `calc(${slotPercent / 2}% + (var(--space-2) / 2))`,
817
+ textAlign:
818
+ anchor === 'start'
819
+ ? 'left'
820
+ : anchor === 'end'
821
+ ? 'right'
822
+ : 'center',
823
+ color: 'rgb(var(--text-tertiary))',
824
+ }}
825
+ >
826
+ {label}
827
+ </span>
828
+ )
829
+ })}
830
+ </div>
831
+ </div>
832
+ </>
833
+ )}
834
+ </figure>
835
+ )
836
+ }
837
+
838
+ interface ChartFrameMessageProps {
839
+ icon: ReactNode
840
+ message: string
841
+ action?: ReactNode
842
+ role?: 'alert'
843
+ }
844
+
845
+ /** Centred state message, inside the plot box's already-reserved space. */
846
+ function ChartFrameMessage({
847
+ icon,
848
+ message,
849
+ action,
850
+ role,
851
+ }: ChartFrameMessageProps) {
852
+ return (
853
+ <div
854
+ role={role}
855
+ className="absolute inset-0 flex flex-col items-center justify-center text-center"
856
+ style={{ gap: 'var(--space-2)', padding: 'var(--space-4)' }}
857
+ >
858
+ {icon}
859
+ <span
860
+ className="text-[13px]"
861
+ style={{ color: 'rgb(var(--text-tertiary))' }}
862
+ >
863
+ {message}
864
+ </span>
865
+ {action}
866
+ </div>
867
+ )
868
+ }
869
+
870
+ interface ChartDataTableProps {
871
+ data: ChartTableData
872
+ caption: string
873
+ }
874
+
875
+ /**
876
+ * The accessible table view.
877
+ *
878
+ * Built to the data-display surface recipe (CLAUDE.md §2): an opaque panel,
879
+ * with the grey STRUCTURE inside it — a solid `--muted` header strip and
880
+ * `--border` row rules. Stripping those is the "flat / floating" defect, and
881
+ * it is exactly as wrong as using a translucent tint for the surface.
882
+ *
883
+ * Column 0 is the category and left-aligns; every other column is a measure
884
+ * and right-aligns in `tabular-nums`, so figures line up digit for digit.
885
+ */
886
+ function ChartDataTable({ data, caption }: ChartDataTableProps) {
887
+ return (
888
+ <div
889
+ className="overflow-x-auto"
890
+ style={{
891
+ border: '1px solid rgb(var(--border))',
892
+ borderRadius: 'var(--radius-sm)',
893
+ }}
894
+ >
895
+ <table className="w-full border-collapse text-left">
896
+ <caption className="sr-only">{caption}</caption>
897
+ <thead>
898
+ <tr style={{ background: 'rgb(var(--muted))' }}>
899
+ {data.columns.map((column, index) => (
900
+ <th
901
+ key={column}
902
+ scope="col"
903
+ className={cn(
904
+ 'text-[12px] font-semibold whitespace-nowrap',
905
+ index === 0 ? 'text-left' : 'text-right',
906
+ )}
907
+ style={{
908
+ padding: 'var(--space-2) var(--space-3)',
909
+ borderBottom: '1px solid rgb(var(--border))',
910
+ color: 'rgb(var(--text-secondary))',
911
+ }}
912
+ >
913
+ {column}
914
+ </th>
915
+ ))}
916
+ </tr>
917
+ </thead>
918
+ <tbody>
919
+ {data.rows.map((row, rowIndex) => (
920
+ <tr key={row.key}>
921
+ {row.cells.map((cell, cellIndex) => {
922
+ const isCategory = cellIndex === 0
923
+ const style = {
924
+ padding: 'var(--space-2) var(--space-3)',
925
+ borderTop:
926
+ rowIndex > 0 ? '1px solid rgb(var(--border))' : undefined,
927
+ color: isCategory
928
+ ? 'rgb(var(--foreground))'
929
+ : 'rgb(var(--text-secondary))',
930
+ }
931
+ const className = cn(
932
+ 'text-[13px] whitespace-nowrap',
933
+ isCategory ? 'text-left font-medium' : 'text-right tabular-nums',
934
+ )
935
+ return isCategory ? (
936
+ <th
937
+ key={`${row.key}-${cellIndex}`}
938
+ scope="row"
939
+ className={className}
940
+ style={style}
941
+ >
942
+ {cell}
943
+ </th>
944
+ ) : (
945
+ <td
946
+ key={`${row.key}-${cellIndex}`}
947
+ className={className}
948
+ style={style}
949
+ >
950
+ {cell}
951
+ </td>
952
+ )
953
+ })}
954
+ </tr>
955
+ ))}
956
+ </tbody>
957
+ </table>
958
+ </div>
959
+ )
960
+ }