@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5402 -71
- package/dist/index.js +21992 -11618
- 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 +208 -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__/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 +186 -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 +507 -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 +242 -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 +296 -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 +315 -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 +235 -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 +662 -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 +915 -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 +218 -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
|
@@ -117,10 +117,15 @@ describe('DropdownMenu', () => {
|
|
|
117
117
|
expect(items[1]).toHaveAttribute('data-variant', 'destructive')
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
|
|
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(
|
|
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
|
+
})
|