@lovett/ui 0.0.11 → 0.1.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.
Files changed (141) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5402 -71
  4. package/dist/index.js +21992 -11618
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +208 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sparkline.test.tsx +368 -0
  27. package/src/__tests__/stat-layer.test.tsx +271 -0
  28. package/src/__tests__/stat-strip.test.tsx +175 -0
  29. package/src/__tests__/status.test.tsx +293 -0
  30. package/src/__tests__/tabs.test.tsx +303 -0
  31. package/src/__tests__/token-shape.test.ts +132 -2
  32. package/src/avatar.tsx +352 -0
  33. package/src/bar-chart.tsx +1214 -0
  34. package/src/board.tsx +658 -0
  35. package/src/chart-frame.tsx +960 -0
  36. package/src/chart-legend.tsx +304 -0
  37. package/src/chart-tooltip.tsx +267 -0
  38. package/src/code-block.tsx +62 -8
  39. package/src/delta-chip.tsx +263 -0
  40. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  41. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  42. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  43. package/src/detail/__tests__/field-row.test.tsx +357 -0
  44. package/src/detail/activity-pane.tsx +507 -0
  45. package/src/detail/calendar.tsx +355 -0
  46. package/src/detail/detail-divider.tsx +261 -0
  47. package/src/detail/detail-header.tsx +287 -0
  48. package/src/detail/detail-menu.tsx +254 -0
  49. package/src/detail/detail-surface.tsx +1110 -0
  50. package/src/detail/field-list.tsx +196 -0
  51. package/src/detail/field-row.tsx +1131 -0
  52. package/src/detail/index.ts +58 -0
  53. package/src/detail/segmented-choice.tsx +94 -0
  54. package/src/detail/types.ts +129 -0
  55. package/src/display-popover.tsx +487 -0
  56. package/src/display-store.tsx +242 -0
  57. package/src/donut-chart.tsx +988 -0
  58. package/src/dropdown-menu.tsx +290 -19
  59. package/src/filter-core/EXPORTS.md +85 -0
  60. package/src/filter-core/__tests__/columns.test.ts +159 -0
  61. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  62. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  63. package/src/filter-core/__tests__/operators.test.ts +235 -0
  64. package/src/filter-core/__tests__/state.test.ts +268 -0
  65. package/src/filter-core/__tests__/url.test.ts +350 -0
  66. package/src/filter-core/columns.ts +134 -0
  67. package/src/filter-core/date-utils.ts +38 -0
  68. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  69. package/src/filter-core/faceting.ts +120 -0
  70. package/src/filter-core/filter-fns.ts +335 -0
  71. package/src/filter-core/index.ts +105 -0
  72. package/src/filter-core/operators.ts +433 -0
  73. package/src/filter-core/state.ts +280 -0
  74. package/src/filter-core/types.ts +247 -0
  75. package/src/filter-core/url.ts +261 -0
  76. package/src/filter-dropdown.tsx +12 -0
  77. package/src/filter-menu.tsx +649 -0
  78. package/src/floating-drawer.tsx +19 -1
  79. package/src/gauge-ring.tsx +435 -0
  80. package/src/hue.ts +52 -0
  81. package/src/index.ts +296 -0
  82. package/src/kbd.tsx +27 -4
  83. package/src/lib/chart.ts +866 -0
  84. package/src/lib/focus.ts +43 -1
  85. package/src/lib/layer-stack.ts +30 -3
  86. package/src/lib/remark-underline.ts +443 -0
  87. package/src/lib/series.ts +169 -0
  88. package/src/line-chart.tsx +1176 -0
  89. package/src/markdown.tsx +26 -7
  90. package/src/modal.tsx +42 -18
  91. package/src/progress-ledger.tsx +304 -0
  92. package/src/ranked-bars.tsx +386 -0
  93. package/src/segmented-pill.tsx +32 -9
  94. package/src/sortable.tsx +315 -1
  95. package/src/sparkline.tsx +416 -0
  96. package/src/stat-card.tsx +376 -0
  97. package/src/stat-strip.tsx +327 -0
  98. package/src/status.tsx +215 -0
  99. package/src/styles.css +1570 -0
  100. package/src/tabs.tsx +206 -25
  101. package/src/task-card.tsx +610 -0
  102. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  103. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  104. package/src/thread/__tests__/emoji.test.ts +187 -0
  105. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  106. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  107. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  108. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  109. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  110. package/src/thread/__tests__/relative-time.test.ts +71 -0
  111. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  112. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  113. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  114. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  115. package/src/thread/__tests__/thread.test.tsx +662 -0
  116. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  117. package/src/thread/actions.tsx +196 -0
  118. package/src/thread/attachments.tsx +1071 -0
  119. package/src/thread/comment-body.tsx +148 -0
  120. package/src/thread/comment-tree.ts +182 -0
  121. package/src/thread/comment.tsx +915 -0
  122. package/src/thread/composer-footer.tsx +125 -0
  123. package/src/thread/composer.tsx +319 -0
  124. package/src/thread/emoji.ts +283 -0
  125. package/src/thread/index.ts +153 -0
  126. package/src/thread/link-preview.tsx +341 -0
  127. package/src/thread/markdown-format.ts +155 -0
  128. package/src/thread/markdown-spec.ts +754 -0
  129. package/src/thread/rail.tsx +372 -0
  130. package/src/thread/reactions.tsx +415 -0
  131. package/src/thread/relative-time.tsx +94 -0
  132. package/src/thread/rich-composer-impl.tsx +1601 -0
  133. package/src/thread/rich-composer.tsx +195 -0
  134. package/src/thread/scroll-caret.ts +37 -0
  135. package/src/thread/suggestion-list.tsx +182 -0
  136. package/src/thread/thread.tsx +718 -0
  137. package/src/thread/types.ts +218 -0
  138. package/src/thread/use-attachments.ts +598 -0
  139. package/src/thread/use-now.ts +73 -0
  140. package/src/thread/use-thread.ts +316 -0
  141. package/src/tokens.css +112 -0
@@ -117,10 +117,15 @@ describe('DropdownMenu', () => {
117
117
  expect(items[1]).toHaveAttribute('data-variant', 'destructive')
118
118
  })
119
119
 
120
- it('does not move focus into the menu on open (the trigger keeps it)', async () => {
120
+ // REPLACES 'does not move focus into the menu on open (the trigger keeps
121
+ // it)'. That pinned the defect: focus stayed outside an open menu, so
122
+ // ArrowDown did nothing and Tab walked off into the page behind it.
123
+ it('moves focus onto the first item on open', async () => {
121
124
  render(<Menu />)
122
125
  await userEvent.click(trigger())
123
- expect(document.activeElement).toBe(trigger())
126
+ expect(document.activeElement).toBe(
127
+ screen.getByRole('menuitem', { name: 'Rename' }),
128
+ )
124
129
  })
125
130
 
126
131
  it('spreads className and style, caller style last', async () => {
@@ -381,6 +386,155 @@ describe('DropdownMenu', () => {
381
386
  })
382
387
  })
383
388
 
389
+ // The APG menu-button keyboard model, added 2026-09-06. Before it, the
390
+ // primitive had NO keyboard handling: `ArrowDown|ArrowUp|onKeyDown` did
391
+ // not appear in the file, opening left focus on the trigger, and Tab
392
+ // moved to the next page control with the menu still open.
393
+ describe('keyboard model (APG menu)', () => {
394
+ const items = () => screen.getAllByRole('menuitem')
395
+
396
+ it('ArrowDown on the trigger opens onto the first item', async () => {
397
+ render(<Menu />)
398
+ trigger().focus()
399
+ await userEvent.keyboard('{ArrowDown}')
400
+ expect(screen.getByRole('menu')).toBeInTheDocument()
401
+ expect(document.activeElement).toBe(items()[0])
402
+ })
403
+
404
+ it('ArrowUp on the trigger opens onto the LAST item', async () => {
405
+ render(<Menu />)
406
+ trigger().focus()
407
+ await userEvent.keyboard('{ArrowUp}')
408
+ expect(document.activeElement).toBe(items()[1])
409
+ })
410
+
411
+ it('Up / Down move between items and wrap at both ends', async () => {
412
+ render(<Menu />)
413
+ await userEvent.click(trigger())
414
+ expect(document.activeElement).toBe(items()[0])
415
+ await userEvent.keyboard('{ArrowDown}')
416
+ expect(document.activeElement).toBe(items()[1])
417
+ await userEvent.keyboard('{ArrowDown}')
418
+ expect(document.activeElement).toBe(items()[0]) // wrapped forward
419
+ await userEvent.keyboard('{ArrowUp}')
420
+ expect(document.activeElement).toBe(items()[1]) // wrapped backward
421
+ })
422
+
423
+ it('Home and End jump to the first and last item', async () => {
424
+ render(<Menu />)
425
+ await userEvent.click(trigger())
426
+ await userEvent.keyboard('{End}')
427
+ expect(document.activeElement).toBe(items()[1])
428
+ await userEvent.keyboard('{Home}')
429
+ expect(document.activeElement).toBe(items()[0])
430
+ })
431
+
432
+ it('is ONE tab stop — roving tabindex, never a positive value', async () => {
433
+ render(<Menu />)
434
+ await userEvent.click(trigger())
435
+ expect(items().map((i) => i.tabIndex)).toEqual([0, -1])
436
+ await userEvent.keyboard('{ArrowDown}')
437
+ expect(items().map((i) => i.tabIndex)).toEqual([-1, 0])
438
+ })
439
+
440
+ it('Tab from an item closes the menu and restores the trigger', async () => {
441
+ render(
442
+ <>
443
+ <Menu />
444
+ <button type="button">Next control</button>
445
+ </>,
446
+ )
447
+ await userEvent.click(trigger())
448
+ await userEvent.keyboard('{Tab}')
449
+ expect(screen.queryByRole('menu')).toBeNull()
450
+ expect(document.activeElement).toBe(trigger())
451
+ })
452
+
453
+ it('activating an item restores focus to the trigger', async () => {
454
+ render(<Menu />)
455
+ await userEvent.click(trigger())
456
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
457
+ expect(screen.queryByRole('menu')).toBeNull()
458
+ expect(document.activeElement).toBe(trigger())
459
+ })
460
+
461
+ it('an outside click that lands on another control leaves it focused', async () => {
462
+ render(
463
+ <>
464
+ <Menu />
465
+ <button type="button">Elsewhere</button>
466
+ </>,
467
+ )
468
+ await userEvent.click(trigger())
469
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
470
+ expect(document.activeElement).toBe(
471
+ screen.getByRole('button', { name: 'Elsewhere' }),
472
+ )
473
+ })
474
+
475
+ // The cascade the menu defect caused: Modal captures
476
+ // `document.activeElement` as its restore target, so a dialog opened
477
+ // from a menu item used to capture <body> and restore nothing.
478
+ it('a dialog opened from a menu item restores focus to the trigger', async () => {
479
+ function MenuWithDialog() {
480
+ const [open, setOpen] = useState(false)
481
+ return (
482
+ <>
483
+ <DropdownMenu>
484
+ <DropdownMenuTrigger>Actions</DropdownMenuTrigger>
485
+ <DropdownMenuContent>
486
+ <DropdownMenuItem onClick={() => setOpen(true)}>
487
+ Edit column
488
+ </DropdownMenuItem>
489
+ </DropdownMenuContent>
490
+ </DropdownMenu>
491
+ <Modal isOpen={open} onClose={() => setOpen(false)} title="Edit column">
492
+ <button type="button">Save</button>
493
+ </Modal>
494
+ </>
495
+ )
496
+ }
497
+ render(<MenuWithDialog />)
498
+ await userEvent.click(trigger())
499
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Edit column' }))
500
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
501
+ await userEvent.keyboard('{Escape}')
502
+ expect(screen.queryByRole('dialog')).toBeNull()
503
+ expect(document.activeElement).toBe(trigger())
504
+ })
505
+
506
+ // Menus here legitimately host a small form (the keywords range-filter
507
+ // chip). Those must not be hijacked: no items to rove, arrows belong to
508
+ // the field, and Tab moves between the fields instead of closing.
509
+ describe('a form-shaped menu (no items)', () => {
510
+ const FormMenu = () => (
511
+ <DropdownMenu>
512
+ <DropdownMenuTrigger>Range</DropdownMenuTrigger>
513
+ <DropdownMenuContent>
514
+ <label htmlFor="lo">Min</label>
515
+ <input id="lo" type="number" />
516
+ <label htmlFor="hi">Max</label>
517
+ <input id="hi" type="number" />
518
+ </DropdownMenuContent>
519
+ </DropdownMenu>
520
+ )
521
+
522
+ it('focuses its first field on open', async () => {
523
+ render(<FormMenu />)
524
+ await userEvent.click(screen.getByRole('button', { name: 'Range' }))
525
+ expect(document.activeElement).toBe(screen.getByLabelText('Min'))
526
+ })
527
+
528
+ it('leaves Tab alone so the fields stay reachable', async () => {
529
+ render(<FormMenu />)
530
+ await userEvent.click(screen.getByRole('button', { name: 'Range' }))
531
+ await userEvent.tab()
532
+ expect(screen.getByRole('menu')).toBeInTheDocument()
533
+ expect(document.activeElement).toBe(screen.getByLabelText('Max'))
534
+ })
535
+ })
536
+ })
537
+
384
538
  describe('composed primitives still work', () => {
385
539
  it('DropdownButton opens its items and selects', async () => {
386
540
  const onSelect = vi.fn()
@@ -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
+ })