@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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DetailHeader and DetailMenu — the slots, and the rule that governs them.
|
|
3
|
+
*
|
|
4
|
+
* §7 names three controls with no backing system, and the owner's standing
|
|
5
|
+
* rule is that a rendered control does what it says. Both components express
|
|
6
|
+
* that the same way: a slot the host does not fill is not drawn. These tests
|
|
7
|
+
* assert the ABSENCE, because a dead control is exactly the kind of regression
|
|
8
|
+
* that no other check catches.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { render, screen } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
import { DetailHeader } from '../detail-header'
|
|
14
|
+
import { DetailMenu } from '../detail-menu'
|
|
15
|
+
|
|
16
|
+
const noop = () => {}
|
|
17
|
+
const NOW = new Date(2026, 8, 15, 12, 0, 0).getTime()
|
|
18
|
+
|
|
19
|
+
describe('DetailHeader', () => {
|
|
20
|
+
it('draws nothing for a slot the host did not fill', () => {
|
|
21
|
+
render(<DetailHeader title="Slice B" onClose={noop} />)
|
|
22
|
+
expect(screen.queryByRole('button', { name: /complete/i })).toBeNull()
|
|
23
|
+
expect(screen.queryByRole('button', { name: /favourite/i })).toBeNull()
|
|
24
|
+
expect(screen.queryByRole('button', { name: /Previous item/ })).toBeNull()
|
|
25
|
+
expect(document.querySelector('[data-slot="detail-reminder"]')).toBeNull()
|
|
26
|
+
expect(document.querySelector('[data-slot="detail-menu-slot"]')).toBeNull()
|
|
27
|
+
// A title with no change handler is a heading, not a field.
|
|
28
|
+
expect(screen.getByRole('heading', { name: 'Slice B' })).toBeInTheDocument()
|
|
29
|
+
expect(screen.queryByRole('textbox')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('reflects completion in the label, not only in colour', async () => {
|
|
33
|
+
const onCompleteChange = vi.fn()
|
|
34
|
+
const view = render(
|
|
35
|
+
<DetailHeader
|
|
36
|
+
title="Slice B"
|
|
37
|
+
onClose={noop}
|
|
38
|
+
complete={false}
|
|
39
|
+
onCompleteChange={onCompleteChange}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
const toggle = screen.getByRole('button', { name: 'Mark complete' })
|
|
43
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
44
|
+
await userEvent.click(toggle)
|
|
45
|
+
expect(onCompleteChange).toHaveBeenCalledWith(true)
|
|
46
|
+
|
|
47
|
+
view.rerender(
|
|
48
|
+
<DetailHeader title="Slice B" onClose={noop} complete onCompleteChange={onCompleteChange} />,
|
|
49
|
+
)
|
|
50
|
+
expect(screen.getByRole('button', { name: 'Completed' })).toHaveAttribute(
|
|
51
|
+
'aria-pressed',
|
|
52
|
+
'true',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('disables the step at the end it has no handler for', () => {
|
|
57
|
+
render(<DetailHeader title="Slice B" onClose={noop} onNext={noop} />)
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Previous item' })).toBeDisabled()
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Next item' })).toBeEnabled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('edits the title in place, committing on Enter', async () => {
|
|
63
|
+
const onTitleChange = vi.fn()
|
|
64
|
+
const onTitleCommit = vi.fn()
|
|
65
|
+
render(
|
|
66
|
+
<DetailHeader
|
|
67
|
+
title="Slice B"
|
|
68
|
+
onClose={noop}
|
|
69
|
+
onTitleChange={onTitleChange}
|
|
70
|
+
onTitleCommit={onTitleCommit}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
const field = screen.getByRole('textbox', { name: 'Item title' })
|
|
74
|
+
await userEvent.type(field, '!{Enter}')
|
|
75
|
+
expect(onTitleChange).toHaveBeenCalledWith('Slice B!')
|
|
76
|
+
expect(onTitleCommit).toHaveBeenCalledTimes(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reads out when the item was updated', () => {
|
|
80
|
+
render(<DetailHeader title="Slice B" onClose={noop} updatedAt={NOW - 3_600_000} now={NOW} />)
|
|
81
|
+
expect(document.querySelector('[data-slot="detail-updated"]')).toHaveTextContent('Updated 1h')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('DetailMenu', () => {
|
|
86
|
+
it('renders only the actions the host supplied, in the brief order', async () => {
|
|
87
|
+
render(
|
|
88
|
+
<DetailMenu
|
|
89
|
+
mode="full"
|
|
90
|
+
onModeChange={noop}
|
|
91
|
+
actions={{ move: { onSelect: noop }, print: { onSelect: noop }, delete: { onSelect: noop } }}
|
|
92
|
+
/>,
|
|
93
|
+
)
|
|
94
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
95
|
+
const items = screen.getAllByRole('menuitem').map((item) => item.dataset['action'])
|
|
96
|
+
expect(items).toEqual(['move', 'print', 'delete'])
|
|
97
|
+
// Repeat has no scheduling engine behind it (§7), so it is not drawn.
|
|
98
|
+
expect(screen.queryByRole('menuitem', { name: /Repeat/ })).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('offers the three modes, with the current one pressed', async () => {
|
|
102
|
+
const onModeChange = vi.fn()
|
|
103
|
+
render(<DetailMenu mode="panel" onModeChange={onModeChange} />)
|
|
104
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
105
|
+
expect(screen.getByRole('button', { name: 'Open in side panel' })).toHaveAttribute(
|
|
106
|
+
'aria-pressed',
|
|
107
|
+
'true',
|
|
108
|
+
)
|
|
109
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open full screen' }))
|
|
110
|
+
expect(onModeChange).toHaveBeenCalledWith('full')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('asks twice before deleting', async () => {
|
|
114
|
+
const onDelete = vi.fn()
|
|
115
|
+
render(
|
|
116
|
+
<DetailMenu mode="full" onModeChange={noop} actions={{ delete: { onSelect: onDelete } }} />,
|
|
117
|
+
)
|
|
118
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
119
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
|
120
|
+
expect(onDelete).not.toHaveBeenCalled()
|
|
121
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Confirm delete' }))
|
|
122
|
+
expect(onDelete).toHaveBeenCalledTimes(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('offers the record id to the clipboard', async () => {
|
|
126
|
+
const onCopy = vi.fn()
|
|
127
|
+
render(
|
|
128
|
+
<DetailMenu mode="full" onModeChange={noop} recordId="itm_123" onCopyRecordId={onCopy} />,
|
|
129
|
+
)
|
|
130
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
131
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Copy record id itm_123' }))
|
|
132
|
+
expect(onCopy).toHaveBeenCalledWith('itm_123')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DetailSurface — three modes, one DOM.
|
|
3
|
+
*
|
|
4
|
+
* The tests that matter are not "does it render": they are the two claims the
|
|
5
|
+
* brief makes that a different implementation would silently break — that the
|
|
6
|
+
* orientation follows the mode, and that switching modes keeps the open item,
|
|
7
|
+
* the scroll, and an in-progress edit. The last one is asserted by NODE
|
|
8
|
+
* IDENTITY, because that is what actually guarantees it: if the panes are the
|
|
9
|
+
* same DOM elements before and after, nothing below them was unmounted and
|
|
10
|
+
* there is no list of state to remember.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
14
|
+
import userEvent from '@testing-library/user-event'
|
|
15
|
+
import { DetailSurface } from '../detail-surface'
|
|
16
|
+
import { FieldRow } from '../field-row'
|
|
17
|
+
import type { DetailMode } from '../types'
|
|
18
|
+
|
|
19
|
+
const noop = () => {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A `ResizeObserver` that reports one fixed box, immediately.
|
|
23
|
+
*
|
|
24
|
+
* The package's setup stubs the observer as a no-op, which is right for every
|
|
25
|
+
* other test — but the bounds this file asserts EXIST because the container is
|
|
26
|
+
* measured, so a test that never measures would pass against the very bug it
|
|
27
|
+
* is here to catch. Returns the restore function.
|
|
28
|
+
*/
|
|
29
|
+
function measureAt(width: number, height: number): () => void {
|
|
30
|
+
const original = globalThis.ResizeObserver
|
|
31
|
+
class FixedResizeObserver {
|
|
32
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
33
|
+
observe(target: Element): void {
|
|
34
|
+
const entry = {
|
|
35
|
+
target,
|
|
36
|
+
contentRect: { width, height } as DOMRectReadOnly,
|
|
37
|
+
} as ResizeObserverEntry
|
|
38
|
+
this.callback([entry], this as unknown as ResizeObserver)
|
|
39
|
+
}
|
|
40
|
+
unobserve(): void {}
|
|
41
|
+
disconnect(): void {}
|
|
42
|
+
}
|
|
43
|
+
globalThis.ResizeObserver = FixedResizeObserver as unknown as typeof ResizeObserver
|
|
44
|
+
return () => {
|
|
45
|
+
globalThis.ResizeObserver = original
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The same fixed box, but the test can MOVE it afterwards.
|
|
51
|
+
*
|
|
52
|
+
* `measureAt` answers once, at `observe`, which is all a first-layout
|
|
53
|
+
* assertion needs. The claim that the clamps RE-DERIVE needs a second reading:
|
|
54
|
+
* every live observer is re-notified with the new box, exactly as the browser
|
|
55
|
+
* would on a resize. Returns a setter and a restore function.
|
|
56
|
+
*/
|
|
57
|
+
function measurable(width: number, height: number) {
|
|
58
|
+
const original = globalThis.ResizeObserver
|
|
59
|
+
let box = { width, height }
|
|
60
|
+
const live = new Set<{ cb: ResizeObserverCallback; targets: Set<Element> }>()
|
|
61
|
+
const emit = (record: { cb: ResizeObserverCallback; targets: Set<Element> }) => {
|
|
62
|
+
for (const target of record.targets) {
|
|
63
|
+
const entry = {
|
|
64
|
+
target,
|
|
65
|
+
contentRect: { width: box.width, height: box.height } as DOMRectReadOnly,
|
|
66
|
+
} as ResizeObserverEntry
|
|
67
|
+
record.cb([entry], null as unknown as ResizeObserver)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
class LiveResizeObserver {
|
|
71
|
+
private readonly record: { cb: ResizeObserverCallback; targets: Set<Element> }
|
|
72
|
+
constructor(callback: ResizeObserverCallback) {
|
|
73
|
+
this.record = { cb: callback, targets: new Set<Element>() }
|
|
74
|
+
live.add(this.record)
|
|
75
|
+
}
|
|
76
|
+
observe(target: Element): void {
|
|
77
|
+
this.record.targets.add(target)
|
|
78
|
+
emit(this.record)
|
|
79
|
+
}
|
|
80
|
+
unobserve(target: Element): void {
|
|
81
|
+
this.record.targets.delete(target)
|
|
82
|
+
}
|
|
83
|
+
disconnect(): void {
|
|
84
|
+
live.delete(this.record)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
globalThis.ResizeObserver = LiveResizeObserver as unknown as typeof ResizeObserver
|
|
88
|
+
return {
|
|
89
|
+
resize(next: { width: number; height: number }) {
|
|
90
|
+
box = next
|
|
91
|
+
for (const record of live) emit(record)
|
|
92
|
+
},
|
|
93
|
+
restore() {
|
|
94
|
+
globalThis.ResizeObserver = original
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The inner seam. Named, because `panel` mode now has two separators. */
|
|
100
|
+
function innerDivider(): HTMLElement {
|
|
101
|
+
return screen.getByRole('separator', { name: 'Resize the activity pane' })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The panel's own leading edge. Only exists in `panel` mode. */
|
|
105
|
+
function edgeHandle(): HTMLElement {
|
|
106
|
+
return screen.getByRole('separator', { name: 'Resize the panel' })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function panelOf(): HTMLElement {
|
|
110
|
+
const node = document.querySelector<HTMLElement>('[data-slot="detail-panel"]')
|
|
111
|
+
if (node === null) throw new Error('no detail panel')
|
|
112
|
+
return node
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderSurface(mode: DetailMode, open = true) {
|
|
116
|
+
return render(
|
|
117
|
+
<DetailSurface
|
|
118
|
+
open={open}
|
|
119
|
+
mode={mode}
|
|
120
|
+
onClose={noop}
|
|
121
|
+
ariaLabel="Tasks lens slice B"
|
|
122
|
+
aside={<div>activity</div>}
|
|
123
|
+
>
|
|
124
|
+
<div>fields</div>
|
|
125
|
+
</DetailSurface>,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('DetailSurface', () => {
|
|
130
|
+
it('names the dialog and stays mounted when closed', () => {
|
|
131
|
+
renderSurface('full', false)
|
|
132
|
+
const panel = panelOf()
|
|
133
|
+
expect(panel).toHaveAttribute('inert')
|
|
134
|
+
expect(panel).toHaveAttribute('aria-label', 'Tasks lens slice B')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('renders each of the three modes', () => {
|
|
138
|
+
for (const mode of ['full', 'modal', 'panel'] as const) {
|
|
139
|
+
const view = renderSurface(mode)
|
|
140
|
+
expect(panelOf()).toHaveAttribute('data-mode', mode)
|
|
141
|
+
view.unmount()
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('is modal in full and modal, and not in panel', () => {
|
|
146
|
+
const view = renderSurface('full')
|
|
147
|
+
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
|
148
|
+
view.unmount()
|
|
149
|
+
|
|
150
|
+
renderSurface('panel')
|
|
151
|
+
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('puts activity to the side in full and modal, below in panel', () => {
|
|
155
|
+
const view = render(
|
|
156
|
+
<DetailSurface open mode="full" onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
157
|
+
<div>fields</div>
|
|
158
|
+
</DetailSurface>,
|
|
159
|
+
)
|
|
160
|
+
expect(document.querySelector('[data-slot="detail-split"]')).toHaveAttribute(
|
|
161
|
+
'data-orientation',
|
|
162
|
+
'side',
|
|
163
|
+
)
|
|
164
|
+
expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical')
|
|
165
|
+
|
|
166
|
+
view.rerender(
|
|
167
|
+
<DetailSurface open mode="panel" onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
168
|
+
<div>fields</div>
|
|
169
|
+
</DetailSurface>,
|
|
170
|
+
)
|
|
171
|
+
expect(document.querySelector('[data-slot="detail-split"]')).toHaveAttribute(
|
|
172
|
+
'data-orientation',
|
|
173
|
+
'bottom',
|
|
174
|
+
)
|
|
175
|
+
expect(innerDivider()).toHaveAttribute('aria-orientation', 'horizontal')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('renders no divider when there is no activity pane', () => {
|
|
179
|
+
render(
|
|
180
|
+
<DetailSurface open mode="full" onClose={noop} ariaLabel="Item">
|
|
181
|
+
<div>fields</div>
|
|
182
|
+
</DetailSurface>,
|
|
183
|
+
)
|
|
184
|
+
expect(screen.queryByRole('separator')).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('moves focus into the surface on open', () => {
|
|
188
|
+
renderSurface('full')
|
|
189
|
+
expect(document.activeElement).toBe(panelOf())
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('closes on Escape', async () => {
|
|
193
|
+
const onClose = vi.fn()
|
|
194
|
+
render(
|
|
195
|
+
<DetailSurface open mode="full" onClose={onClose} ariaLabel="Item">
|
|
196
|
+
<div>fields</div>
|
|
197
|
+
</DetailSurface>,
|
|
198
|
+
)
|
|
199
|
+
await userEvent.keyboard('{Escape}')
|
|
200
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('keeps the panes — and an in-progress edit — across a mode switch', async () => {
|
|
204
|
+
// The mode changes the way it changes in the product: the host re-renders
|
|
205
|
+
// with a new `mode`. It is deliberately NOT driven by clicking a button in
|
|
206
|
+
// this test, because clicking anything outside the field blurs it, and
|
|
207
|
+
// blur COMMITS (§4) — that path would prove the commit rule, not the
|
|
208
|
+
// structural claim this test exists for.
|
|
209
|
+
const surface = (mode: DetailMode) => (
|
|
210
|
+
<DetailSurface open mode={mode} onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
211
|
+
<FieldRow.Text label="Next steps" value="" onCommit={noop} />
|
|
212
|
+
</DetailSurface>
|
|
213
|
+
)
|
|
214
|
+
const view = render(surface('full'))
|
|
215
|
+
|
|
216
|
+
const fieldsBefore = document.querySelector('[data-slot="detail-fields"]')
|
|
217
|
+
await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
|
|
218
|
+
const editor = screen.getByRole('textbox', { name: 'Next steps' })
|
|
219
|
+
await userEvent.type(editor, 'half a sen')
|
|
220
|
+
|
|
221
|
+
view.rerender(surface('panel'))
|
|
222
|
+
|
|
223
|
+
// The same DOM nodes, so nothing below them was reconciled away.
|
|
224
|
+
expect(document.querySelector('[data-slot="detail-fields"]')).toBe(fieldsBefore)
|
|
225
|
+
expect(screen.getByRole('textbox', { name: 'Next steps' })).toBe(editor)
|
|
226
|
+
expect(editor).toHaveValue('half a sen')
|
|
227
|
+
expect(panelOf()).toHaveAttribute('data-mode', 'panel')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('never lets the activity pane squeeze the fields below their floor', () => {
|
|
231
|
+
// The defect, as a number. A `modal` panel is at most 840px wide; the
|
|
232
|
+
// aside's own ceiling is 720, which left the fields ~116px and wrapped
|
|
233
|
+
// every value to about four characters. The ceiling the divider reports
|
|
234
|
+
// must be the CONTAINER's answer — 840 − 288 (the fields floor) − 4 (the
|
|
235
|
+
// divider) = 548 — not the constant.
|
|
236
|
+
const restore = measureAt(840, 600)
|
|
237
|
+
try {
|
|
238
|
+
render(
|
|
239
|
+
<DetailSurface open mode="modal" onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
240
|
+
<div>fields</div>
|
|
241
|
+
</DetailSurface>,
|
|
242
|
+
)
|
|
243
|
+
expect(screen.getByRole('separator')).toHaveAttribute('aria-valuemax', '548')
|
|
244
|
+
} finally {
|
|
245
|
+
restore()
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('gives the fields their floor even when there is not room for both', () => {
|
|
250
|
+
// 320px of container cannot hold 288 of fields AND 280 of aside. The
|
|
251
|
+
// fields win and the aside takes what is left — and the separator does not
|
|
252
|
+
// report a minimum above its own maximum, which is a range that does not
|
|
253
|
+
// exist.
|
|
254
|
+
const restore = measureAt(320, 600)
|
|
255
|
+
try {
|
|
256
|
+
render(
|
|
257
|
+
<DetailSurface open mode="full" onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
258
|
+
<div>fields</div>
|
|
259
|
+
</DetailSurface>,
|
|
260
|
+
)
|
|
261
|
+
const divider = screen.getByRole('separator')
|
|
262
|
+
expect(divider).toHaveAttribute('aria-valuemax', '28')
|
|
263
|
+
expect(divider).toHaveAttribute('aria-valuemin', '28')
|
|
264
|
+
expect(divider).toHaveAttribute('aria-valuenow', '28')
|
|
265
|
+
} finally {
|
|
266
|
+
restore()
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('collapses and restores a pane without unmounting either', async () => {
|
|
271
|
+
render(
|
|
272
|
+
<DetailSurface open mode="full" onClose={noop} ariaLabel="Item" aside={<div>activity</div>}>
|
|
273
|
+
<FieldRow.Text label="Next steps" value="" onCommit={noop} />
|
|
274
|
+
</DetailSurface>,
|
|
275
|
+
)
|
|
276
|
+
const fieldsBefore = document.querySelector('[data-slot="detail-fields"]')
|
|
277
|
+
const asideBefore = document.querySelector('[data-slot="detail-activity"]')
|
|
278
|
+
|
|
279
|
+
await userEvent.click(
|
|
280
|
+
screen.getByRole('button', { name: 'Collapse the activity pane' }),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
// The pane is still the same node — nothing was reconciled away, so its
|
|
284
|
+
// scroll, its drafts and its active tab are still there.
|
|
285
|
+
expect(document.querySelector('[data-slot="detail-activity"]')).toBe(asideBefore)
|
|
286
|
+
expect(document.querySelector('[data-slot="detail-fields"]')).toBe(fieldsBefore)
|
|
287
|
+
expect(
|
|
288
|
+
document.querySelector('[data-slot="detail-activity-clip"]'),
|
|
289
|
+
).toHaveAttribute('inert')
|
|
290
|
+
|
|
291
|
+
// And there is a way back.
|
|
292
|
+
await userEvent.click(screen.getByRole('button', { name: 'Show the activity pane' }))
|
|
293
|
+
expect(
|
|
294
|
+
document.querySelector('[data-slot="detail-activity-clip"]'),
|
|
295
|
+
).not.toHaveAttribute('inert')
|
|
296
|
+
expect(document.querySelector('[data-slot="detail-activity"]')).toBe(asideBefore)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('reports the collapse per orientation', async () => {
|
|
300
|
+
const onCollapsedChange = vi.fn()
|
|
301
|
+
const view = render(
|
|
302
|
+
<DetailSurface
|
|
303
|
+
open
|
|
304
|
+
mode="full"
|
|
305
|
+
onClose={noop}
|
|
306
|
+
ariaLabel="Item"
|
|
307
|
+
aside={<div>activity</div>}
|
|
308
|
+
onCollapsedChange={onCollapsedChange}
|
|
309
|
+
>
|
|
310
|
+
<div>fields</div>
|
|
311
|
+
</DetailSurface>,
|
|
312
|
+
)
|
|
313
|
+
await userEvent.click(screen.getByRole('button', { name: 'Collapse the fields pane' }))
|
|
314
|
+
expect(onCollapsedChange).toHaveBeenLastCalledWith('fields', 'side')
|
|
315
|
+
|
|
316
|
+
view.rerender(
|
|
317
|
+
<DetailSurface
|
|
318
|
+
open
|
|
319
|
+
mode="panel"
|
|
320
|
+
onClose={noop}
|
|
321
|
+
ariaLabel="Item"
|
|
322
|
+
aside={<div>activity</div>}
|
|
323
|
+
onCollapsedChange={onCollapsedChange}
|
|
324
|
+
>
|
|
325
|
+
<div>fields</div>
|
|
326
|
+
</DetailSurface>,
|
|
327
|
+
)
|
|
328
|
+
// The side panel is a DIFFERENT split, so it opens uncollapsed and the
|
|
329
|
+
// divider is back.
|
|
330
|
+
await userEvent.click(screen.getByRole('button', { name: 'Collapse the activity pane' }))
|
|
331
|
+
expect(onCollapsedChange).toHaveBeenLastCalledWith('aside', 'bottom')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('toggles collapse on a double-click of the seam', () => {
|
|
335
|
+
const onCollapsedChange = vi.fn()
|
|
336
|
+
render(
|
|
337
|
+
<DetailSurface
|
|
338
|
+
open
|
|
339
|
+
mode="full"
|
|
340
|
+
onClose={noop}
|
|
341
|
+
ariaLabel="Item"
|
|
342
|
+
aside={<div>activity</div>}
|
|
343
|
+
onCollapsedChange={onCollapsedChange}
|
|
344
|
+
>
|
|
345
|
+
<div>fields</div>
|
|
346
|
+
</DetailSurface>,
|
|
347
|
+
)
|
|
348
|
+
// `fireEvent`, not `userEvent`: a real double-click also fires pointer
|
|
349
|
+
// events, and jsdom ships no `setPointerCapture`, so the handle's drag
|
|
350
|
+
// path throws before the gesture this test is about ever runs.
|
|
351
|
+
fireEvent.doubleClick(screen.getByRole('separator'))
|
|
352
|
+
expect(onCollapsedChange).toHaveBeenCalledWith('aside', 'side')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('gives the panel an edge handle, and only the panel', () => {
|
|
356
|
+
const view = renderSurface('panel')
|
|
357
|
+
expect(edgeHandle()).toHaveAttribute('aria-orientation', 'vertical')
|
|
358
|
+
view.unmount()
|
|
359
|
+
|
|
360
|
+
for (const mode of ['full', 'modal'] as const) {
|
|
361
|
+
const centred = renderSurface(mode)
|
|
362
|
+
expect(
|
|
363
|
+
screen.queryByRole('separator', { name: 'Resize the panel' }),
|
|
364
|
+
).toBeNull()
|
|
365
|
+
centred.unmount()
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it("derives the panel's ceiling from the viewport, not a constant", () => {
|
|
370
|
+
// The defect this replaces, as a number: a hardcoded 560px side panel plus
|
|
371
|
+
// this app's 308px rail leaves a 1024px screen 144px of board — half a
|
|
372
|
+
// lane. The ceiling has to be the measured wrapper less what the host
|
|
373
|
+
// reserves: 1000 − 621 = 379, and the panel's own 400px floor is pulled
|
|
374
|
+
// down to it rather than reporting a range that does not exist.
|
|
375
|
+
const restore = measureAt(1000, 800)
|
|
376
|
+
try {
|
|
377
|
+
render(
|
|
378
|
+
<DetailSurface open mode="panel" onClose={noop} ariaLabel="Item" panelReserve={621}>
|
|
379
|
+
<div>fields</div>
|
|
380
|
+
</DetailSurface>,
|
|
381
|
+
)
|
|
382
|
+
const edge = edgeHandle()
|
|
383
|
+
expect(edge).toHaveAttribute('aria-valuemax', '379')
|
|
384
|
+
expect(edge).toHaveAttribute('aria-valuemin', '379')
|
|
385
|
+
expect(edge).toHaveAttribute('aria-valuenow', '379')
|
|
386
|
+
} finally {
|
|
387
|
+
restore()
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it("keeps the panel's own content floor when the viewport can afford it", () => {
|
|
392
|
+
const restore = measureAt(1488, 800)
|
|
393
|
+
try {
|
|
394
|
+
render(
|
|
395
|
+
<DetailSurface open mode="panel" onClose={noop} ariaLabel="Item" panelReserve={621}>
|
|
396
|
+
<div>fields</div>
|
|
397
|
+
</DetailSurface>,
|
|
398
|
+
)
|
|
399
|
+
const edge = edgeHandle()
|
|
400
|
+
expect(edge).toHaveAttribute('aria-valuemin', '400')
|
|
401
|
+
expect(edge).toHaveAttribute('aria-valuemax', '867')
|
|
402
|
+
} finally {
|
|
403
|
+
restore()
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('resizes the panel from the keyboard, and Home puts it back', async () => {
|
|
408
|
+
const onCommit = vi.fn()
|
|
409
|
+
const restore = measureAt(1488, 800)
|
|
410
|
+
try {
|
|
411
|
+
render(
|
|
412
|
+
<DetailSurface
|
|
413
|
+
open
|
|
414
|
+
mode="panel"
|
|
415
|
+
onClose={noop}
|
|
416
|
+
ariaLabel="Item"
|
|
417
|
+
panelReserve={621}
|
|
418
|
+
panelSize={560}
|
|
419
|
+
onPanelSizeChange={noop}
|
|
420
|
+
onPanelSizeCommit={onCommit}
|
|
421
|
+
>
|
|
422
|
+
<div>fields</div>
|
|
423
|
+
</DetailSurface>,
|
|
424
|
+
)
|
|
425
|
+
const edge = edgeHandle()
|
|
426
|
+
edge.focus()
|
|
427
|
+
// Dragging the leading edge toward the inline start GROWS the panel, and
|
|
428
|
+
// the arrow follows the gesture rather than the number.
|
|
429
|
+
await userEvent.keyboard('{ArrowLeft}')
|
|
430
|
+
expect(onCommit).toHaveBeenLastCalledWith(576)
|
|
431
|
+
await userEvent.keyboard('{Home}')
|
|
432
|
+
expect(onCommit).toHaveBeenLastCalledWith(560)
|
|
433
|
+
} finally {
|
|
434
|
+
restore()
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('resets the panel to its default on a double-click of the edge', () => {
|
|
439
|
+
const onCommit = vi.fn()
|
|
440
|
+
const restore = measureAt(1488, 800)
|
|
441
|
+
try {
|
|
442
|
+
render(
|
|
443
|
+
<DetailSurface
|
|
444
|
+
open
|
|
445
|
+
mode="panel"
|
|
446
|
+
onClose={noop}
|
|
447
|
+
ariaLabel="Item"
|
|
448
|
+
panelReserve={621}
|
|
449
|
+
panelSize={820}
|
|
450
|
+
onPanelSizeChange={noop}
|
|
451
|
+
onPanelSizeCommit={onCommit}
|
|
452
|
+
>
|
|
453
|
+
<div>fields</div>
|
|
454
|
+
</DetailSurface>,
|
|
455
|
+
)
|
|
456
|
+
fireEvent.doubleClick(edgeHandle())
|
|
457
|
+
expect(onCommit).toHaveBeenCalledWith(560)
|
|
458
|
+
} finally {
|
|
459
|
+
restore()
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('re-derives the split when the container moves, and re-clamps a stale size', () => {
|
|
464
|
+
// THE REGRESSION THIS PASS EXISTS TO PREVENT. Before the outer edge was
|
|
465
|
+
// draggable the split's container never moved after first layout, so a
|
|
466
|
+
// clamp computed once was a clamp that stayed right. Now it can move under
|
|
467
|
+
// the split at any moment, and a bound left at its first reading would let
|
|
468
|
+
// the activity pane keep a size the container can no longer hold — which
|
|
469
|
+
// is the pane collision that was just fixed, arriving by a different door.
|
|
470
|
+
const box = measurable(1000, 800)
|
|
471
|
+
try {
|
|
472
|
+
render(
|
|
473
|
+
<DetailSurface
|
|
474
|
+
open
|
|
475
|
+
mode="panel"
|
|
476
|
+
onClose={noop}
|
|
477
|
+
ariaLabel="Item"
|
|
478
|
+
panelReserve={621}
|
|
479
|
+
aside={<div>activity</div>}
|
|
480
|
+
asideSize={400}
|
|
481
|
+
onAsideSizeChange={noop}
|
|
482
|
+
>
|
|
483
|
+
<div>fields</div>
|
|
484
|
+
</DetailSurface>,
|
|
485
|
+
)
|
|
486
|
+
// 800 of container leaves 596 (− 200, the fields floor; − 4, the seam),
|
|
487
|
+
// which is more than the pane's own 560 ceiling — so the ceiling stands.
|
|
488
|
+
expect(innerDivider()).toHaveAttribute('aria-valuemax', '560')
|
|
489
|
+
expect(innerDivider()).toHaveAttribute('aria-valuenow', '400')
|
|
490
|
+
|
|
491
|
+
// The container shrinks under it. 300 − 200 − 4 = 96, and the pane's
|
|
492
|
+
// stored 400 is REPORTED at the value the container can honour rather
|
|
493
|
+
// than at the one it was left at.
|
|
494
|
+
act(() => box.resize({ width: 600, height: 300 }))
|
|
495
|
+
const divider = innerDivider()
|
|
496
|
+
expect(divider).toHaveAttribute('aria-valuemax', '96')
|
|
497
|
+
expect(divider).toHaveAttribute('aria-valuemin', '96')
|
|
498
|
+
expect(divider).toHaveAttribute('aria-valuenow', '96')
|
|
499
|
+
|
|
500
|
+
// And the outer edge re-derives off the same reading, so the two clamps
|
|
501
|
+
// cannot disagree about how much room there is.
|
|
502
|
+
expect(edgeHandle()).toHaveAttribute('aria-valuemax', '0')
|
|
503
|
+
} finally {
|
|
504
|
+
box.restore()
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('resizes the activity pane from the keyboard', async () => {
|
|
509
|
+
const onCommit = vi.fn()
|
|
510
|
+
render(
|
|
511
|
+
<DetailSurface
|
|
512
|
+
open
|
|
513
|
+
mode="full"
|
|
514
|
+
onClose={noop}
|
|
515
|
+
ariaLabel="Item"
|
|
516
|
+
aside={<div>activity</div>}
|
|
517
|
+
asideSize={400}
|
|
518
|
+
onAsideSizeChange={noop}
|
|
519
|
+
onAsideSizeCommit={onCommit}
|
|
520
|
+
>
|
|
521
|
+
<div>fields</div>
|
|
522
|
+
</DetailSurface>,
|
|
523
|
+
)
|
|
524
|
+
const divider = screen.getByRole('separator')
|
|
525
|
+
divider.focus()
|
|
526
|
+
await userEvent.keyboard('{ArrowLeft}')
|
|
527
|
+
expect(onCommit).toHaveBeenCalledWith(416, 'side')
|
|
528
|
+
})
|
|
529
|
+
})
|