@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,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparkline — ADR-146 D5.
|
|
3
|
+
*
|
|
4
|
+
* Two things drive the shape of this file.
|
|
5
|
+
*
|
|
6
|
+
* **It must hold at both ends of its size range.** A sparkline that looks
|
|
7
|
+
* right at 300×60 and clips its end dot at 60×20 is broken in exactly the
|
|
8
|
+
* place it is actually used — a table cell. So the geometry cases run at both
|
|
9
|
+
* sizes and assert that no mark leaves the box.
|
|
10
|
+
*
|
|
11
|
+
* **It inherits arithmetic from the primitive it supersedes.** The bar
|
|
12
|
+
* variant reproduces `allocation-sparkbar.tsx:48`'s `8 + (v/max) * (h - 12)`
|
|
13
|
+
* exactly at that file's 36px default height. That equivalence is the whole
|
|
14
|
+
* reason the migration in ADR-146 D13 is a swap rather than a redesign, so it
|
|
15
|
+
* is pinned to the integer.
|
|
16
|
+
*
|
|
17
|
+
* Float comparisons use an epsilon (CLAUDE.md §7); the 36px cases land on
|
|
18
|
+
* integers by construction and are asserted exactly.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, it } from 'vitest'
|
|
21
|
+
import { render, screen } from '@testing-library/react'
|
|
22
|
+
import { Sparkline } from '../sparkline'
|
|
23
|
+
|
|
24
|
+
const EPS = 1e-6
|
|
25
|
+
|
|
26
|
+
/** The reserved plot box. */
|
|
27
|
+
function plot(container: HTMLElement): HTMLElement {
|
|
28
|
+
const el = container.querySelector<HTMLElement>('.relative')
|
|
29
|
+
if (el === null) throw new Error('no plot box rendered')
|
|
30
|
+
return el
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The absolutely-positioned bars, left to right. */
|
|
34
|
+
function bars(container: HTMLElement): HTMLElement[] {
|
|
35
|
+
return Array.from(container.querySelectorAll<HTMLElement>('.relative > div'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A style length as a number, e.g. `'17.5px'` → `17.5`. */
|
|
39
|
+
function px(value: string): number {
|
|
40
|
+
return Number.parseFloat(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('Sparkline reserved space', () => {
|
|
44
|
+
it('reserves its exact box at cell size and at panel size', () => {
|
|
45
|
+
const { container, rerender } = render(<Sparkline values={[1, 2, 3]} width={60} height={20} />)
|
|
46
|
+
expect(plot(container)).toHaveStyle({ width: '60px', height: '20px' })
|
|
47
|
+
|
|
48
|
+
rerender(<Sparkline values={[1, 2, 3]} width={300} height={60} />)
|
|
49
|
+
expect(plot(container)).toHaveStyle({ width: '300px', height: '60px' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps the box when there is no data, so a row cannot reflow', () => {
|
|
53
|
+
const { container } = render(<Sparkline values={[]} width={60} height={20} />)
|
|
54
|
+
expect(plot(container)).toHaveStyle({ width: '60px', height: '20px' })
|
|
55
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reserves the trailing value column', () => {
|
|
59
|
+
const { container } = render(<Sparkline values={[1, 2]} valueLabel="11,227" />)
|
|
60
|
+
const value = container.querySelector<HTMLElement>('span')
|
|
61
|
+
expect(value).toHaveTextContent('11,227')
|
|
62
|
+
expect(value).toHaveClass('tabular-nums')
|
|
63
|
+
expect(value?.style.minWidth).toBe('var(--space-10)')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('Sparkline line variant', () => {
|
|
68
|
+
it('draws a bare trend — no axes, no gridlines, no frame', () => {
|
|
69
|
+
const { container } = render(<Sparkline values={[1, 4, 2, 5]} />)
|
|
70
|
+
const svg = container.querySelector('svg')
|
|
71
|
+
expect(svg?.querySelectorAll('line')).toHaveLength(0)
|
|
72
|
+
expect(svg?.querySelectorAll('rect')).toHaveLength(0)
|
|
73
|
+
expect(svg?.querySelectorAll('text')).toHaveLength(0)
|
|
74
|
+
// A path for the line, plus the single end dot. Nothing else.
|
|
75
|
+
expect(svg?.querySelectorAll('path')).toHaveLength(1)
|
|
76
|
+
expect(svg?.querySelectorAll('circle')).toHaveLength(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('plots the exact path at 60x20', () => {
|
|
80
|
+
const { container } = render(<Sparkline values={[0, 1]} width={60} height={20} />)
|
|
81
|
+
// dot r 2.5 + half its 1.5 stroke = 3.25 inset on every side.
|
|
82
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M3.25,16.75 L56.75,3.25')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('plots the exact path at 300x60', () => {
|
|
86
|
+
const { container } = render(<Sparkline values={[0, 1]} width={300} height={60} />)
|
|
87
|
+
// dot r 6 + half its 3px stroke = 7.5 inset.
|
|
88
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M7.5,52.5 L292.5,7.5')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('never lets the end dot leave the box, at either size', () => {
|
|
92
|
+
for (const [w, h] of [
|
|
93
|
+
[60, 20],
|
|
94
|
+
[300, 60],
|
|
95
|
+
] as const) {
|
|
96
|
+
const { container, unmount } = render(<Sparkline values={[0, 1]} width={w} height={h} />)
|
|
97
|
+
const dot = container.querySelector('circle')
|
|
98
|
+
const cx = Number(dot?.getAttribute('cx'))
|
|
99
|
+
const cy = Number(dot?.getAttribute('cy'))
|
|
100
|
+
const r = Number(dot?.getAttribute('r'))
|
|
101
|
+
const half = Number(dot?.getAttribute('stroke-width')) / 2
|
|
102
|
+
expect(cx - r - half).toBeGreaterThanOrEqual(-EPS)
|
|
103
|
+
expect(cx + r + half).toBeLessThanOrEqual(w + EPS)
|
|
104
|
+
expect(cy - r - half).toBeGreaterThanOrEqual(-EPS)
|
|
105
|
+
expect(cy + r + half).toBeLessThanOrEqual(h + EPS)
|
|
106
|
+
unmount()
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('draws the end dot HOLLOW — surface fill, coloured stroke', () => {
|
|
111
|
+
const { container } = render(<Sparkline values={[1, 2]} />)
|
|
112
|
+
const dot = container.querySelector('circle')
|
|
113
|
+
expect(dot?.getAttribute('fill')).toBe('rgb(var(--surface-card))')
|
|
114
|
+
expect(dot?.getAttribute('stroke')).toBe('rgb(var(--accent))')
|
|
115
|
+
expect(Number(dot?.getAttribute('stroke-width'))).toBeGreaterThanOrEqual(1.5)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('drops the dot, and its inset, when emphasis is off', () => {
|
|
119
|
+
const { container } = render(
|
|
120
|
+
<Sparkline values={[0, 1]} width={60} height={20} emphasizeLast={false} />,
|
|
121
|
+
)
|
|
122
|
+
expect(container.querySelector('circle')).toBeNull()
|
|
123
|
+
// Inset is now half the 2px stroke, so the line spans nearly the full box.
|
|
124
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M1,19 L59,1')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('insets for a thick stroke even when the dot reserves less', () => {
|
|
128
|
+
// strokeWidth 8 at h=20 needs 4px of inset; the dot only reserves 3.25.
|
|
129
|
+
// Taking the max is what keeps the line inside the viewBox.
|
|
130
|
+
const { container } = render(
|
|
131
|
+
<Sparkline values={[0, 1]} width={60} height={20} strokeWidth={8} />,
|
|
132
|
+
)
|
|
133
|
+
const d = container.querySelector('path')?.getAttribute('d') ?? ''
|
|
134
|
+
const ys = [...d.matchAll(/,(-?[\d.]+)/g)].map((m) => Number(m[1]))
|
|
135
|
+
for (const y of ys) {
|
|
136
|
+
expect(y).toBeGreaterThanOrEqual(4 - EPS)
|
|
137
|
+
expect(y).toBeLessThanOrEqual(20 - 4 + EPS)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('emphasizes the peak rather than the last point when asked', () => {
|
|
142
|
+
const { container } = render(
|
|
143
|
+
<Sparkline values={[0, 9, 1]} width={300} height={60} emphasisIndex={1} />,
|
|
144
|
+
)
|
|
145
|
+
expect(Number(container.querySelector('circle')?.getAttribute('cx'))).toBeCloseTo(150, 6)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('clamps an out-of-range emphasis index onto a real mark', () => {
|
|
149
|
+
const { container } = render(
|
|
150
|
+
<Sparkline values={[0, 1, 2]} width={300} height={60} emphasisIndex={99} />,
|
|
151
|
+
)
|
|
152
|
+
expect(Number(container.querySelector('circle')?.getAttribute('cx'))).toBeCloseTo(292.5, 6)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('adds the area fill only when asked, and only as a tint', () => {
|
|
156
|
+
const { container, rerender } = render(<Sparkline values={[1, 2, 3]} />)
|
|
157
|
+
expect(container.querySelectorAll('path')).toHaveLength(1)
|
|
158
|
+
|
|
159
|
+
rerender(<Sparkline values={[1, 2, 3]} area />)
|
|
160
|
+
const paths = container.querySelectorAll('path')
|
|
161
|
+
expect(paths).toHaveLength(2)
|
|
162
|
+
expect(paths[0]?.getAttribute('fill-opacity')).toBe('0.12')
|
|
163
|
+
expect(paths[0]?.getAttribute('fill')).toBe('rgb(var(--accent))')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('colours from the frozen series lookup when given a slot', () => {
|
|
167
|
+
const { container } = render(<Sparkline values={[1, 2]} slot={2} />)
|
|
168
|
+
expect(container.querySelector('path')?.getAttribute('stroke')).toBe('rgb(var(--series-3))')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('Sparkline line degenerate data', () => {
|
|
173
|
+
it('paints a single datum as a dot rather than nothing', () => {
|
|
174
|
+
const { container } = render(<Sparkline values={[5]} width={60} height={20} />)
|
|
175
|
+
// A zero-length segment with a round linecap is a visible dot.
|
|
176
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M30,10 L30,10')
|
|
177
|
+
expect(container.querySelector('path')?.getAttribute('stroke-linecap')).toBe('round')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('draws a flat series through the middle, not pinned to an edge', () => {
|
|
181
|
+
const { container } = render(<Sparkline values={[7, 7, 7]} width={60} height={20} />)
|
|
182
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M3.25,10 L30,10 L56.75,10')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('draws an all-zero series the same way — flat, never NaN', () => {
|
|
186
|
+
const { container } = render(<Sparkline values={[0, 0]} width={60} height={20} />)
|
|
187
|
+
const d = container.querySelector('path')?.getAttribute('d') ?? ''
|
|
188
|
+
expect(d).toBe('M3.25,10 L56.75,10')
|
|
189
|
+
expect(d).not.toContain('NaN')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('plots negative values on their real position', () => {
|
|
193
|
+
const { container } = render(<Sparkline values={[-1, 1]} width={60} height={20} />)
|
|
194
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M3.25,16.75 L56.75,3.25')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('closes over a non-finite datum instead of inventing a dip at zero', () => {
|
|
198
|
+
const { container } = render(
|
|
199
|
+
<Sparkline values={[1, Number.NaN, 3]} width={60} height={20} />,
|
|
200
|
+
)
|
|
201
|
+
expect(container.querySelector('path')?.getAttribute('d')).toBe('M3.25,16.75 L56.75,3.25')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('omits the dot when the emphasized datum is not plottable', () => {
|
|
205
|
+
const { container } = render(<Sparkline values={[1, 2, Number.NaN]} />)
|
|
206
|
+
expect(container.querySelector('circle')).toBeNull()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('renders nothing but the reserved box when every datum is non-finite', () => {
|
|
210
|
+
const { container } = render(
|
|
211
|
+
<Sparkline values={[Number.NaN, Number.POSITIVE_INFINITY]} width={60} height={20} />,
|
|
212
|
+
)
|
|
213
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
214
|
+
expect(plot(container)).toHaveStyle({ width: '60px', height: '20px' })
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('Sparkline bar variant', () => {
|
|
219
|
+
it('reproduces AllocationSparkbar arithmetic exactly at its 36px height', () => {
|
|
220
|
+
// floor 8, headroom 4 → a bar spans 8..32, i.e. `8 + f * (36 - 12)`.
|
|
221
|
+
const { container } = render(
|
|
222
|
+
<Sparkline variant="bar" values={[10, 0, 5]} width={90} height={36} />,
|
|
223
|
+
)
|
|
224
|
+
expect(bars(container).map((b) => b.style.height)).toEqual(['32px', '4px', '20px'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('scales the floor with the box so it cannot swallow a 20px strip', () => {
|
|
228
|
+
const { container } = render(
|
|
229
|
+
<Sparkline variant="bar" values={[10, 5]} width={60} height={20} />,
|
|
230
|
+
)
|
|
231
|
+
const heights = bars(container).map((b) => px(b.style.height))
|
|
232
|
+
// floor 20*2/9, cap 20 - 20/9.
|
|
233
|
+
expect(heights[0]).toBeCloseTo(20 - 20 / 9, 6)
|
|
234
|
+
expect(heights[1]).toBeCloseTo((20 * 2) / 9 + 0.5 * (20 - 20 / 9 - (20 * 2) / 9), 6)
|
|
235
|
+
for (const height of heights) expect(height).toBeLessThanOrEqual(20 + EPS)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('lays bars out on a band scale that fits the width', () => {
|
|
239
|
+
const { container } = render(
|
|
240
|
+
<Sparkline variant="bar" values={[1, 2, 3, 4]} width={60} height={20} />,
|
|
241
|
+
)
|
|
242
|
+
const laid = bars(container).map((b) => ({
|
|
243
|
+
left: px(b.style.left),
|
|
244
|
+
width: px(b.style.width),
|
|
245
|
+
}))
|
|
246
|
+
laid.forEach((bar, index) => {
|
|
247
|
+
expect(bar.left).toBeCloseTo(2.1 + index * 15, 6)
|
|
248
|
+
})
|
|
249
|
+
for (const bar of laid) {
|
|
250
|
+
expect(bar.width).toBeCloseTo(10.8, 6)
|
|
251
|
+
expect(bar.left + bar.width).toBeLessThanOrEqual(60 + EPS)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('renders a zero as a short dashed stub — paused, not missing', () => {
|
|
256
|
+
const { container } = render(
|
|
257
|
+
<Sparkline variant="bar" values={[10, 0]} width={60} height={36} />,
|
|
258
|
+
)
|
|
259
|
+
const paused = bars(container)[1]
|
|
260
|
+
expect(paused?.style.height).toBe('4px')
|
|
261
|
+
expect(paused?.style.border).toContain('dashed')
|
|
262
|
+
expect(paused?.style.background).toBe('rgb(var(--text-muted) / 0.18)')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('treats zero as a real measurement when asked to', () => {
|
|
266
|
+
const { container } = render(
|
|
267
|
+
<Sparkline variant="bar" values={[10, 0]} width={60} height={36} zeroAsPaused={false} />,
|
|
268
|
+
)
|
|
269
|
+
const zero = bars(container)[1]
|
|
270
|
+
expect(zero?.style.height).toBe('8px')
|
|
271
|
+
expect(zero?.style.border).toBe('')
|
|
272
|
+
expect(zero?.style.background).toBe('rgb(var(--accent))')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('gives the emphasized bar full strength and tints the rest', () => {
|
|
276
|
+
const { container } = render(
|
|
277
|
+
<Sparkline variant="bar" values={[3, 2, 1]} width={60} height={36} />,
|
|
278
|
+
)
|
|
279
|
+
expect(bars(container).map((b) => b.style.opacity)).toEqual(['0.55', '0.55', '1'])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('emphasizes a named bar, and clamps an out-of-range index', () => {
|
|
283
|
+
const { container, rerender } = render(
|
|
284
|
+
<Sparkline variant="bar" values={[3, 2, 1]} emphasisIndex={0} />,
|
|
285
|
+
)
|
|
286
|
+
expect(bars(container).map((b) => b.style.opacity)).toEqual(['1', '0.55', '0.55'])
|
|
287
|
+
|
|
288
|
+
rerender(<Sparkline variant="bar" values={[3, 2, 1]} emphasisIndex={-4} />)
|
|
289
|
+
expect(bars(container).map((b) => b.style.opacity)).toEqual(['1', '0.55', '0.55'])
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('renders a uniform strip when emphasis is off', () => {
|
|
293
|
+
const { container } = render(
|
|
294
|
+
<Sparkline variant="bar" values={[3, 2, 1]} emphasizeLast={false} />,
|
|
295
|
+
)
|
|
296
|
+
expect(bars(container).map((b) => b.style.opacity)).toEqual(['1', '1', '1'])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('rounds bars lightly rather than into pills', () => {
|
|
300
|
+
const { container } = render(<Sparkline variant="bar" values={[1, 2]} />)
|
|
301
|
+
expect(bars(container)[0]?.style.borderRadius).toBe('calc(var(--radius-xs) / 2)')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('draws an all-zero series at the floor, never at full height', () => {
|
|
305
|
+
const { container } = render(
|
|
306
|
+
<Sparkline
|
|
307
|
+
variant="bar"
|
|
308
|
+
values={[0, 0]}
|
|
309
|
+
width={60}
|
|
310
|
+
height={36}
|
|
311
|
+
zeroAsPaused={false}
|
|
312
|
+
/>,
|
|
313
|
+
)
|
|
314
|
+
expect(bars(container).map((b) => b.style.height)).toEqual(['8px', '8px'])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('clamps a negative value to the floor and absorbs a non-finite one', () => {
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<Sparkline
|
|
320
|
+
variant="bar"
|
|
321
|
+
values={[10, -5, Number.NaN]}
|
|
322
|
+
width={90}
|
|
323
|
+
height={36}
|
|
324
|
+
zeroAsPaused={false}
|
|
325
|
+
/>,
|
|
326
|
+
)
|
|
327
|
+
const heights = bars(container).map((b) => b.style.height)
|
|
328
|
+
expect(heights).toEqual(['32px', '8px', '8px'])
|
|
329
|
+
expect(heights.join()).not.toContain('NaN')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('renders a single bar without dividing by zero', () => {
|
|
333
|
+
const { container } = render(
|
|
334
|
+
<Sparkline variant="bar" values={[42]} width={60} height={36} />,
|
|
335
|
+
)
|
|
336
|
+
expect(bars(container)).toHaveLength(1)
|
|
337
|
+
expect(bars(container)[0]?.style.height).toBe('32px')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('renders nothing but the reserved box for an empty series', () => {
|
|
341
|
+
const { container } = render(
|
|
342
|
+
<Sparkline variant="bar" values={[]} width={60} height={20} />,
|
|
343
|
+
)
|
|
344
|
+
expect(bars(container)).toHaveLength(0)
|
|
345
|
+
expect(plot(container)).toHaveStyle({ width: '60px', height: '20px' })
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('Sparkline accessibility', () => {
|
|
350
|
+
it('is an image with a name when it carries meaning', () => {
|
|
351
|
+
render(<Sparkline values={[1, 2, 3]} label="Impressions, last 12 weeks" />)
|
|
352
|
+
expect(
|
|
353
|
+
screen.getByRole('img', { name: 'Impressions, last 12 weeks' }),
|
|
354
|
+
).toBeInTheDocument()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('hides itself from assistive tech when unlabelled and decorative', () => {
|
|
358
|
+
const { container } = render(<Sparkline values={[1, 2, 3]} />)
|
|
359
|
+
expect(plot(container)).toHaveAttribute('aria-hidden', 'true')
|
|
360
|
+
expect(plot(container)).not.toHaveAttribute('role')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('keeps the inner svg out of the accessibility tree either way', () => {
|
|
364
|
+
const { container } = render(<Sparkline values={[1, 2, 3]} label="Trend" />)
|
|
365
|
+
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
|
|
366
|
+
expect(container.querySelector('svg')).toHaveAttribute('focusable', 'false')
|
|
367
|
+
})
|
|
368
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeltaChip + StatCard — the ADR-146 D5 stat layer.
|
|
3
|
+
*
|
|
4
|
+
* These pin the four behaviours that the three delta forks and the ~26
|
|
5
|
+
* stat-tile forks got wrong or omitted, and that a well-meaning refactor
|
|
6
|
+
* would quietly undo:
|
|
7
|
+
*
|
|
8
|
+
* 1. `invert` flips the COLOUR, not the arrow. Cost metrics are half our
|
|
9
|
+
* KPIs; "up is green" is wrong for every one of them.
|
|
10
|
+
* 2. Direction is carried by glyph + text, never by hue alone.
|
|
11
|
+
* 3. The tray is conditional — a bare stat card must not grow a frame.
|
|
12
|
+
* 4. `pending` renders a well, never a fabricated `0`, and suppresses the
|
|
13
|
+
* delta along with it.
|
|
14
|
+
*
|
|
15
|
+
* The suite runs with `css: false`, so it asserts the values handed to the
|
|
16
|
+
* browser, not painted pixels. CSS validity is covered by the source scan in
|
|
17
|
+
* `token-shape.test.ts`.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it } from 'vitest'
|
|
20
|
+
import { render, screen } from '@testing-library/react'
|
|
21
|
+
import { DeltaChip } from '../delta-chip'
|
|
22
|
+
import { StatCard } from '../stat-card'
|
|
23
|
+
|
|
24
|
+
/** The chip is the element carrying the tone background. */
|
|
25
|
+
function chipOf(container: HTMLElement): HTMLElement {
|
|
26
|
+
const el = container.querySelector('span[style]')
|
|
27
|
+
if (!el) throw new Error('no chip rendered')
|
|
28
|
+
return el as HTMLElement
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('DeltaChip', () => {
|
|
32
|
+
it('renders the magnitude, never a duplicated sign', () => {
|
|
33
|
+
render(<DeltaChip value={-12.5} />)
|
|
34
|
+
expect(screen.getByText('12.5%')).toBeInTheDocument()
|
|
35
|
+
expect(screen.queryByText('-12.5%')).not.toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('formats absolute values without a percent sign', () => {
|
|
39
|
+
render(<DeltaChip value={1204} format="absolute" />)
|
|
40
|
+
expect(screen.getByText('1,204')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('names its direction for assistive tech, not only in colour', () => {
|
|
44
|
+
const up = render(<DeltaChip value={4} />)
|
|
45
|
+
expect(up.container.textContent).toContain('Up')
|
|
46
|
+
up.unmount()
|
|
47
|
+
|
|
48
|
+
const down = render(<DeltaChip value={-4} />)
|
|
49
|
+
expect(down.container.textContent).toContain('Down')
|
|
50
|
+
down.unmount()
|
|
51
|
+
|
|
52
|
+
const flat = render(<DeltaChip value={0} />)
|
|
53
|
+
expect(flat.container.textContent).toContain('No change')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('paints a rise as success and a fall as destructive', () => {
|
|
57
|
+
const up = render(<DeltaChip value={4} />)
|
|
58
|
+
expect(chipOf(up.container).style.background).toContain('--success-bg')
|
|
59
|
+
up.unmount()
|
|
60
|
+
|
|
61
|
+
const down = render(<DeltaChip value={-4} />)
|
|
62
|
+
expect(chipOf(down.container).style.background).toContain('--destructive-bg')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('inverts the COLOUR for cost-shaped metrics, not the arrow', () => {
|
|
66
|
+
// A 4% rise in CPA is bad news painted red — but the arrow still
|
|
67
|
+
// points up, because the number still went up.
|
|
68
|
+
const worse = render(<DeltaChip value={4} invert />)
|
|
69
|
+
expect(chipOf(worse.container).style.background).toContain('--destructive-bg')
|
|
70
|
+
expect(worse.container.textContent).toContain('Up')
|
|
71
|
+
worse.unmount()
|
|
72
|
+
|
|
73
|
+
const better = render(<DeltaChip value={-4} invert />)
|
|
74
|
+
expect(chipOf(better.container).style.background).toContain('--success-bg')
|
|
75
|
+
expect(better.container.textContent).toContain('Down')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reads zero as neutral, in neither semantic colour', () => {
|
|
79
|
+
const { container } = render(<DeltaChip value={0} />)
|
|
80
|
+
const bg = chipOf(container).style.background
|
|
81
|
+
expect(bg).not.toContain('--success')
|
|
82
|
+
expect(bg).not.toContain('--destructive')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('degrades a non-finite delta to flat rather than throwing', () => {
|
|
86
|
+
const { container } = render(<DeltaChip value={Number.NaN} />)
|
|
87
|
+
expect(container.textContent).toContain('No change')
|
|
88
|
+
// `format.ts` renders garbage as an em-dash — never as "NaN%".
|
|
89
|
+
expect(container.textContent).not.toContain('NaN')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reads a change that rounds away as flat, not as a green zero', () => {
|
|
93
|
+
// +0.04% printed at one decimal is "0.0%". Taking the arrow from the
|
|
94
|
+
// raw sign put a green UP arrow next to a zero — the chip contradicting
|
|
95
|
+
// its own numeral.
|
|
96
|
+
const { container } = render(<DeltaChip value={0.04} />)
|
|
97
|
+
expect(screen.getByText('0.0%')).toBeInTheDocument()
|
|
98
|
+
expect(container.textContent).toContain('No change')
|
|
99
|
+
const bg = chipOf(container).style.background
|
|
100
|
+
expect(bg).not.toContain('--success')
|
|
101
|
+
expect(bg).not.toContain('--destructive')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('still reads a change that survives the precision', () => {
|
|
105
|
+
const { container } = render(<DeltaChip value={0.04} decimals={2} />)
|
|
106
|
+
expect(screen.getByText('0.04%')).toBeInTheDocument()
|
|
107
|
+
expect(container.textContent).toContain('Up')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('honours an explicit direction override', () => {
|
|
111
|
+
// Rank 4 → 2 is an improvement expressed as a negative number.
|
|
112
|
+
const { container } = render(<DeltaChip value={-2} direction="up" format="absolute" />)
|
|
113
|
+
expect(container.textContent).toContain('Up')
|
|
114
|
+
expect(chipOf(container).style.background).toContain('--success-bg')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('never puts a second alpha on the alpha-carrying tone fills', () => {
|
|
118
|
+
// `--success-bg` is declared `R G B / A`. A second alpha expands to
|
|
119
|
+
// `rgb(... / 0.12 / 0.5)`, which the browser silently discards.
|
|
120
|
+
const { container } = render(<DeltaChip value={4} />)
|
|
121
|
+
expect(chipOf(container).style.background).toBe('rgb(var(--success-bg))')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('DeltaChip sentiment (ADR-146 amendment, phase audit H1)', () => {
|
|
126
|
+
/**
|
|
127
|
+
* A falling number is not always a failure. The community leaderboard shows
|
|
128
|
+
* a contribution count on a NAMED PERSON, and painting a quieter month in
|
|
129
|
+
* `--destructive` spends the error channel on something that is not an
|
|
130
|
+
* error. `invert` cannot express this — it only flips WHICH direction is
|
|
131
|
+
* good, so a down-trend still reads as an outcome.
|
|
132
|
+
*/
|
|
133
|
+
it('renders a fall in the neutral tone, not destructive, when sentiment is none', () => {
|
|
134
|
+
const { container: judged } = render(<DeltaChip value={-4} format="absolute" />)
|
|
135
|
+
const { container: unjudged } = render(
|
|
136
|
+
<DeltaChip value={-4} format="absolute" sentiment="none" />,
|
|
137
|
+
)
|
|
138
|
+
const styleOf = (c: HTMLElement) =>
|
|
139
|
+
(c.querySelector('[style]') as HTMLElement | null)?.getAttribute('style') ?? ''
|
|
140
|
+
|
|
141
|
+
expect(styleOf(judged)).toContain('--destructive')
|
|
142
|
+
expect(styleOf(unjudged)).not.toContain('--destructive')
|
|
143
|
+
expect(styleOf(unjudged)).toContain('--muted')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('still reports the direction — only the colour declines to judge it', () => {
|
|
147
|
+
const { container } = render(
|
|
148
|
+
<DeltaChip value={-4} format="absolute" sentiment="none" />,
|
|
149
|
+
)
|
|
150
|
+
// The arrow is what carries direction, so identity is never colour-alone.
|
|
151
|
+
expect(container.querySelector('svg')).not.toBeNull()
|
|
152
|
+
expect(container.textContent).toContain('4')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('leaves the default behaviour alone', () => {
|
|
156
|
+
const { container } = render(<DeltaChip value={-4} format="absolute" />)
|
|
157
|
+
expect(
|
|
158
|
+
(container.querySelector('[style]') as HTMLElement | null)?.getAttribute('style'),
|
|
159
|
+
).toContain('--destructive')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('StatCard', () => {
|
|
164
|
+
it('renders label and value', () => {
|
|
165
|
+
render(<StatCard label="Sessions" value="12,480" />)
|
|
166
|
+
expect(screen.getByText('Sessions')).toBeInTheDocument()
|
|
167
|
+
expect(screen.getByText('12,480')).toBeInTheDocument()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('has the delta slot the forked tiles all lacked', () => {
|
|
171
|
+
render(<StatCard label="Sessions" value="12,480" delta={{ value: 8.4 }} />)
|
|
172
|
+
expect(screen.getByText('8.4%')).toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('is a plain card with NO tray when there is nothing but the panel', () => {
|
|
176
|
+
const { container } = render(<StatCard label="Sessions" value="12,480" />)
|
|
177
|
+
expect(container.querySelector('.ds-card-tray')).toBeNull()
|
|
178
|
+
expect(container.querySelector('[data-framed]')).toBeNull()
|
|
179
|
+
expect(container.querySelector('.ds-card-surface')).not.toBeNull()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('promotes to the framed tray once a footer exists', () => {
|
|
183
|
+
const { container } = render(
|
|
184
|
+
<StatCard label="Sessions" value="12,480" footer="vs. last 30 days" />,
|
|
185
|
+
)
|
|
186
|
+
expect(container.querySelector('.ds-card-tray')).not.toBeNull()
|
|
187
|
+
expect(container.querySelector('[data-framed="true"]')).not.toBeNull()
|
|
188
|
+
expect(screen.getByText('vs. last 30 days')).toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('promotes on a footer link alone, and keeps it keyboard-reachable', () => {
|
|
192
|
+
const { container } = render(
|
|
193
|
+
<StatCard
|
|
194
|
+
label="Sessions"
|
|
195
|
+
value="12,480"
|
|
196
|
+
footerLink={{ label: 'View report', onClick: () => {} }}
|
|
197
|
+
/>,
|
|
198
|
+
)
|
|
199
|
+
expect(container.querySelector('.ds-card-tray')).not.toBeNull()
|
|
200
|
+
const link = screen.getByRole('button', { name: /View report/ })
|
|
201
|
+
expect(link.className).toContain('focus-visible:[box-shadow:var(--ring-focus)]')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('renders a well instead of a fabricated zero when pending', () => {
|
|
205
|
+
render(
|
|
206
|
+
<StatCard
|
|
207
|
+
label="Conversion rate"
|
|
208
|
+
value="0%"
|
|
209
|
+
pending
|
|
210
|
+
pendingHint="No conversions tracked yet"
|
|
211
|
+
delta={{ value: 12 }}
|
|
212
|
+
/>,
|
|
213
|
+
)
|
|
214
|
+
expect(screen.getByText('Not available')).toBeInTheDocument()
|
|
215
|
+
expect(screen.getByText('No conversions tracked yet')).toBeInTheDocument()
|
|
216
|
+
expect(screen.queryByText('0%')).not.toBeInTheDocument()
|
|
217
|
+
// A delta on data we do not have is a fabricated figure too.
|
|
218
|
+
expect(screen.queryByText('12.0%')).not.toBeInTheDocument()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('renders the pending well into the SAME reserved slot as the value', () => {
|
|
222
|
+
// The well used to be padded like a control (8px) and stood 10px taller
|
|
223
|
+
// than the value row it replaces, so a pending card grew its whole grid
|
|
224
|
+
// row on load. Both branches now render into one --space-6 slot.
|
|
225
|
+
const loaded = render(<StatCard label="Sessions" value="12,480" />)
|
|
226
|
+
const loadedSlot = loaded.container.querySelector<HTMLElement>(
|
|
227
|
+
'div[style*="min-height"]',
|
|
228
|
+
)
|
|
229
|
+
expect(loadedSlot?.style.minHeight).toBe('var(--space-6)')
|
|
230
|
+
loaded.unmount()
|
|
231
|
+
|
|
232
|
+
const { container } = render(<StatCard label="Sessions" value="12,480" pending />)
|
|
233
|
+
const slot = container.querySelector<HTMLElement>('div[style*="min-height"]')
|
|
234
|
+
expect(slot?.style.minHeight).toBe('var(--space-6)')
|
|
235
|
+
expect(slot?.textContent).toContain('Not available')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('sets the pending hint in a token that clears the text floor', () => {
|
|
239
|
+
// --text-muted is documented in tokens.css as non-text/disabled chrome
|
|
240
|
+
// (3.47:1 on a white card). The hint is prose a user has to read.
|
|
241
|
+
render(
|
|
242
|
+
<StatCard label="Sessions" value="0" pending pendingHint="No data yet" />,
|
|
243
|
+
)
|
|
244
|
+
const hint = screen.getByText('No data yet')
|
|
245
|
+
expect(hint.style.color).toBe('rgb(var(--text-tertiary))')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('steps the icon tile off the card surface so it survives dark mode', () => {
|
|
249
|
+
// --surface-card ON --surface-card is the same colour in dark, leaving
|
|
250
|
+
// an 8%-alpha border as the tile's only edge.
|
|
251
|
+
const { container } = render(
|
|
252
|
+
<StatCard label="Sessions" value="12,480" icon={<span>i</span>} />,
|
|
253
|
+
)
|
|
254
|
+
const tile = container.querySelector<HTMLElement>('span[style*="--ctrl-md"]')
|
|
255
|
+
expect(tile?.style.background).toBe('rgb(var(--surface-raised))')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('sets the value solid and reserves its width', () => {
|
|
259
|
+
render(<StatCard label="Sessions" value="12,480" size="lg" />)
|
|
260
|
+
const value = screen.getByText('12,480')
|
|
261
|
+
expect(value.className).toContain('leading-none')
|
|
262
|
+
expect(value.className).toContain('tabular-nums')
|
|
263
|
+
expect(value.className).toContain('text-[24px]')
|
|
264
|
+
expect(value.style.minWidth).toBe('6ch')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('uses the 18px value at md', () => {
|
|
268
|
+
render(<StatCard label="Sessions" value="12,480" />)
|
|
269
|
+
expect(screen.getByText('12,480').className).toContain('text-[18px]')
|
|
270
|
+
})
|
|
271
|
+
})
|