@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
}
|