@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,809 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarChart contract tests — ADR-146 D5 / D7 / D9, acceptance criterion 8.
|
|
3
|
+
*
|
|
4
|
+
* The suite runs with `css: false`, so nothing here measures a painted
|
|
5
|
+
* pixel. What it CAN assert is the set of things that were silently wrong in
|
|
6
|
+
* every hand-rolled bar strip this primitive replaces:
|
|
7
|
+
*
|
|
8
|
+
* • the monochrome contract — exactly ONE bar at full accent, never a bar
|
|
9
|
+
* coloured by its own value (D7);
|
|
10
|
+
* • that a negative bar descends from the zero baseline instead of being
|
|
11
|
+
* clamped to nothing, which is a chart that lies;
|
|
12
|
+
* • that the degenerate inputs a charting library would have handled for
|
|
13
|
+
* us — no data, one bar, all zero, one category — each render something
|
|
14
|
+
* honest rather than a blank panel;
|
|
15
|
+
* • that identity, the readout and the table view exist for keyboard and
|
|
16
|
+
* assistive tech, not just for a mouse.
|
|
17
|
+
*
|
|
18
|
+
* jsdom reports every box as 0×0 and its ResizeObserver is a no-op stub, so
|
|
19
|
+
* the component's measurement path never fires here. That is deliberate: the
|
|
20
|
+
* layout is percentage-based precisely so that a chart renders correctly
|
|
21
|
+
* before anything has been measured. These tests exercise that path.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
24
|
+
import { render, screen, within } from '@testing-library/react'
|
|
25
|
+
import userEvent from '@testing-library/user-event'
|
|
26
|
+
|
|
27
|
+
import { BarChart, type BarChartDatum, type BarChartSeries } from '../bar-chart'
|
|
28
|
+
|
|
29
|
+
const DATA: BarChartDatum[] = [
|
|
30
|
+
{ key: 'mon', label: 'Mon', value: 40 },
|
|
31
|
+
{ key: 'tue', label: 'Tue', value: 120 },
|
|
32
|
+
{ key: 'wed', label: 'Wed', value: 90 },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const SERIES: BarChartSeries[] = [
|
|
36
|
+
{ key: 'paid', label: 'Paid', slot: 0 },
|
|
37
|
+
{ key: 'organic', label: 'Organic', slot: 1 },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const GROUPED: BarChartDatum[] = [
|
|
41
|
+
{ key: 'q1', label: 'Q1', values: [10, 20] },
|
|
42
|
+
{ key: 'q2', label: 'Q2', values: [30, 5] },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/** Every drawn mark. Marks are aria-hidden; the hit layer carries the names. */
|
|
46
|
+
function marks(container: HTMLElement): HTMLElement[] {
|
|
47
|
+
return Array.from(
|
|
48
|
+
container.querySelectorAll<HTMLElement>('[aria-hidden="true"]'),
|
|
49
|
+
).filter((el) => el.style.background !== '' && el.style.borderRadius !== '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fills(container: HTMLElement): string[] {
|
|
53
|
+
return marks(container).map((el) => el.style.background)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Only the drawn BARS — both axes sized in percent. `marks` also matches the
|
|
58
|
+
* legend and readout swatches, which are token-sized; filtering on the unit
|
|
59
|
+
* is what separates a bar from a swatch without depending on colour.
|
|
60
|
+
*/
|
|
61
|
+
function bars(container: HTMLElement): HTMLElement[] {
|
|
62
|
+
return marks(container).filter(
|
|
63
|
+
(el) => el.style.width.endsWith('%') && el.style.height.endsWith('%'),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* jsdom reports every box as 0x0. This gives the layout real numbers so the
|
|
69
|
+
* MEASURED paths — the density floor and the readout's clamp bounds — can be
|
|
70
|
+
* exercised. Returns the undo.
|
|
71
|
+
*/
|
|
72
|
+
function stubLayout(width: number, height: number): () => void {
|
|
73
|
+
const proto = HTMLElement.prototype
|
|
74
|
+
const w = Object.getOwnPropertyDescriptor(proto, 'offsetWidth')
|
|
75
|
+
const h = Object.getOwnPropertyDescriptor(proto, 'offsetHeight')
|
|
76
|
+
Object.defineProperty(proto, 'offsetWidth', { configurable: true, get: () => width })
|
|
77
|
+
Object.defineProperty(proto, 'offsetHeight', { configurable: true, get: () => height })
|
|
78
|
+
return () => {
|
|
79
|
+
if (w !== undefined) Object.defineProperty(proto, 'offsetWidth', w)
|
|
80
|
+
if (h !== undefined) Object.defineProperty(proto, 'offsetHeight', h)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('BarChart — the monochrome contract (ADR-146 D7)', () => {
|
|
85
|
+
it('paints exactly one bar at full accent and the rest at the tint', () => {
|
|
86
|
+
const { container } = render(<BarChart title="Volume" data={DATA} />)
|
|
87
|
+
const painted = fills(container).filter((f) => f.includes('--accent'))
|
|
88
|
+
const full = painted.filter((f) => f === 'rgb(var(--accent))')
|
|
89
|
+
const tint = painted.filter((f) => f === 'rgb(var(--accent) / 0.14)')
|
|
90
|
+
|
|
91
|
+
expect(full).toHaveLength(1)
|
|
92
|
+
expect(tint).toHaveLength(DATA.length - 1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('never reaches for a series token when there is one series', () => {
|
|
96
|
+
const { container } = render(<BarChart data={DATA} />)
|
|
97
|
+
for (const fill of fills(container)) {
|
|
98
|
+
expect(fill).not.toMatch(/--series-/)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('emphasizes the peak by default, not the first or last bar', () => {
|
|
103
|
+
const { container } = render(<BarChart data={DATA} />)
|
|
104
|
+
const index = fills(container).indexOf('rgb(var(--accent))')
|
|
105
|
+
// Tue (120) is the peak, and it is neither the first nor the last bar —
|
|
106
|
+
// so an off-by-one in the peak search cannot pass this.
|
|
107
|
+
expect(index).toBe(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('lets selectedKey override the peak', () => {
|
|
111
|
+
const { container } = render(<BarChart data={DATA} selectedKey="wed" />)
|
|
112
|
+
expect(fills(container).indexOf('rgb(var(--accent))')).toBe(2)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('emphasizes nothing when selectedKey matches nothing', () => {
|
|
116
|
+
// Falling back to the peak would make a failed lookup invisible.
|
|
117
|
+
const { container } = render(<BarChart data={DATA} selectedKey="nope" />)
|
|
118
|
+
expect(fills(container).filter((f) => f === 'rgb(var(--accent))')).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('emphasizes nothing when asked for none', () => {
|
|
122
|
+
const { container } = render(<BarChart data={DATA} emphasis="none" />)
|
|
123
|
+
expect(fills(container).filter((f) => f === 'rgb(var(--accent))')).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('BarChart — series colour follows the entity, not the rank (D7)', () => {
|
|
128
|
+
it('colours a grouped chart from the frozen series lookup', () => {
|
|
129
|
+
const { container } = render(
|
|
130
|
+
<BarChart data={GROUPED} series={SERIES} title="Channels" />,
|
|
131
|
+
)
|
|
132
|
+
const painted = fills(container).filter((f) => f.includes('--series-'))
|
|
133
|
+
expect(painted).toContain('rgb(var(--series-1))')
|
|
134
|
+
expect(painted).toContain('rgb(var(--series-2))')
|
|
135
|
+
// Never a constructed name (D8): the lookup is a frozen literal array,
|
|
136
|
+
// so nothing can produce `--series-undefined` or `--series-9`.
|
|
137
|
+
for (const fill of painted) {
|
|
138
|
+
expect(fill).toMatch(/^rgb\(var\(--series-[1-8]\)\)$/)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('keeps a series on its own hue when an earlier series is filtered out', () => {
|
|
143
|
+
const survivor = SERIES[1]!
|
|
144
|
+
const { container } = render(
|
|
145
|
+
<BarChart
|
|
146
|
+
data={[{ key: 'q1', label: 'Q1', values: [20] }]}
|
|
147
|
+
series={[survivor]}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
// Organic is slot 1, so it stays --series-2 even though it is now the
|
|
151
|
+
// only (and therefore first) series in the array.
|
|
152
|
+
expect(fills(container)).toContain('rgb(var(--series-2))')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('renders a legend for two series and none for one', () => {
|
|
156
|
+
const { rerender } = render(<BarChart data={GROUPED} series={SERIES} />)
|
|
157
|
+
expect(screen.getByRole('list', { name: /series/i })).toBeInTheDocument()
|
|
158
|
+
|
|
159
|
+
rerender(<BarChart data={DATA} />)
|
|
160
|
+
expect(screen.queryByRole('list', { name: /series/i })).not.toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('BarChart — the degenerate inputs (acceptance criterion 8)', () => {
|
|
165
|
+
it('renders the empty state for no data', () => {
|
|
166
|
+
render(<BarChart data={[]} emptyMessage="Nothing in this range." />)
|
|
167
|
+
expect(screen.getByText('Nothing in this range.')).toBeInTheDocument()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('renders a single bar as a legal chart', () => {
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<BarChart data={[{ key: 'only', label: 'Only', value: 7 }]} />,
|
|
173
|
+
)
|
|
174
|
+
expect(fills(container).filter((f) => f.includes('--accent'))).toEqual([
|
|
175
|
+
'rgb(var(--accent))',
|
|
176
|
+
])
|
|
177
|
+
expect(screen.getByRole('img', { name: 'Only: 7' })).toBeInTheDocument()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('says so for an all-zero series instead of drawing invisible bars', () => {
|
|
181
|
+
const { container } = render(
|
|
182
|
+
<BarChart
|
|
183
|
+
data={[
|
|
184
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
185
|
+
{ key: 'b', label: 'B', value: 0 },
|
|
186
|
+
]}
|
|
187
|
+
/>,
|
|
188
|
+
)
|
|
189
|
+
expect(screen.getByText(/every value in this range is zero/i)).toBeInTheDocument()
|
|
190
|
+
expect(fills(container).filter((f) => f.includes('--accent'))).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('descends a negative bar from the baseline instead of clamping it', () => {
|
|
194
|
+
const { container } = render(
|
|
195
|
+
<BarChart
|
|
196
|
+
data={[
|
|
197
|
+
{ key: 'up', label: 'Up', value: 100 },
|
|
198
|
+
{ key: 'down', label: 'Down', value: -50 },
|
|
199
|
+
]}
|
|
200
|
+
/>,
|
|
201
|
+
)
|
|
202
|
+
const drawn = marks(container).filter((el) =>
|
|
203
|
+
el.style.background.includes('--accent'),
|
|
204
|
+
)
|
|
205
|
+
expect(drawn).toHaveLength(2)
|
|
206
|
+
// A clamped negative would be a zero-height bar sitting on the baseline.
|
|
207
|
+
// Both bars have real height, and they sit at different offsets.
|
|
208
|
+
const heights = drawn.map((el) => parseFloat(el.style.height))
|
|
209
|
+
const bottoms = drawn.map((el) => parseFloat(el.style.bottom))
|
|
210
|
+
expect(heights.every((h) => h > 0)).toBe(true)
|
|
211
|
+
expect(bottoms[0]).not.toBe(bottoms[1])
|
|
212
|
+
// The zero rule is drawn in the darker axis token, not the grid token.
|
|
213
|
+
const zeroRule = Array.from(
|
|
214
|
+
container.querySelectorAll<HTMLElement>('div'),
|
|
215
|
+
).filter((el) => el.style.background === 'rgb(var(--chart-axis))')
|
|
216
|
+
expect(zeroRule.length).toBeGreaterThan(0)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('leaves the axis token free of a second alpha', () => {
|
|
220
|
+
// `--chart-axis` / `--chart-grid` / `--chart-band` already carry alpha;
|
|
221
|
+
// a second one is invalid CSS the browser discards silently.
|
|
222
|
+
const { container } = render(
|
|
223
|
+
<BarChart data={[{ key: 'a', label: 'A', value: -5 }]} />,
|
|
224
|
+
)
|
|
225
|
+
for (const el of Array.from(container.querySelectorAll<HTMLElement>('div'))) {
|
|
226
|
+
expect(el.style.background).not.toMatch(/--chart-[a-z]+\)\s*\//)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('absorbs a non-finite value without voiding the bars around it', () => {
|
|
231
|
+
const { container } = render(
|
|
232
|
+
<BarChart
|
|
233
|
+
data={[
|
|
234
|
+
{ key: 'a', label: 'A', value: Number.NaN },
|
|
235
|
+
{ key: 'b', label: 'B', value: 10 },
|
|
236
|
+
]}
|
|
237
|
+
/>,
|
|
238
|
+
)
|
|
239
|
+
for (const el of marks(container)) {
|
|
240
|
+
expect(el.style.height).not.toMatch(/NaN/)
|
|
241
|
+
expect(el.style.bottom).not.toMatch(/NaN/)
|
|
242
|
+
}
|
|
243
|
+
// NaN is ABSENT, not zero: it draws nothing, and the measured bar next
|
|
244
|
+
// to it still draws. (See the Truthful block below for the rest.)
|
|
245
|
+
expect(bars(container)).toHaveLength(1)
|
|
246
|
+
expect(screen.getByRole('img', { name: 'A: —' })).toBeInTheDocument()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('refuses to draw mush once a measurement says the bars are illegible', () => {
|
|
250
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
251
|
+
const restore = stubLayout(480, 150)
|
|
252
|
+
try {
|
|
253
|
+
const many: BarChartDatum[] = Array.from({ length: 400 }, (_, i) => ({
|
|
254
|
+
key: `d${i}`,
|
|
255
|
+
label: `${i}`,
|
|
256
|
+
value: i + 1,
|
|
257
|
+
}))
|
|
258
|
+
const { container } = render(<BarChart data={many} minBarSize={4} />)
|
|
259
|
+
// 480px over 400 bars is ~1.2px each — mush. We say so, and the
|
|
260
|
+
// frame's table toggle is still the way out.
|
|
261
|
+
expect(screen.getByText(/too many categories to chart legibly \(400\)/i))
|
|
262
|
+
.toBeInTheDocument()
|
|
263
|
+
expect(fills(container).filter((f) => f.includes('--accent'))).toEqual([])
|
|
264
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
265
|
+
expect(screen.getByRole('button', { name: 'Table view' })).toBeInTheDocument()
|
|
266
|
+
} finally {
|
|
267
|
+
restore()
|
|
268
|
+
warn.mockRestore()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('draws normally when the same measurement leaves room', () => {
|
|
273
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
274
|
+
const restore = stubLayout(480, 150)
|
|
275
|
+
try {
|
|
276
|
+
const { container } = render(<BarChart data={DATA} minBarSize={4} />)
|
|
277
|
+
// The guard is a MEASURED floor, not a count heuristic — three bars in
|
|
278
|
+
// 480px clear it comfortably. Remove the comparison and this goes red.
|
|
279
|
+
expect(screen.queryByText(/too many categories/i)).not.toBeInTheDocument()
|
|
280
|
+
expect(fills(container).filter((f) => f.includes('--accent'))).toHaveLength(3)
|
|
281
|
+
expect(warn).not.toHaveBeenCalled()
|
|
282
|
+
} finally {
|
|
283
|
+
restore()
|
|
284
|
+
warn.mockRestore()
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('BarChart — interaction and accessibility (D9, D10)', () => {
|
|
290
|
+
it('gives every band a hit target that names its datum', () => {
|
|
291
|
+
render(<BarChart data={DATA} />)
|
|
292
|
+
expect(screen.getByRole('img', { name: 'Mon: 40' })).toBeInTheDocument()
|
|
293
|
+
expect(screen.getByRole('img', { name: 'Tue: 120' })).toBeInTheDocument()
|
|
294
|
+
expect(screen.getByRole('img', { name: 'Wed: 90' })).toBeInTheDocument()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('names every series in a grouped hit target', () => {
|
|
298
|
+
render(<BarChart data={GROUPED} series={SERIES} />)
|
|
299
|
+
expect(
|
|
300
|
+
screen.getByRole('img', { name: 'Q1: Paid 10, Organic 20' }),
|
|
301
|
+
).toBeInTheDocument()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('is a button only when it actually does something', async () => {
|
|
305
|
+
const onSelect = vi.fn()
|
|
306
|
+
const { rerender } = render(<BarChart data={DATA} />)
|
|
307
|
+
expect(screen.queryAllByRole('button', { name: /Mon/ })).toEqual([])
|
|
308
|
+
|
|
309
|
+
rerender(<BarChart data={DATA} onSelect={onSelect} />)
|
|
310
|
+
await userEvent.click(screen.getByRole('button', { name: 'Mon: 40' }))
|
|
311
|
+
expect(onSelect).toHaveBeenCalledWith('mon')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('marks the selected band pressed', () => {
|
|
315
|
+
render(<BarChart data={DATA} onSelect={() => {}} selectedKey="tue" />)
|
|
316
|
+
expect(screen.getByRole('button', { name: 'Tue: 120' })).toHaveAttribute(
|
|
317
|
+
'aria-pressed',
|
|
318
|
+
'true',
|
|
319
|
+
)
|
|
320
|
+
expect(screen.getByRole('button', { name: 'Mon: 40' })).toHaveAttribute(
|
|
321
|
+
'aria-pressed',
|
|
322
|
+
'false',
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('reveals the readout on hover and hides it without unmounting', async () => {
|
|
327
|
+
render(<BarChart data={DATA} />)
|
|
328
|
+
const tooltip = screen.getByRole('tooltip', { hidden: true })
|
|
329
|
+
expect(tooltip).toHaveAttribute('aria-hidden', 'true')
|
|
330
|
+
|
|
331
|
+
await userEvent.hover(screen.getByRole('img', { name: 'Wed: 90' }))
|
|
332
|
+
expect(tooltip).toHaveAttribute('aria-hidden', 'false')
|
|
333
|
+
expect(within(tooltip).getByText('Wed')).toBeInTheDocument()
|
|
334
|
+
expect(within(tooltip).getByText('90')).toBeInTheDocument()
|
|
335
|
+
|
|
336
|
+
await userEvent.unhover(screen.getByRole('img', { name: 'Wed: 90' }))
|
|
337
|
+
expect(tooltip).toHaveAttribute('aria-hidden', 'true')
|
|
338
|
+
// Still mounted: re-showing must cost no measurement and no reflow.
|
|
339
|
+
expect(screen.getByRole('tooltip', { hidden: true })).toBe(tooltip)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('reveals the readout on keyboard focus too', async () => {
|
|
343
|
+
render(<BarChart data={DATA} />)
|
|
344
|
+
// The frame's table toggle is the first tab stop; the bands follow it.
|
|
345
|
+
await userEvent.tab()
|
|
346
|
+
await userEvent.tab()
|
|
347
|
+
expect(screen.getByRole('img', { name: 'Mon: 40' })).toHaveFocus()
|
|
348
|
+
const tooltip = screen.getByRole('tooltip', { hidden: true })
|
|
349
|
+
expect(tooltip).toHaveAttribute('aria-hidden', 'false')
|
|
350
|
+
expect(within(tooltip).getByText('Mon')).toBeInTheDocument()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('offers the table view as the accessible representation', async () => {
|
|
354
|
+
render(<BarChart title="Volume" data={DATA} valueLabel="Sessions" />)
|
|
355
|
+
await userEvent.click(screen.getByRole('button', { name: 'Table view' }))
|
|
356
|
+
const table = screen.getByRole('table')
|
|
357
|
+
expect(within(table).getByRole('columnheader', { name: 'Category' })).toBeInTheDocument()
|
|
358
|
+
expect(within(table).getByRole('columnheader', { name: 'Sessions' })).toBeInTheDocument()
|
|
359
|
+
expect(within(table).getByRole('rowheader', { name: 'Tue' })).toBeInTheDocument()
|
|
360
|
+
expect(within(table).getByRole('cell', { name: '120' })).toBeInTheDocument()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('formats through the caller formatter everywhere it shows a number', async () => {
|
|
364
|
+
const format = (value: number) => `$${value}`
|
|
365
|
+
render(<BarChart data={DATA} formatValue={format} />)
|
|
366
|
+
expect(screen.getByRole('img', { name: 'Mon: $40' })).toBeInTheDocument()
|
|
367
|
+
await userEvent.click(screen.getByRole('button', { name: 'Table view' }))
|
|
368
|
+
expect(screen.getByRole('cell', { name: '$120' })).toBeInTheDocument()
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe('BarChart — orientation', () => {
|
|
373
|
+
it('draws its own vertical gridlines and category gutter when horizontal', () => {
|
|
374
|
+
const { container } = render(
|
|
375
|
+
<BarChart data={DATA} orientation="horizontal" title="Volume" />,
|
|
376
|
+
)
|
|
377
|
+
const gridlines = Array.from(
|
|
378
|
+
container.querySelectorAll<HTMLElement>('div'),
|
|
379
|
+
).filter((el) => el.style.background === 'rgb(var(--chart-grid))')
|
|
380
|
+
// Vertical rules: a width, not a height.
|
|
381
|
+
expect(gridlines.length).toBeGreaterThan(0)
|
|
382
|
+
for (const line of gridlines) {
|
|
383
|
+
expect(line.style.width).toBe('1px')
|
|
384
|
+
}
|
|
385
|
+
// The category labels move into the chart's own gutter, because the
|
|
386
|
+
// frame's y-gutter is a VALUE axis. (`Mon` also appears in the mounted-
|
|
387
|
+
// but-hidden readout, so this asserts the gutter span specifically.)
|
|
388
|
+
const gutterLabels = Array.from(
|
|
389
|
+
container.querySelectorAll<HTMLElement>('span'),
|
|
390
|
+
).filter((el) => el.style.transform === 'translateY(-50%)')
|
|
391
|
+
expect(gutterLabels.map((el) => el.textContent)).toEqual([
|
|
392
|
+
'Mon',
|
|
393
|
+
'Tue',
|
|
394
|
+
'Wed',
|
|
395
|
+
])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('lays bars along the value axis when horizontal', () => {
|
|
399
|
+
const { container } = render(
|
|
400
|
+
<BarChart data={DATA} orientation="horizontal" />,
|
|
401
|
+
)
|
|
402
|
+
const drawn = marks(container).filter((el) =>
|
|
403
|
+
el.style.background.includes('--accent'),
|
|
404
|
+
)
|
|
405
|
+
expect(drawn).toHaveLength(3)
|
|
406
|
+
for (const bar of drawn) {
|
|
407
|
+
expect(bar.style.width).not.toBe('')
|
|
408
|
+
expect(bar.style.height).toMatch(/%$/)
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
describe('BarChart — stacked mode', () => {
|
|
414
|
+
it('seams adjacent segments in the panel colour, and not the top one', () => {
|
|
415
|
+
const { container } = render(
|
|
416
|
+
<BarChart data={GROUPED} series={SERIES} mode="stacked" />,
|
|
417
|
+
)
|
|
418
|
+
const seamed = marks(container).filter(
|
|
419
|
+
(el) => el.style.borderTopColor !== '' || el.style.borderTop !== '',
|
|
420
|
+
)
|
|
421
|
+
// One seam per category — the lower of the two segments only.
|
|
422
|
+
expect(seamed).toHaveLength(GROUPED.length)
|
|
423
|
+
for (const el of seamed) {
|
|
424
|
+
expect(el.style.borderTop).toContain('rgb(var(--surface-card))')
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe('BarChart — review additions (reserved space, honesty, a11y noise)', () => {
|
|
430
|
+
it('keeps the value-axis structure when every value is zero', () => {
|
|
431
|
+
// The gutter and its gridlines are what stop the plot changing WIDTH
|
|
432
|
+
// between a range with data and one without (D9, reserved space). They
|
|
433
|
+
// stay, unlabelled — a single nice tick at zero would be centred at
|
|
434
|
+
// mid-height by the frame, which would draw a zero line that is not
|
|
435
|
+
// where zero is.
|
|
436
|
+
const zeros: BarChartDatum[] = [
|
|
437
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
438
|
+
{ key: 'b', label: 'B', value: 0 },
|
|
439
|
+
]
|
|
440
|
+
const { container } = render(<BarChart data={zeros} />)
|
|
441
|
+
const gridlines = Array.from(
|
|
442
|
+
container.querySelectorAll<HTMLElement>('div'),
|
|
443
|
+
).filter((el) => el.style.background === 'rgb(var(--chart-grid))')
|
|
444
|
+
expect(gridlines.length).toBeGreaterThan(0)
|
|
445
|
+
// Structure, not numbers: no tick reading "0" anywhere in the gutter.
|
|
446
|
+
expect(screen.queryByText('0')).not.toBeInTheDocument()
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('drops the category labels when it has declined to draw the bars', () => {
|
|
450
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
451
|
+
const restore = stubLayout(480, 150)
|
|
452
|
+
try {
|
|
453
|
+
const many: BarChartDatum[] = Array.from({ length: 300 }, (_, i) => ({
|
|
454
|
+
key: `d${i}`,
|
|
455
|
+
label: `L${i}`,
|
|
456
|
+
value: i + 1,
|
|
457
|
+
}))
|
|
458
|
+
render(<BarChart data={many} />)
|
|
459
|
+
// 300 crushed ellipses under a "too dense" message is the same mush
|
|
460
|
+
// the refusal exists to avoid.
|
|
461
|
+
expect(screen.queryByText('L299')).not.toBeInTheDocument()
|
|
462
|
+
expect(screen.queryByText('L0')).not.toBeInTheDocument()
|
|
463
|
+
} finally {
|
|
464
|
+
restore()
|
|
465
|
+
warn.mockRestore()
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('warns rather than silently clamping a negative in a stacked chart', () => {
|
|
470
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
471
|
+
try {
|
|
472
|
+
render(
|
|
473
|
+
<BarChart
|
|
474
|
+
data={[{ key: 'q1', label: 'Q1', values: [10, -4] }]}
|
|
475
|
+
series={SERIES}
|
|
476
|
+
mode="stacked"
|
|
477
|
+
/>,
|
|
478
|
+
)
|
|
479
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
480
|
+
expect(String(warn.mock.calls[0]?.[0])).toMatch(/stacked chart cannot draw a negative/i)
|
|
481
|
+
} finally {
|
|
482
|
+
warn.mockRestore()
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('does not draw a negative in a grouped chart from the same data', () => {
|
|
487
|
+
// The clamp is a STACKED limitation, not a BarChart one: grouped mode
|
|
488
|
+
// draws the negative properly and must not warn.
|
|
489
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
490
|
+
try {
|
|
491
|
+
const { container } = render(
|
|
492
|
+
<BarChart
|
|
493
|
+
data={[{ key: 'q1', label: 'Q1', values: [10, -4] }]}
|
|
494
|
+
series={SERIES}
|
|
495
|
+
/>,
|
|
496
|
+
)
|
|
497
|
+
expect(warn).not.toHaveBeenCalled()
|
|
498
|
+
const drawn = bars(container).filter((el) =>
|
|
499
|
+
el.style.background.includes('--series-'),
|
|
500
|
+
)
|
|
501
|
+
expect(drawn).toHaveLength(2)
|
|
502
|
+
expect(drawn.every((el) => parseFloat(el.style.height) > 0)).toBe(true)
|
|
503
|
+
} finally {
|
|
504
|
+
warn.mockRestore()
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('announces a pressed state only when the caller models selection', () => {
|
|
509
|
+
const { rerender } = render(<BarChart data={DATA} onSelect={() => {}} />)
|
|
510
|
+
expect(screen.getByRole('button', { name: 'Mon: 40' })).not.toHaveAttribute(
|
|
511
|
+
'aria-pressed',
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
rerender(<BarChart data={DATA} onSelect={() => {}} selectedKey={null} />)
|
|
515
|
+
expect(screen.getByRole('button', { name: 'Mon: 40' })).toHaveAttribute(
|
|
516
|
+
'aria-pressed',
|
|
517
|
+
'false',
|
|
518
|
+
)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('insets the horizontal category label off the bar region', () => {
|
|
522
|
+
// Padding on the gutter would be DEAD style: an absolutely-positioned
|
|
523
|
+
// child resolves its offsets against the ancestor's padding box, so the
|
|
524
|
+
// labels would sit flush against the bars.
|
|
525
|
+
const { container } = render(
|
|
526
|
+
<BarChart data={DATA} orientation="horizontal" />,
|
|
527
|
+
)
|
|
528
|
+
const gutterLabels = Array.from(
|
|
529
|
+
container.querySelectorAll<HTMLElement>('span'),
|
|
530
|
+
).filter((el) => el.style.transform === 'translateY(-50%)')
|
|
531
|
+
expect(gutterLabels).toHaveLength(3)
|
|
532
|
+
for (const label of gutterLabels) {
|
|
533
|
+
expect(label.style.right).toBe('var(--space-2)')
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('never tweens with `all`, and never with a spring', () => {
|
|
538
|
+
const { container } = render(<BarChart data={DATA} />)
|
|
539
|
+
// `className` is an SVGAnimatedString on the frame's Lucide icons, so
|
|
540
|
+
// read the attribute rather than the property.
|
|
541
|
+
for (const el of Array.from(container.querySelectorAll('*'))) {
|
|
542
|
+
expect(el.getAttribute('class') ?? '').not.toMatch(/transition-all/)
|
|
543
|
+
}
|
|
544
|
+
for (const el of Array.from(container.querySelectorAll<HTMLElement>('div'))) {
|
|
545
|
+
expect(el.style.transitionTimingFunction).not.toMatch(/spring/)
|
|
546
|
+
}
|
|
547
|
+
const animated = bars(container)
|
|
548
|
+
expect(animated).toHaveLength(DATA.length)
|
|
549
|
+
for (const mark of animated) {
|
|
550
|
+
expect(mark.style.transitionTimingFunction).toBe('var(--ease-out)')
|
|
551
|
+
expect(mark.className).toMatch(/^.*motion-safe:transition-\[/)
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
describe('BarChart — an absent value is not a measured zero (Truthful)', () => {
|
|
557
|
+
const PARTIAL: BarChartDatum[] = [
|
|
558
|
+
{ key: 'a', label: 'A', value: 10 },
|
|
559
|
+
// No `value` at all: this category was never measured. "0" is a
|
|
560
|
+
// different fact, and a chart that prints one for the other lies.
|
|
561
|
+
{ key: 'b', label: 'B' },
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
it('names an absent category with an em dash, not a zero', () => {
|
|
565
|
+
render(<BarChart title="Volume" data={PARTIAL} />)
|
|
566
|
+
expect(screen.getByRole('img', { name: 'B: —' })).toBeInTheDocument()
|
|
567
|
+
expect(screen.queryByRole('img', { name: 'B: 0' })).not.toBeInTheDocument()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('draws no bar where there is no measurement', () => {
|
|
571
|
+
const { container } = render(<BarChart title="Volume" data={PARTIAL} />)
|
|
572
|
+
// A zero-length bar sitting on the baseline is indistinguishable from a
|
|
573
|
+
// measured zero, so there must be no mark at all.
|
|
574
|
+
expect(bars(container)).toHaveLength(1)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('reads an em dash in the readout, never a zero', async () => {
|
|
578
|
+
render(<BarChart title="Volume" data={PARTIAL} />)
|
|
579
|
+
await userEvent.hover(screen.getByRole('img', { name: 'B: —' }))
|
|
580
|
+
const tooltip = screen.getByRole('tooltip', { hidden: true })
|
|
581
|
+
expect(within(tooltip).getByText('B')).toBeInTheDocument()
|
|
582
|
+
expect(within(tooltip).getByText('—')).toBeInTheDocument()
|
|
583
|
+
expect(within(tooltip).queryByText('0')).not.toBeInTheDocument()
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('writes an em dash in the table view', async () => {
|
|
587
|
+
render(<BarChart title="Volume" data={PARTIAL} />)
|
|
588
|
+
await userEvent.click(screen.getByRole('button', { name: 'Table view' }))
|
|
589
|
+
const table = screen.getByRole('table')
|
|
590
|
+
expect(within(table).getByRole('cell', { name: '—' })).toBeInTheDocument()
|
|
591
|
+
expect(within(table).queryByRole('cell', { name: '0' })).not.toBeInTheDocument()
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('never lets an absent value win the peak emphasis', () => {
|
|
595
|
+
const { container } = render(
|
|
596
|
+
<BarChart
|
|
597
|
+
data={[
|
|
598
|
+
{ key: 'a', label: 'A', value: -5 },
|
|
599
|
+
{ key: 'b', label: 'B' },
|
|
600
|
+
{ key: 'c', label: 'C', value: -20 },
|
|
601
|
+
]}
|
|
602
|
+
/>,
|
|
603
|
+
)
|
|
604
|
+
// Read as zero, B is the largest number in the set and steals the accent
|
|
605
|
+
// for a bar that does not exist. The peak is the largest MEASURED value.
|
|
606
|
+
expect(bars(container).map((el) => el.style.background)).toEqual([
|
|
607
|
+
'rgb(var(--accent))',
|
|
608
|
+
'rgb(var(--accent) / 0.14)',
|
|
609
|
+
])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('says there is no data, not that every value is zero, when nothing was measured', () => {
|
|
613
|
+
render(
|
|
614
|
+
<BarChart
|
|
615
|
+
data={[
|
|
616
|
+
{ key: 'a', label: 'A' },
|
|
617
|
+
{ key: 'b', label: 'B' },
|
|
618
|
+
]}
|
|
619
|
+
/>,
|
|
620
|
+
)
|
|
621
|
+
expect(screen.queryByText(/every value in this range is zero/i)).not.toBeInTheDocument()
|
|
622
|
+
expect(screen.getByText('No data for this range.')).toBeInTheDocument()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('keeps a missing grouped entry absent rather than reporting a zero', () => {
|
|
626
|
+
const { container } = render(
|
|
627
|
+
<BarChart data={[{ key: 'q1', label: 'Q1', values: [10] }]} series={SERIES} />,
|
|
628
|
+
)
|
|
629
|
+
expect(
|
|
630
|
+
screen.getByRole('img', { name: 'Q1: Paid 10, Organic —' }),
|
|
631
|
+
).toBeInTheDocument()
|
|
632
|
+
// One segment drawn, not a second one of zero length.
|
|
633
|
+
expect(bars(container).filter((el) => el.style.background.includes('--series-')))
|
|
634
|
+
.toHaveLength(1)
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('skips an absent segment in a stack rather than seaming a zero onto it', () => {
|
|
638
|
+
const { container } = render(
|
|
639
|
+
<BarChart
|
|
640
|
+
data={[{ key: 'q1', label: 'Q1', values: [10] }]}
|
|
641
|
+
series={SERIES}
|
|
642
|
+
mode="stacked"
|
|
643
|
+
/>,
|
|
644
|
+
)
|
|
645
|
+
const drawn = bars(container).filter((el) =>
|
|
646
|
+
el.style.background.includes('--series-'),
|
|
647
|
+
)
|
|
648
|
+
expect(drawn).toHaveLength(1)
|
|
649
|
+
// Nothing sits above the only segment, so it wears no seam.
|
|
650
|
+
expect(drawn[0]?.style.borderTop).toBe('')
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
describe('BarChart — what the all-zero message is allowed to claim', () => {
|
|
655
|
+
it('says every value is zero only when every value was measured', () => {
|
|
656
|
+
render(
|
|
657
|
+
<BarChart
|
|
658
|
+
data={[
|
|
659
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
660
|
+
{ key: 'b', label: 'B', value: 0 },
|
|
661
|
+
]}
|
|
662
|
+
/>,
|
|
663
|
+
)
|
|
664
|
+
expect(screen.getByText('Every value in this range is zero.')).toBeInTheDocument()
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('narrows the claim to the measured values when one is absent', () => {
|
|
668
|
+
render(
|
|
669
|
+
<BarChart
|
|
670
|
+
data={[
|
|
671
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
672
|
+
{ key: 'b', label: 'B' },
|
|
673
|
+
]}
|
|
674
|
+
/>,
|
|
675
|
+
)
|
|
676
|
+
// B was never measured, so "every value is zero" would over-claim.
|
|
677
|
+
expect(
|
|
678
|
+
screen.getByText('Every value measured in this range is zero.'),
|
|
679
|
+
).toBeInTheDocument()
|
|
680
|
+
expect(screen.getByRole('img', { name: 'B: —' })).toBeInTheDocument()
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
describe('BarChart — an in-plot message is not tangled in the gridlines', () => {
|
|
685
|
+
/**
|
|
686
|
+
* MEASURED in a browser, not read out of the source. On the all-zero demo
|
|
687
|
+
* the message box spanned y 241-260 while a `rgba(0,0,0,0.08)` gridline
|
|
688
|
+
* 608px wide sat at y=250 — a rule running dead centre through "Every value
|
|
689
|
+
* in this range is zero." Both the message element and its parent computed
|
|
690
|
+
* to a transparent background.
|
|
691
|
+
*
|
|
692
|
+
* The all-zero and too-dense states deliberately KEEP the axis chrome (that
|
|
693
|
+
* is the point — live gridlines are what prove the chart is not broken), so
|
|
694
|
+
* the fix is not to hide the rules but to give the message an opaque
|
|
695
|
+
* knock-out on the panel surface.
|
|
696
|
+
*
|
|
697
|
+
* WHAT THIS TEST CAN AND CANNOT SEE. jsdom stores a `var()` value verbatim
|
|
698
|
+
* without computing it, so it can prove the backing is DECLARED and that
|
|
699
|
+
* the gridlines are still drawn behind it. It cannot prove the two colours
|
|
700
|
+
* actually match at computed-value time, and it cannot re-measure the
|
|
701
|
+
* overlap — that is the browser pass.
|
|
702
|
+
*/
|
|
703
|
+
|
|
704
|
+
/** The opaque backing the plot notice must declare. */
|
|
705
|
+
const BACKING = 'rgb(var(--surface-card))'
|
|
706
|
+
|
|
707
|
+
it('backs the all-zero message opaquely while the gridlines stay live', () => {
|
|
708
|
+
const { container } = render(
|
|
709
|
+
<BarChart
|
|
710
|
+
data={[
|
|
711
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
712
|
+
{ key: 'b', label: 'B', value: 0 },
|
|
713
|
+
]}
|
|
714
|
+
/>,
|
|
715
|
+
)
|
|
716
|
+
const message = screen.getByText('Every value in this range is zero.')
|
|
717
|
+
expect(message.style.background).toBe(BACKING)
|
|
718
|
+
// Padding and radius, so it reads as a label rather than a text run with
|
|
719
|
+
// a coloured box clamped to its glyphs.
|
|
720
|
+
expect(message.style.paddingBlock).toBe('var(--space-1)')
|
|
721
|
+
expect(message.style.paddingInline).toBe('var(--space-3)')
|
|
722
|
+
expect(message.style.borderRadius).toBe('var(--radius-sm)')
|
|
723
|
+
|
|
724
|
+
// The rules are still there — the notice masks them, it does not
|
|
725
|
+
// replace them. A fix that deleted the gridlines would pass a naive
|
|
726
|
+
// "no rule through the text" check and lose the axis.
|
|
727
|
+
const gridlines = Array.from(
|
|
728
|
+
container.querySelectorAll<HTMLElement>('div'),
|
|
729
|
+
).filter((el) => el.style.background === 'rgb(var(--chart-grid))')
|
|
730
|
+
expect(gridlines.length).toBeGreaterThan(0)
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('backs the too-dense message the same way', () => {
|
|
734
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
735
|
+
const restore = stubLayout(480, 150)
|
|
736
|
+
try {
|
|
737
|
+
const many: BarChartDatum[] = Array.from({ length: 300 }, (_, i) => ({
|
|
738
|
+
key: `d${i}`,
|
|
739
|
+
label: `L${i}`,
|
|
740
|
+
value: i + 1,
|
|
741
|
+
}))
|
|
742
|
+
render(<BarChart data={many} />)
|
|
743
|
+
const message = screen.getByText(/too many categories to chart legibly/i)
|
|
744
|
+
expect(message.style.background).toBe(BACKING)
|
|
745
|
+
expect(message.style.borderRadius).toBe('var(--radius-sm)')
|
|
746
|
+
} finally {
|
|
747
|
+
restore()
|
|
748
|
+
warn.mockRestore()
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('lets a pointer through the notice to the marks underneath', () => {
|
|
753
|
+
// `absolute inset-0` over the whole plot would otherwise swallow every
|
|
754
|
+
// hover the chart's own interaction layer depends on (D9).
|
|
755
|
+
render(
|
|
756
|
+
<BarChart
|
|
757
|
+
data={[
|
|
758
|
+
{ key: 'a', label: 'A', value: 0 },
|
|
759
|
+
{ key: 'b', label: 'B', value: 0 },
|
|
760
|
+
]}
|
|
761
|
+
/>,
|
|
762
|
+
)
|
|
763
|
+
const overlay = screen.getByText('Every value in this range is zero.')
|
|
764
|
+
.parentElement as HTMLElement
|
|
765
|
+
expect(overlay.className).toContain('pointer-events-none')
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
describe('BarChart category axis (the 2026-09-06 browser findings)', () => {
|
|
770
|
+
/**
|
|
771
|
+
* jsdom cannot tell whether a label LOOKS like it is under its bar. What
|
|
772
|
+
* it can assert is the number the browser lays it out from: the inline
|
|
773
|
+
* `left` percentage, which must be the band's own centre — the same
|
|
774
|
+
* `bandScale.center` the bar is drawn on. The visual result is a browser
|
|
775
|
+
* measurement and is recorded in the fix report.
|
|
776
|
+
*/
|
|
777
|
+
function xLabelLefts(container: HTMLElement): { text: string; left: string }[] {
|
|
778
|
+
const rows = container.querySelectorAll('figure > div[aria-hidden="true"]')
|
|
779
|
+
const row = rows[rows.length - 1]
|
|
780
|
+
if (!(row instanceof HTMLElement)) throw new Error('no x-label row')
|
|
781
|
+
return Array.from(row.querySelectorAll('span')).map((span) => ({
|
|
782
|
+
text: span.textContent ?? '',
|
|
783
|
+
left: span.style.left,
|
|
784
|
+
}))
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
it('puts each category label at its BAND CENTRE, not on an even spread', () => {
|
|
788
|
+
// Three bands across the plot: centres at 1/6, 3/6 and 5/6. An even
|
|
789
|
+
// spread would have put them at 0, 1/2 and 1 — which is what
|
|
790
|
+
// `justify-between` did, and why "Paid search" sat 65px from its bar on
|
|
791
|
+
// the 3-category demo in Chromium at 1440.
|
|
792
|
+
const { container } = render(<BarChart data={DATA} title="Sessions" />)
|
|
793
|
+
const labels = xLabelLefts(container)
|
|
794
|
+
expect(labels.map((label) => label.text)).toEqual(['Mon', 'Tue', 'Wed'])
|
|
795
|
+
// Percentages, so epsilon rather than `toBe` (CLAUDE.md §7).
|
|
796
|
+
const pct = (value: string | undefined): number =>
|
|
797
|
+
Number((value ?? '').replace('%', ''))
|
|
798
|
+
expect(pct(labels[0]?.left)).toBeCloseTo((1 / 6) * 100, 6)
|
|
799
|
+
expect(pct(labels[1]?.left)).toBeCloseTo(50, 6)
|
|
800
|
+
expect(pct(labels[2]?.left)).toBeCloseTo((5 / 6) * 100, 6)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('centres a single category rather than pinning it to the left edge', () => {
|
|
804
|
+
const { container } = render(
|
|
805
|
+
<BarChart data={[{ key: 'only', label: 'Only', value: 5 }]} title="One" />,
|
|
806
|
+
)
|
|
807
|
+
expect(xLabelLefts(container)[0]?.left).toBe('50%')
|
|
808
|
+
})
|
|
809
|
+
})
|