@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.
Files changed (142) 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 +5574 -57
  4. package/dist/index.js +21650 -11206
  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 +307 -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__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. 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
+ })