@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,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LineChart / AreaChart — the edge cases ADR-146 acceptance criterion 8 names,
|
|
3
|
+
* plus the two defects this primitive exists to not re-ship.
|
|
4
|
+
*
|
|
5
|
+
* The suite runs with `css: false`, so nothing here measures a painted pixel.
|
|
6
|
+
* What it CAN assert is the shape of what reaches the browser: how many path
|
|
7
|
+
* segments a gap produces, whether two charts on one page collide on a
|
|
8
|
+
* gradient id, and whether the keyboard actually moves the readout.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import { render, screen } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
|
|
14
|
+
import { AreaChart, LineChart, type LineChartSeries } from '../line-chart'
|
|
15
|
+
|
|
16
|
+
const xLabels = ['Mar 1', 'Mar 2', 'Mar 3', 'Mar 4']
|
|
17
|
+
|
|
18
|
+
function series(values: readonly (number | null)[]): LineChartSeries[] {
|
|
19
|
+
return [{ key: 'visits', label: 'Visits', slot: 0, values }]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The plot itself, scoped away from the frame's chrome (the table toggle's
|
|
23
|
+
* Lucide icon is an SVG with paths of its own). */
|
|
24
|
+
function plot(container: HTMLElement): Element {
|
|
25
|
+
const node = container.querySelector('[role="group"] svg')
|
|
26
|
+
if (node === null) throw new Error('no plot rendered')
|
|
27
|
+
return node
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every stroked line path in the plot — one per unbroken run of points. */
|
|
31
|
+
function linePaths(container: HTMLElement): Element[] {
|
|
32
|
+
return Array.from(plot(container).querySelectorAll('path')).filter(
|
|
33
|
+
(path) => path.getAttribute('fill') === 'none',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every filled area path in the plot — one per unbroken run, area variant. */
|
|
38
|
+
function areaPaths(container: HTMLElement): Element[] {
|
|
39
|
+
return Array.from(plot(container).querySelectorAll('path')).filter(
|
|
40
|
+
(path) => path.getAttribute('fill') === null,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('LineChart edge cases', () => {
|
|
45
|
+
it('renders a single point as a dot, with no empty plot', () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<LineChart title="One" xLabels={['Mar 1']} series={series([42])} />,
|
|
48
|
+
)
|
|
49
|
+
const paths = linePaths(container)
|
|
50
|
+
expect(paths).toHaveLength(1)
|
|
51
|
+
// A zero-length segment: with a round cap this paints as a dot.
|
|
52
|
+
expect(paths[0]?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
|
|
53
|
+
expect(plot(container).querySelectorAll('circle')).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('renders two points as one segment', () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<LineChart xLabels={['a', 'b']} series={series([1, 2])} />,
|
|
59
|
+
)
|
|
60
|
+
expect(linePaths(container)).toHaveLength(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('breaks the line at a gap instead of interpolating through it', () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<LineChart xLabels={xLabels} series={series([1, null, 3, 4])} />,
|
|
66
|
+
)
|
|
67
|
+
// Two runs: [Mar 1] and [Mar 3, Mar 4]. One path would be a lie.
|
|
68
|
+
expect(linePaths(container)).toHaveLength(2)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('survives an all-zero series without dividing by zero', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<LineChart
|
|
74
|
+
xLabels={xLabels}
|
|
75
|
+
series={series([0, 0, 0, 0])}
|
|
76
|
+
directLabels={false}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
const d = linePaths(container)[0]?.getAttribute('d') ?? ''
|
|
80
|
+
expect(d).not.toMatch(/NaN|Infinity/)
|
|
81
|
+
// A flat domain maps to the middle of the range, and the axis agrees:
|
|
82
|
+
// ONE tick label, not a ladder of zeroes.
|
|
83
|
+
expect(screen.getAllByText('0')).toHaveLength(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('draws a zero rule when the data crosses zero', () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<LineChart xLabels={xLabels} series={series([-40, -10, 20, 50])} />,
|
|
89
|
+
)
|
|
90
|
+
const rule = plot(container).querySelector('line')
|
|
91
|
+
expect(rule).not.toBeNull()
|
|
92
|
+
expect(rule?.getAttribute('style')).toContain('--chart-axis')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('has no zero rule when the domain does not span zero', () => {
|
|
96
|
+
const { container } = render(
|
|
97
|
+
<LineChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
|
|
98
|
+
)
|
|
99
|
+
expect(plot(container).querySelector('line')).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to the empty state when no datum is finite', () => {
|
|
103
|
+
render(
|
|
104
|
+
<LineChart
|
|
105
|
+
title="Nothing"
|
|
106
|
+
xLabels={xLabels}
|
|
107
|
+
series={series([null, null, null, null])}
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
expect(screen.getByText('No data for this range.')).toBeInTheDocument()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('emits no NaN in any path or attribute', () => {
|
|
114
|
+
const { container } = render(
|
|
115
|
+
<AreaChart xLabels={xLabels} series={series([null, 5, 0, -3])} />,
|
|
116
|
+
)
|
|
117
|
+
expect(container.innerHTML).not.toMatch(/NaN|Infinity/)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('LineChart mark placement', () => {
|
|
122
|
+
it('closes an area fill on the zero line, not the floor of the plot', () => {
|
|
123
|
+
// Domain -40..20, so zero sits inside it. Closing on the floor would
|
|
124
|
+
// paint every negative point down to -40 — a tall block of ink for a
|
|
125
|
+
// small negative number, which is the area-chart lie in reverse.
|
|
126
|
+
const { container } = render(
|
|
127
|
+
<AreaChart xLabels={xLabels} series={series([-40, -10, 0, 20])} />,
|
|
128
|
+
)
|
|
129
|
+
const rule = plot(container).querySelector('line')
|
|
130
|
+
const zeroY = Number(rule?.getAttribute('y1'))
|
|
131
|
+
expect(Number.isFinite(zeroY)).toBe(true)
|
|
132
|
+
|
|
133
|
+
const d = areaPaths(container)[0]?.getAttribute('d') ?? ''
|
|
134
|
+
// The two closing coordinates are the baseline, and they are the zero
|
|
135
|
+
// rule's y — not the bottom of the plot.
|
|
136
|
+
const closing = [...d.matchAll(/L[\d.-]+,([\d.-]+)/g)].map((m) => Number(m[1]))
|
|
137
|
+
expect(closing[closing.length - 1]).toBeCloseTo(zeroY, 1)
|
|
138
|
+
expect(closing[closing.length - 2]).toBeCloseTo(zeroY, 1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('closes an all-positive area on the floor, as before', () => {
|
|
142
|
+
const { container } = render(
|
|
143
|
+
<AreaChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
|
|
144
|
+
)
|
|
145
|
+
const d = areaPaths(container)[0]?.getAttribute('d') ?? ''
|
|
146
|
+
const closing = [...d.matchAll(/L[\d.-]+,([\d.-]+)/g)].map((m) => Number(m[1]))
|
|
147
|
+
// includeZero puts zero at the bottom of the domain, so y(0) IS the floor.
|
|
148
|
+
expect(closing[closing.length - 1]).toBeGreaterThan(0)
|
|
149
|
+
expect(d).not.toMatch(/NaN/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('never paints a direct label above the top of the plot', () => {
|
|
153
|
+
// niceTicks rounds 1..4 to exactly [1,2,3,4], so the peak sits at y = 0.
|
|
154
|
+
// An unflipped label would render its glyphs outside the plot, over the
|
|
155
|
+
// legend, because the plot SVG is deliberately overflow: visible.
|
|
156
|
+
const { container } = render(
|
|
157
|
+
<LineChart title="Peak" xLabels={xLabels} series={series([1, 2, 3, 4])} />,
|
|
158
|
+
)
|
|
159
|
+
const ys = Array.from(plot(container).querySelectorAll('text')).map((node) =>
|
|
160
|
+
Number(node.getAttribute('y')),
|
|
161
|
+
)
|
|
162
|
+
expect(ys.length).toBeGreaterThan(0)
|
|
163
|
+
for (const y of ys) expect(y).toBeGreaterThanOrEqual(0)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('LineChart axis labels', () => {
|
|
168
|
+
it('thins the x-axis rather than rendering one label per day', () => {
|
|
169
|
+
const days = Array.from({ length: 90 }, (_, i) => `Day ${i + 1}`)
|
|
170
|
+
render(
|
|
171
|
+
<LineChart
|
|
172
|
+
title="Sessions"
|
|
173
|
+
xLabels={days}
|
|
174
|
+
series={series(days.map((_, i) => i))}
|
|
175
|
+
/>,
|
|
176
|
+
)
|
|
177
|
+
// 90 labels in a justify-between row is five pixels of ellipsis each.
|
|
178
|
+
expect(screen.queryByText('Day 2')).toBeNull()
|
|
179
|
+
// First and last are always kept — they anchor the range.
|
|
180
|
+
expect(screen.getByText('Day 1')).toBeInTheDocument()
|
|
181
|
+
expect(screen.getByText('Day 90')).toBeInTheDocument()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps every label in the table view, thinned axis or not', async () => {
|
|
185
|
+
const user = userEvent.setup()
|
|
186
|
+
const days = Array.from({ length: 90 }, (_, i) => `Day ${i + 1}`)
|
|
187
|
+
render(
|
|
188
|
+
<LineChart
|
|
189
|
+
title="Sessions"
|
|
190
|
+
xLabels={days}
|
|
191
|
+
series={series(days.map((_, i) => i))}
|
|
192
|
+
/>,
|
|
193
|
+
)
|
|
194
|
+
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
|
195
|
+
expect(screen.getByRole('rowheader', { name: 'Day 2' })).toBeInTheDocument()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('formats the whole y tick ladder to one precision', () => {
|
|
199
|
+
render(
|
|
200
|
+
<LineChart
|
|
201
|
+
title="Rate"
|
|
202
|
+
xLabels={xLabels}
|
|
203
|
+
series={series([0, 0.25, 0.5, 1])}
|
|
204
|
+
// The tick COUNT is fitted to the plot's height, so a six-rung
|
|
205
|
+
// ladder needs a plot tall enough to hold one: at the default 16/5
|
|
206
|
+
// the nominal plot is 143px and the axis correctly thins to three.
|
|
207
|
+
// This case is about the FORMATTING of a six-rung ladder, so it asks
|
|
208
|
+
// for a plot that has room for six.
|
|
209
|
+
aspectRatio={2}
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
// The ladder is 0, 0.2 … 1. Per-value formatting renders that as
|
|
213
|
+
// "1 / 0.8 / 0.6 / 0.4 / 0.2 / 0" — two shapes in one tabular column.
|
|
214
|
+
// One precision for the whole ladder means the ends carry it too.
|
|
215
|
+
for (const tick of ['1.0', '0.8', '0.6', '0.4', '0.2', '0.0']) {
|
|
216
|
+
expect(screen.getByText(tick)).toBeInTheDocument()
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('LineChart axis legibility (the 2026-09-06 browser findings)', () => {
|
|
222
|
+
/**
|
|
223
|
+
* These four assert the SHAPE of the fix, which is all jsdom can see: the
|
|
224
|
+
* inline `left` percentage, which indices survive thinning, how many tick
|
|
225
|
+
* rows exist, and whether both members of a colliding pair are in the DOM.
|
|
226
|
+
* Whether the result LOOKS right — no painted overlap, the label under its
|
|
227
|
+
* bar — is a browser measurement, and is recorded in the fix report.
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
/** The x-axis label row, which mirrors the plot row below the gutter. */
|
|
231
|
+
function xLabelRow(container: HTMLElement): HTMLElement {
|
|
232
|
+
const rows = container.querySelectorAll('figure > div[aria-hidden="true"]')
|
|
233
|
+
const row = rows[rows.length - 1]
|
|
234
|
+
if (!(row instanceof HTMLElement)) throw new Error('no x-label row')
|
|
235
|
+
return row
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readXLabels(container: HTMLElement): { text: string; left: string }[] {
|
|
239
|
+
return Array.from(xLabelRow(container).querySelectorAll('span')).map((span) => ({
|
|
240
|
+
text: span.textContent ?? '',
|
|
241
|
+
left: span.style.left,
|
|
242
|
+
}))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
it('thins 12 weeks by a uniform stride, not by rounding a fraction', () => {
|
|
246
|
+
// The shipped axis read "Wk 1, Wk 3, Wk 4, Wk 6, Wk 7, Wk 9, Wk 10,
|
|
247
|
+
// Wk 12": 3&4, 6&7 and 9&10 adjacent, 2, 5, 8 and 11 skipped. A reader
|
|
248
|
+
// sees an irregular axis and concludes the DATA is irregular.
|
|
249
|
+
const weeks = Array.from({ length: 12 }, (_, i) => `Wk ${i + 1}`)
|
|
250
|
+
const { container } = render(
|
|
251
|
+
<LineChart
|
|
252
|
+
title="Weekly spend"
|
|
253
|
+
xLabels={weeks}
|
|
254
|
+
series={series(weeks.map((_, i) => i * 100))}
|
|
255
|
+
/>,
|
|
256
|
+
)
|
|
257
|
+
const texts = readXLabels(container).map((label) => label.text)
|
|
258
|
+
expect(texts).toEqual(['Wk 1', 'Wk 3', 'Wk 5', 'Wk 7', 'Wk 9', 'Wk 12'])
|
|
259
|
+
// The three the rounding kept ADJACENT to a neighbour are gone.
|
|
260
|
+
expect(texts).not.toContain('Wk 4')
|
|
261
|
+
expect(texts).not.toContain('Wk 6')
|
|
262
|
+
expect(texts).not.toContain('Wk 10')
|
|
263
|
+
// No two kept labels are one week apart — that adjacency is what made
|
|
264
|
+
// the old axis read as irregular data.
|
|
265
|
+
const kept = texts.map((text) => Number(text.replace('Wk ', '')))
|
|
266
|
+
for (let i = 1; i < kept.length; i++) {
|
|
267
|
+
expect((kept[i] ?? 0) - (kept[i - 1] ?? 0)).toBeGreaterThan(1)
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('positions each x label at its own data fraction, not an even spread', () => {
|
|
272
|
+
const weeks = Array.from({ length: 12 }, (_, i) => `Wk ${i + 1}`)
|
|
273
|
+
const { container } = render(
|
|
274
|
+
<LineChart
|
|
275
|
+
title="Weekly spend"
|
|
276
|
+
xLabels={weeks}
|
|
277
|
+
series={series(weeks.map((_, i) => i * 100))}
|
|
278
|
+
/>,
|
|
279
|
+
)
|
|
280
|
+
const byText = new Map(readXLabels(container).map((l) => [l.text, l.left]))
|
|
281
|
+
// Position 4 of 12 is 4/11 of the way across — not 2/5, which is where
|
|
282
|
+
// an even spread of six labels would have put it.
|
|
283
|
+
expect(byText.get('Wk 1')).toBe('0%')
|
|
284
|
+
expect(byText.get('Wk 5')).toBe(`${(4 / 11) * 100}%`)
|
|
285
|
+
expect(byText.get('Wk 12')).toBe('100%')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('shortens the y ladder for a short plot instead of stacking labels', () => {
|
|
289
|
+
// A 21px label needs 42px of room. The nominal plot is 459px wide, so a
|
|
290
|
+
// 16:5 panel is 143px tall (four rungs) and a 16:1 panel is 29px (two).
|
|
291
|
+
const tall = render(
|
|
292
|
+
<LineChart title="Spend" xLabels={xLabels} series={series([0, 25000, 40000, 50000])} />,
|
|
293
|
+
)
|
|
294
|
+
const short = render(
|
|
295
|
+
<LineChart
|
|
296
|
+
title="Spend"
|
|
297
|
+
xLabels={xLabels}
|
|
298
|
+
series={series([0, 25000, 40000, 50000])}
|
|
299
|
+
aspectRatio={16}
|
|
300
|
+
/>,
|
|
301
|
+
)
|
|
302
|
+
const count = (view: ReturnType<typeof render>): number =>
|
|
303
|
+
view.container.querySelectorAll('figure > div > div[aria-hidden="true"] > span')
|
|
304
|
+
.length
|
|
305
|
+
expect(count(short)).toBeLessThan(count(tall))
|
|
306
|
+
expect(count(short)).toBe(2)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('drops one of two value labels that would land on top of each other', () => {
|
|
310
|
+
// The measured defect: AreaChart's "Spend, filled" demo painted
|
|
311
|
+
// "$17,460" and "$17,120" over each other as "$17,46O$17,120". Two
|
|
312
|
+
// series ending 340 apart on a ~20,000 domain are ~2px apart in the
|
|
313
|
+
// plot, so exactly one of the pair may be drawn.
|
|
314
|
+
const { container } = render(
|
|
315
|
+
<AreaChart
|
|
316
|
+
title="Spend"
|
|
317
|
+
xLabels={xLabels}
|
|
318
|
+
series={[
|
|
319
|
+
{ key: 'a', label: 'A', slot: 0, values: [8000, 12000, 15000, 17460] },
|
|
320
|
+
{ key: 'b', label: 'B', slot: 1, values: [8100, 12100, 15100, 17120] },
|
|
321
|
+
]}
|
|
322
|
+
/>,
|
|
323
|
+
)
|
|
324
|
+
const drawn = Array.from(plot(container).querySelectorAll('text')).map(
|
|
325
|
+
(node) => node.textContent,
|
|
326
|
+
)
|
|
327
|
+
const collided = drawn.filter(
|
|
328
|
+
(text) => text === '17,460' || text === '17,120',
|
|
329
|
+
)
|
|
330
|
+
expect(collided).toHaveLength(1)
|
|
331
|
+
// The survivor is the one that is also a peak — priority peak > last.
|
|
332
|
+
expect(collided[0]).toBe('17,460')
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('LineChart identity rules (ADR-146 D10)', () => {
|
|
337
|
+
it('renders no legend for a single series — the title names it', () => {
|
|
338
|
+
render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
|
|
339
|
+
expect(screen.queryByRole('list', { name: /legend/i })).toBeNull()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('always renders a legend for two or more series', () => {
|
|
343
|
+
render(
|
|
344
|
+
<LineChart
|
|
345
|
+
title="Traffic"
|
|
346
|
+
xLabels={xLabels}
|
|
347
|
+
series={[
|
|
348
|
+
{ key: 'a', label: 'Direct', slot: 0, values: [1, 2, 3, 4] },
|
|
349
|
+
{ key: 'b', label: 'Search', slot: 1, values: [4, 3, 2, 1] },
|
|
350
|
+
]}
|
|
351
|
+
/>,
|
|
352
|
+
)
|
|
353
|
+
expect(screen.getByRole('list', { name: /legend/i })).toBeInTheDocument()
|
|
354
|
+
expect(screen.getByText('Direct')).toBeInTheDocument()
|
|
355
|
+
expect(screen.getByText('Search')).toBeInTheDocument()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('offers the table view as the accessible representation', async () => {
|
|
359
|
+
const user = userEvent.setup()
|
|
360
|
+
render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
|
|
361
|
+
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
|
362
|
+
expect(screen.getByRole('table')).toBeInTheDocument()
|
|
363
|
+
expect(screen.getByRole('columnheader', { name: 'Visits' })).toBeInTheDocument()
|
|
364
|
+
// A gap reads as an em dash in the table, never as a zero.
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('writes an em dash, not a zero, for a gap in the table', async () => {
|
|
368
|
+
const user = userEvent.setup()
|
|
369
|
+
render(<LineChart title="Visits" xLabels={xLabels} series={series([1, null, 3, 4])} />)
|
|
370
|
+
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
|
371
|
+
expect(screen.getByText('—')).toBeInTheDocument()
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe('LineChart gradient ids', () => {
|
|
376
|
+
it('does not collide when two area charts share a page', () => {
|
|
377
|
+
const { container } = render(
|
|
378
|
+
<>
|
|
379
|
+
<AreaChart xLabels={xLabels} series={series([1, 2, 3, 4])} />
|
|
380
|
+
<AreaChart
|
|
381
|
+
xLabels={xLabels}
|
|
382
|
+
series={[{ key: 'b', label: 'Other', slot: 1, values: [4, 3, 2, 1] }]}
|
|
383
|
+
/>
|
|
384
|
+
</>,
|
|
385
|
+
)
|
|
386
|
+
const ids = Array.from(container.querySelectorAll('linearGradient')).map((node) =>
|
|
387
|
+
node.getAttribute('id'),
|
|
388
|
+
)
|
|
389
|
+
expect(ids).toHaveLength(2)
|
|
390
|
+
expect(new Set(ids).size).toBe(2)
|
|
391
|
+
// And the ids are usable in a url() fragment — React's useId ships colons.
|
|
392
|
+
for (const id of ids) expect(id).not.toContain(':')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('defines one gradient per series, and only for the area variant', () => {
|
|
396
|
+
const twoSeries: LineChartSeries[] = [
|
|
397
|
+
{ key: 'a', label: 'Direct', slot: 0, values: [1, 2, 3, 4] },
|
|
398
|
+
{ key: 'b', label: 'Search', slot: 1, values: [4, 3, 2, 1] },
|
|
399
|
+
]
|
|
400
|
+
const area = render(<AreaChart xLabels={xLabels} series={twoSeries} />)
|
|
401
|
+
expect(area.container.querySelectorAll('linearGradient')).toHaveLength(2)
|
|
402
|
+
// One filled region per unbroken run, over the two stroked lines.
|
|
403
|
+
expect(areaPaths(area.container)).toHaveLength(2)
|
|
404
|
+
expect(linePaths(area.container)).toHaveLength(2)
|
|
405
|
+
|
|
406
|
+
// The line variant defines no gradient at all — nothing to collide with.
|
|
407
|
+
const line = render(<LineChart xLabels={xLabels} series={twoSeries} />)
|
|
408
|
+
expect(line.container.querySelectorAll('linearGradient')).toHaveLength(0)
|
|
409
|
+
expect(linePaths(line.container)).toHaveLength(2)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
describe('LineChart keyboard interaction (ADR-146 D9)', () => {
|
|
414
|
+
it('moves the readout with the arrow keys and clears on Escape', async () => {
|
|
415
|
+
const user = userEvent.setup()
|
|
416
|
+
render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
|
|
417
|
+
|
|
418
|
+
const plot = screen.getByRole('group', { name: 'Visits' })
|
|
419
|
+
plot.focus()
|
|
420
|
+
|
|
421
|
+
await user.keyboard('{ArrowRight}')
|
|
422
|
+
expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 1')
|
|
423
|
+
|
|
424
|
+
await user.keyboard('{ArrowRight}')
|
|
425
|
+
expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 2')
|
|
426
|
+
|
|
427
|
+
await user.keyboard('{End}')
|
|
428
|
+
expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 4')
|
|
429
|
+
|
|
430
|
+
await user.keyboard('{Escape}')
|
|
431
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('drops a hover index that new, shorter data no longer has', async () => {
|
|
435
|
+
const user = userEvent.setup()
|
|
436
|
+
const { rerender } = render(
|
|
437
|
+
<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />,
|
|
438
|
+
)
|
|
439
|
+
screen.getByRole('group', { name: 'Visits' }).focus()
|
|
440
|
+
await user.keyboard('{End}')
|
|
441
|
+
expect(screen.getByRole('tooltip')).toHaveTextContent('Mar 4')
|
|
442
|
+
|
|
443
|
+
// A range switch: the same chart, fewer points. A stale index would read
|
|
444
|
+
// past the end of every series and paint a readout of nothing.
|
|
445
|
+
rerender(
|
|
446
|
+
<LineChart title="Visits" xLabels={['Mar 1', 'Mar 2']} series={series([1, 2])} />,
|
|
447
|
+
)
|
|
448
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('is keyboard reachable', () => {
|
|
452
|
+
render(<LineChart title="Visits" xLabels={xLabels} series={series([1, 2, 3, 4])} />)
|
|
453
|
+
expect(screen.getByRole('group', { name: 'Visits' })).toHaveAttribute('tabindex', '0')
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe('LineChart — every datum is plotted (Truthful axis)', () => {
|
|
458
|
+
it('plots values that run past the last x label instead of discarding them', async () => {
|
|
459
|
+
const user = userEvent.setup()
|
|
460
|
+
// Three values, two labels. Deriving the position count from `xLabels`
|
|
461
|
+
// drops the third: the domain reads 1..2, the point never draws, and the
|
|
462
|
+
// table view — the chart's accessible representation — never mentions it.
|
|
463
|
+
render(
|
|
464
|
+
<LineChart title="Visits" xLabels={['a', 'b']} series={series([1, 2, 999])} />,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
// The domain must cover the data that exists. niceTicks(1, 999) tops out
|
|
468
|
+
// at 1,000; a domain truncated to 1..2 tops out at "2.0".
|
|
469
|
+
expect(screen.getByText('1,000')).toBeInTheDocument()
|
|
470
|
+
expect(screen.queryByText('2.0')).toBeNull()
|
|
471
|
+
|
|
472
|
+
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
|
473
|
+
expect(screen.getByRole('cell', { name: '999' })).toBeInTheDocument()
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('keeps every position in the table when the labels run short', async () => {
|
|
477
|
+
const user = userEvent.setup()
|
|
478
|
+
// The real shape of this bug: a 30-day series arriving with 7 labels.
|
|
479
|
+
const values = Array.from({ length: 30 }, (_, i) => i + 1)
|
|
480
|
+
render(
|
|
481
|
+
<LineChart
|
|
482
|
+
title="Sessions"
|
|
483
|
+
xLabels={['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7']}
|
|
484
|
+
series={series(values)}
|
|
485
|
+
/>,
|
|
486
|
+
)
|
|
487
|
+
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
|
488
|
+
// Header + one row per VALUE, not one row per label.
|
|
489
|
+
expect(screen.getAllByRole('row')).toHaveLength(values.length + 1)
|
|
490
|
+
expect(screen.getByRole('cell', { name: '30' })).toBeInTheDocument()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('takes the longest series, not the first, as the position count', () => {
|
|
494
|
+
const { container } = render(
|
|
495
|
+
<LineChart
|
|
496
|
+
title="Traffic"
|
|
497
|
+
xLabels={['a']}
|
|
498
|
+
series={[
|
|
499
|
+
{ key: 'a', label: 'Direct', slot: 0, values: [1] },
|
|
500
|
+
{ key: 'b', label: 'Search', slot: 1, values: [1, 2, 3, 4] },
|
|
501
|
+
]}
|
|
502
|
+
markers="all"
|
|
503
|
+
/>,
|
|
504
|
+
)
|
|
505
|
+
// 1 marker for Direct + 4 for Search. Counting from `xLabels` gives 2.
|
|
506
|
+
expect(plot(container).querySelectorAll('circle')).toHaveLength(5)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('leaves an unlabelled position blank on the axis rather than dropping it', () => {
|
|
510
|
+
render(
|
|
511
|
+
<LineChart title="Visits" xLabels={['a', 'b']} series={series([1, 2, 3])} />,
|
|
512
|
+
)
|
|
513
|
+
// The axis row still names what it can; the position with no label is
|
|
514
|
+
// rendered empty, not deleted (deleting it would shift every label left).
|
|
515
|
+
expect(screen.getByText('a')).toBeInTheDocument()
|
|
516
|
+
expect(screen.getByText('b')).toBeInTheDocument()
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The plot box unmounts and remounts — `ChartFrame` renders `children` only in
|
|
522
|
+
* the `loaded` state, and the table view swaps the whole plot row out. The
|
|
523
|
+
* measurement hooks used to key their effect on `[ref]`, and a ref object is
|
|
524
|
+
* stable for the component's life, so the observer bound once to whatever node
|
|
525
|
+
* existed then and never rebound.
|
|
526
|
+
*
|
|
527
|
+
* That made the ORDINARY async path the broken one: a chart that first paints
|
|
528
|
+
* in `loading` (every TanStack Query flow) stayed unmeasured for the rest of
|
|
529
|
+
* its life and silently fell back to the nominal plot size — defeating the
|
|
530
|
+
* measured axis fit that most of ADR-146's axis work exists to provide.
|
|
531
|
+
*
|
|
532
|
+
* jsdom has no layout, so these cannot assert a measured WIDTH. What they can
|
|
533
|
+
* assert is the thing that actually regressed: that the box carrying the
|
|
534
|
+
* observer is a live node in the document after each remount, so the effect
|
|
535
|
+
* has something to re-observe.
|
|
536
|
+
*/
|
|
537
|
+
describe('LineChart re-measures after the plot subtree remounts', () => {
|
|
538
|
+
/**
|
|
539
|
+
* Instrument `ResizeObserver` and record which nodes get observed. Asserting
|
|
540
|
+
* the plot node is merely PRESENT would pass in the broken version too — the
|
|
541
|
+
* node always mounts; what regressed is that the observer never rebound to
|
|
542
|
+
* it. So the assertion has to be on `observe()`.
|
|
543
|
+
*/
|
|
544
|
+
function trackObserved() {
|
|
545
|
+
const observed: Element[] = []
|
|
546
|
+
const Original = globalThis.ResizeObserver
|
|
547
|
+
class Tracking {
|
|
548
|
+
observe(el: Element) {
|
|
549
|
+
observed.push(el)
|
|
550
|
+
}
|
|
551
|
+
unobserve() {}
|
|
552
|
+
disconnect() {}
|
|
553
|
+
}
|
|
554
|
+
globalThis.ResizeObserver = Tracking as unknown as typeof ResizeObserver
|
|
555
|
+
return {
|
|
556
|
+
observed,
|
|
557
|
+
restore: () => {
|
|
558
|
+
globalThis.ResizeObserver = Original
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it('observes the plot box when loading resolves to loaded', () => {
|
|
564
|
+
const { observed, restore } = trackObserved()
|
|
565
|
+
try {
|
|
566
|
+
const { container, rerender } = render(
|
|
567
|
+
<LineChart
|
|
568
|
+
state="loading"
|
|
569
|
+
xLabels={xLabels}
|
|
570
|
+
series={series([10, 20, 30, 40])}
|
|
571
|
+
/>,
|
|
572
|
+
)
|
|
573
|
+
const before = observed.length
|
|
574
|
+
|
|
575
|
+
rerender(
|
|
576
|
+
<LineChart
|
|
577
|
+
state="loaded"
|
|
578
|
+
xLabels={xLabels}
|
|
579
|
+
series={series([10, 20, 30, 40])}
|
|
580
|
+
/>,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
const plot = container.querySelector('svg circle')
|
|
584
|
+
expect(plot).not.toBeNull()
|
|
585
|
+
// The regression: zero NEW observations after the plot mounted, because
|
|
586
|
+
// the effect was keyed on a ref object that never changes.
|
|
587
|
+
expect(observed.length).toBeGreaterThan(before)
|
|
588
|
+
expect(observed.some((el) => el.isConnected)).toBe(true)
|
|
589
|
+
} finally {
|
|
590
|
+
restore()
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('re-observes the plot box after a table-view round trip', async () => {
|
|
595
|
+
const user = userEvent.setup()
|
|
596
|
+
const { observed, restore } = trackObserved()
|
|
597
|
+
try {
|
|
598
|
+
render(
|
|
599
|
+
<LineChart xLabels={xLabels} series={series([10, 20, 30, 40])} />,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
await user.click(screen.getByRole('button', { name: /table/i }))
|
|
603
|
+
expect(screen.getByRole('table')).toBeTruthy()
|
|
604
|
+
const beforeReturn = observed.length
|
|
605
|
+
|
|
606
|
+
await user.click(screen.getByRole('button', { name: /chart/i }))
|
|
607
|
+
expect(observed.length).toBeGreaterThan(beforeReturn)
|
|
608
|
+
} finally {
|
|
609
|
+
restore()
|
|
610
|
+
}
|
|
611
|
+
})
|
|
612
|
+
})
|