@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterMenu + FilterChips — the UI over `filter-core`.
|
|
3
|
+
*
|
|
4
|
+
* The contract worth pinning is the part the source got wrong
|
|
5
|
+
* (`docs/design-briefs/tasks-board-header-primitives.md` §5):
|
|
6
|
+
*
|
|
7
|
+
* - facet counts are computed against everything surviving the OTHER
|
|
8
|
+
* columns' filters, so picking one option does not zero the rest of the
|
|
9
|
+
* list and leave it unusable for widening a selection;
|
|
10
|
+
* - a field the UI cannot edit is not OFFERED, rather than offered and dead;
|
|
11
|
+
* - a chip is removable, and Clear appears only when something is filtered.
|
|
12
|
+
*/
|
|
13
|
+
import { useState } from 'react'
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
15
|
+
import { render, screen, within } from '@testing-library/react'
|
|
16
|
+
import userEvent from '@testing-library/user-event'
|
|
17
|
+
import { FilterChipBar, FilterMenu } from '../filter-menu'
|
|
18
|
+
import { defineColumns, type AnyColumnConfig, type FiltersState } from '../filter-core'
|
|
19
|
+
import { mockGeometry, setViewport } from './helpers/geometry'
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
id: string
|
|
23
|
+
lane: string
|
|
24
|
+
priority: string
|
|
25
|
+
title: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ROWS: Row[] = [
|
|
29
|
+
{ id: '1', lane: 'todo', priority: 'high', title: 'Alpha' },
|
|
30
|
+
{ id: '2', lane: 'todo', priority: 'low', title: 'Beta' },
|
|
31
|
+
{ id: '3', lane: 'doing', priority: 'high', title: 'Gamma' },
|
|
32
|
+
{ id: '4', lane: 'done', priority: 'low', title: 'Delta' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const COLUMNS: ReadonlyArray<AnyColumnConfig<Row>> = defineColumns<Row>([
|
|
36
|
+
{
|
|
37
|
+
id: 'lane',
|
|
38
|
+
type: 'option',
|
|
39
|
+
displayName: 'Lane',
|
|
40
|
+
accessor: (row) => row.lane,
|
|
41
|
+
options: [
|
|
42
|
+
{ value: 'todo', label: 'To do' },
|
|
43
|
+
{ value: 'doing', label: 'Doing' },
|
|
44
|
+
{ value: 'done', label: 'Done' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'priority',
|
|
49
|
+
type: 'option',
|
|
50
|
+
displayName: 'Priority',
|
|
51
|
+
accessor: (row) => row.priority,
|
|
52
|
+
options: [
|
|
53
|
+
{ value: 'high', label: 'High' },
|
|
54
|
+
{ value: 'low', label: 'Low' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
// A text column: real for `filterRows`, and deliberately NOT offered in the
|
|
58
|
+
// picker, because there is no text editor in this UI.
|
|
59
|
+
{ id: 'title', type: 'text', displayName: 'Title', accessor: (row) => row.title },
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
function Harness() {
|
|
63
|
+
const [filters, setFilters] = useState<FiltersState>([])
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<FilterMenu
|
|
67
|
+
rows={ROWS}
|
|
68
|
+
columns={COLUMNS}
|
|
69
|
+
filters={filters}
|
|
70
|
+
onFiltersChange={setFilters}
|
|
71
|
+
/>
|
|
72
|
+
<FilterChipBar
|
|
73
|
+
rows={ROWS}
|
|
74
|
+
columns={COLUMNS}
|
|
75
|
+
filters={filters}
|
|
76
|
+
onFiltersChange={setFilters}
|
|
77
|
+
/>
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('FilterMenu', () => {
|
|
83
|
+
let restore: () => void
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
restore = mockGeometry()
|
|
86
|
+
setViewport(1280, 800)
|
|
87
|
+
})
|
|
88
|
+
afterEach(() => restore())
|
|
89
|
+
|
|
90
|
+
it('offers only the fields it can actually edit', async () => {
|
|
91
|
+
const user = userEvent.setup()
|
|
92
|
+
render(<Harness />)
|
|
93
|
+
await user.click(screen.getByRole('button', { name: 'Filter' }))
|
|
94
|
+
|
|
95
|
+
const fields = screen.getByRole('group', { name: 'Filter fields' })
|
|
96
|
+
expect(within(fields).getByRole('button', { name: 'Lane' })).toBeInTheDocument()
|
|
97
|
+
expect(within(fields).getByRole('button', { name: 'Priority' })).toBeInTheDocument()
|
|
98
|
+
// A text column has no editor here, so it is absent — not disabled.
|
|
99
|
+
expect(within(fields).queryByRole('button', { name: 'Title' })).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('shows faceted counts, and picking one does not zero the others', async () => {
|
|
103
|
+
const user = userEvent.setup()
|
|
104
|
+
render(<Harness />)
|
|
105
|
+
await user.click(screen.getByRole('button', { name: 'Filter' }))
|
|
106
|
+
await user.click(screen.getByRole('button', { name: 'Lane' }))
|
|
107
|
+
|
|
108
|
+
const list = screen.getByRole('group', { name: 'Lane' })
|
|
109
|
+
expect(within(list).getByRole('checkbox', { name: 'To do, 2 matching' })).toBeInTheDocument()
|
|
110
|
+
expect(within(list).getByRole('checkbox', { name: 'Doing, 1 matching' })).toBeInTheDocument()
|
|
111
|
+
|
|
112
|
+
await user.click(within(list).getByRole('checkbox', { name: 'To do, 2 matching' }))
|
|
113
|
+
|
|
114
|
+
// THE BUG THAT IS NOT HERE: counted against the fully filtered set, Doing
|
|
115
|
+
// would now read 0 and the list would be useless for widening.
|
|
116
|
+
const after = screen.getByRole('group', { name: 'Lane' })
|
|
117
|
+
expect(within(after).getByRole('checkbox', { name: 'Doing, 1 matching' })).toBeInTheDocument()
|
|
118
|
+
expect(
|
|
119
|
+
within(after).getByRole('checkbox', { name: 'To do, 2 matching', checked: true }),
|
|
120
|
+
).toBeInTheDocument()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('counts the active filters on the trigger', async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
render(<Harness />)
|
|
126
|
+
await user.click(screen.getByRole('button', { name: 'Filter' }))
|
|
127
|
+
await user.click(screen.getByRole('button', { name: 'Lane' }))
|
|
128
|
+
await user.click(
|
|
129
|
+
within(screen.getByRole('group', { name: 'Lane' })).getByRole('checkbox', {
|
|
130
|
+
name: 'To do, 2 matching',
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
await user.keyboard('{Escape}')
|
|
134
|
+
// The trigger is `[icon Filter]` with no count badge: the filter bar below
|
|
135
|
+
// the action bar is where applied filters are reported (action-bar §5).
|
|
136
|
+
expect(screen.getByRole('button', { name: 'Filter' })).toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('FilterChips', () => {
|
|
141
|
+
let restore: () => void
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
restore = mockGeometry()
|
|
144
|
+
setViewport(1280, 800)
|
|
145
|
+
})
|
|
146
|
+
afterEach(() => restore())
|
|
147
|
+
|
|
148
|
+
it('renders nothing at all when nothing is filtered', () => {
|
|
149
|
+
render(<Harness />)
|
|
150
|
+
expect(screen.queryByRole('group', { name: 'Active filters' })).toBeNull()
|
|
151
|
+
expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('shows a removable chip once a value is picked', async () => {
|
|
155
|
+
const user = userEvent.setup()
|
|
156
|
+
render(<Harness />)
|
|
157
|
+
await user.click(screen.getByRole('button', { name: 'Filter' }))
|
|
158
|
+
await user.click(screen.getByRole('button', { name: 'Lane' }))
|
|
159
|
+
await user.click(
|
|
160
|
+
within(screen.getByRole('group', { name: 'Lane' })).getByRole('checkbox', {
|
|
161
|
+
name: 'To do, 2 matching',
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
await user.keyboard('{Escape}')
|
|
165
|
+
|
|
166
|
+
const chips = screen.getByRole('group', { name: 'Active filters' })
|
|
167
|
+
expect(within(chips).getByText('Lane')).toBeInTheDocument()
|
|
168
|
+
expect(within(chips).getByText('To do')).toBeInTheDocument()
|
|
169
|
+
|
|
170
|
+
await user.click(
|
|
171
|
+
within(chips).getByRole('button', { name: 'Remove filter: Lane is To do' }),
|
|
172
|
+
)
|
|
173
|
+
expect(screen.queryByRole('group', { name: 'Active filters' })).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GaugeRing — ADR-146 D5.
|
|
3
|
+
*
|
|
4
|
+
* Two classes of defect are pinned here, and both are invisible to
|
|
5
|
+
* typecheck, lint and build:
|
|
6
|
+
*
|
|
7
|
+
* 1. **A degenerate arc that silently drops the element.** `NaN` or
|
|
8
|
+
* `Infinity` in `stroke-dasharray` does not render wrong — it renders
|
|
9
|
+
* nothing, with every gate green. ADR-146's named cases (empty scale,
|
|
10
|
+
* all-zero value, a single point, a stroke wider than the ring) each get
|
|
11
|
+
* an assertion that the attribute is finite.
|
|
12
|
+
* 2. **Identity carried by colour alone (D10.1).** A banded ring must
|
|
13
|
+
* expose its band as TEXT and in the accessible name, not only as a hue.
|
|
14
|
+
* A refactor that drops the label leaves the ring looking correct and
|
|
15
|
+
* the contract broken, so the label and the `aria-label` are asserted
|
|
16
|
+
* together.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from 'vitest'
|
|
19
|
+
import { render, screen } from '@testing-library/react'
|
|
20
|
+
|
|
21
|
+
import { GaugeRing, type GaugeBand } from '../gauge-ring'
|
|
22
|
+
|
|
23
|
+
const FIT_BANDS: readonly GaugeBand[] = [
|
|
24
|
+
{ from: 0, tone: 'destructive', label: 'Weak' },
|
|
25
|
+
{ from: 40, tone: 'warning', label: 'Fair' },
|
|
26
|
+
{ from: 70, tone: 'success', label: 'Strong' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Every dash length an SVG attribute carries must be a finite number. */
|
|
30
|
+
function dashesAreFinite(container: HTMLElement): boolean {
|
|
31
|
+
const circles = Array.from(container.querySelectorAll('circle'))
|
|
32
|
+
expect(circles.length).toBe(2)
|
|
33
|
+
return circles.every((circle) => {
|
|
34
|
+
const dash = circle.getAttribute('stroke-dasharray') ?? ''
|
|
35
|
+
const r = circle.getAttribute('r') ?? ''
|
|
36
|
+
return (
|
|
37
|
+
Number.isFinite(Number(r)) &&
|
|
38
|
+
dash.split(/\s+/).every((part) => part !== '' && Number.isFinite(Number(part)))
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('GaugeRing', () => {
|
|
44
|
+
it('renders the value, unit and an accessible name', () => {
|
|
45
|
+
render(<GaugeRing value={80} unit="%" />)
|
|
46
|
+
const gauge = screen.getByRole('img')
|
|
47
|
+
expect(gauge).toHaveAccessibleName('80% of 100%')
|
|
48
|
+
expect(gauge).toHaveTextContent('80%')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not announce a degenerate scale as "of 0"', () => {
|
|
52
|
+
render(<GaugeRing value={10} max={Number.POSITIVE_INFINITY} />)
|
|
53
|
+
expect(screen.getByRole('img')).toHaveAccessibleName('10')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reserves the readout width for the widest reading, not the current one', () => {
|
|
57
|
+
const { container } = render(<GaugeRing value={7} max={100} size={88} />)
|
|
58
|
+
const readout = container.querySelector<HTMLElement>('[style*="min-width"]')
|
|
59
|
+
// "100" is three characters even while the gauge reads "7", so the slot
|
|
60
|
+
// does not shrink as the value ticks down.
|
|
61
|
+
expect(readout?.style.minWidth).toBe('3ch')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('bands', () => {
|
|
65
|
+
it('names the matched band in text and in the accessible name', () => {
|
|
66
|
+
render(<GaugeRing value={83} max={100} bands={FIT_BANDS} size={88} />)
|
|
67
|
+
const gauge = screen.getByRole('img')
|
|
68
|
+
expect(gauge).toHaveAccessibleName('83 of 100 — Strong')
|
|
69
|
+
// D10.1: the band is exposed as text, never as hue alone.
|
|
70
|
+
expect(screen.getByText('Strong')).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('picks the band by the last bound at or below the value', () => {
|
|
74
|
+
const { rerender } = render(<GaugeRing value={39} bands={FIT_BANDS} />)
|
|
75
|
+
expect(screen.getByText('Weak')).toBeInTheDocument()
|
|
76
|
+
|
|
77
|
+
rerender(<GaugeRing value={40} bands={FIT_BANDS} />)
|
|
78
|
+
expect(screen.getByText('Fair')).toBeInTheDocument()
|
|
79
|
+
|
|
80
|
+
rerender(<GaugeRing value={70} bands={FIT_BANDS} />)
|
|
81
|
+
expect(screen.getByText('Strong')).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('is order-independent and falls to the lowest band below every bound', () => {
|
|
85
|
+
const shuffled: readonly GaugeBand[] = [FIT_BANDS[2]!, FIT_BANDS[0]!, FIT_BANDS[1]!]
|
|
86
|
+
render(<GaugeRing value={-5} bands={shuffled} />)
|
|
87
|
+
expect(screen.getByText('Weak')).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('lets an explicit caption win over the band label', () => {
|
|
91
|
+
render(<GaugeRing value={83} bands={FIT_BANDS} caption="/ 100" />)
|
|
92
|
+
expect(screen.getByText('/ 100')).toBeInTheDocument()
|
|
93
|
+
expect(screen.queryByText('Strong')).not.toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('arc geometry', () => {
|
|
98
|
+
it('paints a full turn by default and a partial turn when swept', () => {
|
|
99
|
+
const { container, rerender } = render(<GaugeRing value={100} size={88} strokeWidth={8} />)
|
|
100
|
+
const trackDash = () =>
|
|
101
|
+
container.querySelectorAll('circle')[0]?.getAttribute('stroke-dasharray') ?? ''
|
|
102
|
+
|
|
103
|
+
// A full ring leaves no gap.
|
|
104
|
+
expect(trackDash().split(' ')[1]).toBe('0')
|
|
105
|
+
|
|
106
|
+
rerender(<GaugeRing value={100} size={88} strokeWidth={8} sweep={270} />)
|
|
107
|
+
const [dash, gap] = trackDash().split(' ').map(Number)
|
|
108
|
+
// 270° is three quarters of the turn, so the gap is a third of the dash.
|
|
109
|
+
expect(gap! / dash!).toBeCloseTo(1 / 3, 6)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rotates the arc by startAngle from twelve o clock', () => {
|
|
113
|
+
const { container } = render(<GaugeRing value={50} startAngle={135} sweep={270} />)
|
|
114
|
+
expect(container.querySelector('g')?.getAttribute('transform')).toContain('rotate(45')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('puts a 270deg gauge gap at the BOTTOM for startAngle 225, not 135', () => {
|
|
118
|
+
// An <svg> circle starts at 3 o'clock and runs clockwise; the component
|
|
119
|
+
// rotates by (-90 + startAngle). So the arc runs startAngle -> startAngle
|
|
120
|
+
// + sweep and the GAP sits before the start:
|
|
121
|
+
// 225 -> 135, gap 135 -> 225, centred on 180deg = bottom. CORRECT
|
|
122
|
+
// 135 -> 45, gap 45 -> 135, centred on 90deg = right. ROTATED
|
|
123
|
+
// Both render a plausible-looking gauge, so only a browser or this test
|
|
124
|
+
// catches the difference. The docstring quotes 225; keep them together.
|
|
125
|
+
const { container } = render(<GaugeRing value={50} startAngle={225} sweep={270} />)
|
|
126
|
+
const transform = container.querySelector('g')?.getAttribute('transform') ?? ''
|
|
127
|
+
expect(transform).toContain('rotate(135')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('butts the cap at zero so a round cap cannot paint a stray dot', () => {
|
|
131
|
+
const { container } = render(<GaugeRing value={0} />)
|
|
132
|
+
const value = container.querySelectorAll('circle')[1]
|
|
133
|
+
expect(value?.getAttribute('stroke-linecap')).toBe('butt')
|
|
134
|
+
expect(value?.getAttribute('stroke-dasharray')?.split(' ')[0]).toBe('0')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('rounds the cap once the arc has length', () => {
|
|
138
|
+
const { container } = render(<GaugeRing value={1} />)
|
|
139
|
+
expect(container.querySelectorAll('circle')[1]?.getAttribute('stroke-linecap')).toBe('round')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('degenerate input never drops the element', () => {
|
|
144
|
+
it.each<[string, React.ComponentProps<typeof GaugeRing>]>([
|
|
145
|
+
['all-zero', { value: 0, max: 0 }],
|
|
146
|
+
['negative value', { value: -40 }],
|
|
147
|
+
['over the top of scale', { value: 400 }],
|
|
148
|
+
['non-finite value', { value: Number.NaN }],
|
|
149
|
+
['non-finite max', { value: 10, max: Number.POSITIVE_INFINITY }],
|
|
150
|
+
['stroke wider than the ring', { value: 50, size: 10, strokeWidth: 40 }],
|
|
151
|
+
['zero size', { value: 50, size: 0 }],
|
|
152
|
+
['non-finite size', { value: 50, size: Number.NaN }],
|
|
153
|
+
['non-finite stroke', { value: 50, strokeWidth: Number.NaN }],
|
|
154
|
+
['negative size', { value: 50, size: -30 }],
|
|
155
|
+
['zero sweep', { value: 50, sweep: 0 }],
|
|
156
|
+
['non-finite sweep', { value: 50, sweep: Number.NaN }],
|
|
157
|
+
['non-finite start angle', { value: 50, startAngle: Number.NaN }],
|
|
158
|
+
])('%s', (_name, props) => {
|
|
159
|
+
const { container } = render(<GaugeRing {...props} />)
|
|
160
|
+
expect(dashesAreFinite(container)).toBe(true)
|
|
161
|
+
expect(screen.getByRole('img')).toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('CompletionRing parity', () => {
|
|
166
|
+
it('hides the readout on showLabel={false}', () => {
|
|
167
|
+
render(<GaugeRing value={80} unit="%" showLabel={false} />)
|
|
168
|
+
// Not `toHaveTextContent('')`, which passes on any content.
|
|
169
|
+
expect(screen.getByRole('img').textContent).toBe('')
|
|
170
|
+
// The accessible name survives — the value is still announced.
|
|
171
|
+
expect(screen.getByRole('img')).toHaveAccessibleName('80% of 100%')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('lets an explicit label node replace the readout entirely', () => {
|
|
175
|
+
render(<GaugeRing value={80} unit="%" label={<span>done</span>} />)
|
|
176
|
+
expect(screen.getByText('done')).toBeInTheDocument()
|
|
177
|
+
expect(screen.getByRole('img')).not.toHaveTextContent('80')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('defaults the track to a structural token, never a surface one', () => {
|
|
181
|
+
// MEASURED in a browser: the old `rgb(var(--muted))` default resolved
|
|
182
|
+
// through `--muted: var(--surface-inset)` to the recessed-well surface,
|
|
183
|
+
// so a gauge sitting in a well stroked its track in exactly the colour
|
|
184
|
+
// behind it — 242,242,244 on 242,242,244 in light, 45,45,48 on 45,45,48
|
|
185
|
+
// in dark, 1.00:1 in BOTH themes, i.e. a 0% gauge with no ring at all.
|
|
186
|
+
// jsdom can only see the attribute string; the relationship between
|
|
187
|
+
// that token and the surfaces is pinned in `token-shape.test.ts`.
|
|
188
|
+
const { container } = render(<GaugeRing value={0} />)
|
|
189
|
+
const track = container.querySelectorAll('circle')[0]
|
|
190
|
+
expect(track?.getAttribute('stroke')).toBe('rgb(var(--border-strong))')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('accepts whole token strings for the track and fill', () => {
|
|
194
|
+
const { container } = render(
|
|
195
|
+
<GaugeRing value={50} trackColor="rgb(var(--border))" fillColor="rgb(var(--series-1))" />,
|
|
196
|
+
)
|
|
197
|
+
const circles = container.querySelectorAll('circle')
|
|
198
|
+
expect(circles[0]?.getAttribute('stroke')).toBe('rgb(var(--border))')
|
|
199
|
+
expect(circles[1]?.getAttribute('stroke')).toBe('rgb(var(--series-1))')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('sizes the wrapper from the size prop via inline style, not a class', () => {
|
|
203
|
+
const { container } = render(<GaugeRing value={50} size={88} />)
|
|
204
|
+
const wrapper = container.firstElementChild as HTMLElement
|
|
205
|
+
expect(wrapper.style.width).toBe('88px')
|
|
206
|
+
expect(wrapper.style.height).toBe('88px')
|
|
207
|
+
expect(wrapper.className).not.toMatch(/\[88px\]/)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('motion', () => {
|
|
212
|
+
it('tweens the arc under motion-safe with ease-out, never spring', () => {
|
|
213
|
+
const { container } = render(<GaugeRing value={50} />)
|
|
214
|
+
const value = container.querySelectorAll('circle')[1] as SVGCircleElement
|
|
215
|
+
expect(value.getAttribute('class')).toContain(
|
|
216
|
+
'motion-safe:transition-[stroke-dasharray]',
|
|
217
|
+
)
|
|
218
|
+
expect(value.style.transitionTimingFunction).toBe('var(--ease-out)')
|
|
219
|
+
expect(value.style.transitionDuration).toBe('var(--dur-base)')
|
|
220
|
+
expect(value.style.transitionTimingFunction).not.toContain('spring')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('kills the transition outright under prefers-reduced-motion', () => {
|
|
224
|
+
const { container } = render(<GaugeRing value={50} />)
|
|
225
|
+
const value = container.querySelectorAll('circle')[1] as SVGCircleElement
|
|
226
|
+
// The duration and easing are INLINE, so they apply in every media
|
|
227
|
+
// state. `transition-property` initially computes to `all`, so without
|
|
228
|
+
// this class a reduce user would tween every animatable property --
|
|
229
|
+
// more motion than a no-preference user, the exact inverse of D9.
|
|
230
|
+
expect(value.getAttribute('class')).toContain('motion-reduce:transition-none')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|