@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,357 @@
1
+ /**
2
+ * FieldRow — engage, revert, commit, and the resting appearance.
3
+ *
4
+ * The resting assertions are not decoration: §4's whole point is that a field
5
+ * does NOT look like a form control until you engage it, so "there is no
6
+ * textbox on screen until the row is clicked" is the requirement, stated the
7
+ * only way a test can state it.
8
+ */
9
+ import { useState } from 'react'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { render, screen, within } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { FieldList } from '../field-list'
14
+ import { FieldRow } from '../field-row'
15
+
16
+ const noop = () => {}
17
+
18
+ /** Local noon on a fixed day, so the calendar always opens on Sept 2026. */
19
+ const FROZEN = new Date(2026, 8, 15, 12, 0, 0).getTime()
20
+
21
+ describe('FieldRow', () => {
22
+ it('reads as plain text at rest, with a verb phrase when empty', () => {
23
+ render(<FieldRow.Text label="Next steps" value="" onCommit={noop} placeholder="Empty" />)
24
+ expect(screen.queryByRole('textbox')).toBeNull()
25
+ expect(screen.getByRole('button', { name: /Next steps/ })).toHaveTextContent('Empty')
26
+ })
27
+
28
+ it('commits on Enter', async () => {
29
+ const onCommit = vi.fn()
30
+ render(<FieldRow.Text label="Next steps" value="" onCommit={onCommit} />)
31
+ await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
32
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), 'Ship it{Enter}')
33
+ expect(onCommit).toHaveBeenCalledWith('Ship it')
34
+ expect(screen.queryByRole('textbox')).toBeNull()
35
+ })
36
+
37
+ it('reverts on Escape and writes nothing', async () => {
38
+ const onCommit = vi.fn()
39
+ render(<FieldRow.Text label="Next steps" value="Original" onCommit={onCommit} />)
40
+ await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
41
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), ' and more{Escape}')
42
+ expect(onCommit).not.toHaveBeenCalled()
43
+ expect(screen.getByRole('button', { name: /Next steps/ })).toHaveTextContent('Original')
44
+ })
45
+
46
+ it('does not close the surface when Escape reverts a field', async () => {
47
+ // The layer stack listens on `document`. Without `stopPropagation` in the
48
+ // editor, reverting a field would throw the operator out of the item.
49
+ const onEscape = vi.fn()
50
+ document.addEventListener('keydown', onEscape)
51
+ try {
52
+ render(<FieldRow.Text label="Next steps" value="Original" onCommit={noop} />)
53
+ await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
54
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), '{Escape}')
55
+ expect(onEscape).not.toHaveBeenCalled()
56
+ } finally {
57
+ document.removeEventListener('keydown', onEscape)
58
+ }
59
+ })
60
+
61
+ it('commits on blur', async () => {
62
+ const onCommit = vi.fn()
63
+ render(
64
+ <>
65
+ <FieldRow.Text label="Next steps" value="" onCommit={onCommit} />
66
+ <button type="button">Elsewhere</button>
67
+ </>,
68
+ )
69
+ await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
70
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), 'Typed')
71
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
72
+ expect(onCommit).toHaveBeenCalledWith('Typed')
73
+ })
74
+
75
+ it('does not fire a commit when nothing changed', async () => {
76
+ const onCommit = vi.fn()
77
+ render(<FieldRow.Text label="Next steps" value="Same" onCommit={onCommit} />)
78
+ await userEvent.click(screen.getByRole('button', { name: /Next steps/ }))
79
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), '{Enter}')
80
+ expect(onCommit).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it('picks a date through the calendar and confirms it', async () => {
84
+ const onCommit = vi.fn()
85
+ render(
86
+ <FieldRow.Date label="Due date" value={null} onCommit={onCommit} now={FROZEN} locale="en-US" />,
87
+ )
88
+ await userEvent.click(screen.getByRole('button', { name: /Due date/ }))
89
+ const grid = screen.getByRole('grid')
90
+ await userEvent.click(within(grid).getByText('10'))
91
+ await userEvent.click(screen.getByRole('button', { name: 'Save date' }))
92
+ expect(onCommit).toHaveBeenCalledWith('2026-09-10')
93
+ })
94
+
95
+ it('adds a time when Include time is on', async () => {
96
+ const onCommit = vi.fn()
97
+ render(
98
+ <FieldRow.Date
99
+ label="Due date"
100
+ value="2026-09-10"
101
+ onCommit={onCommit}
102
+ now={FROZEN}
103
+ locale="en-US"
104
+ />,
105
+ )
106
+ await userEvent.click(screen.getByRole('button', { name: /Due date/ }))
107
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Include time' }))
108
+ await userEvent.click(screen.getByRole('button', { name: 'Save date' }))
109
+ expect(onCommit).toHaveBeenCalledWith('2026-09-10T09:00')
110
+ })
111
+
112
+ it('clears a date', async () => {
113
+ const onCommit = vi.fn()
114
+ render(
115
+ <FieldRow.Date label="Due date" value="2026-09-10" onCommit={onCommit} now={FROZEN} />,
116
+ )
117
+ await userEvent.click(screen.getByRole('button', { name: /Due date/ }))
118
+ await userEvent.click(screen.getByRole('button', { name: 'Clear' }))
119
+ expect(onCommit).toHaveBeenCalledWith(null)
120
+ })
121
+
122
+ it('picks one option through the select row', async () => {
123
+ const onCommit = vi.fn()
124
+ render(
125
+ <FieldRow.Select
126
+ label="Status"
127
+ value={null}
128
+ onCommit={onCommit}
129
+ options={[
130
+ { value: 'building', label: 'Building' },
131
+ { value: 'review', label: 'Review' },
132
+ ]}
133
+ />,
134
+ )
135
+ await userEvent.click(screen.getByRole('button', { name: /Status/ }))
136
+ await userEvent.click(screen.getByRole('option', { name: /Review/ }))
137
+ expect(onCommit).toHaveBeenCalledWith('review')
138
+ })
139
+
140
+ it('assigns people and commits the pending set when the picker closes', async () => {
141
+ const onCommit = vi.fn()
142
+ function Host() {
143
+ const [value, setValue] = useState<readonly string[]>([])
144
+ return (
145
+ <>
146
+ <FieldRow.People
147
+ label="Assignees"
148
+ value={value}
149
+ people={[
150
+ { id: 'u1', name: 'Ada Lovelace' },
151
+ { id: 'u2', name: 'Grace Hopper' },
152
+ ]}
153
+ onCommit={(next) => {
154
+ onCommit(next)
155
+ setValue(next)
156
+ }}
157
+ />
158
+ <button type="button">Elsewhere</button>
159
+ </>
160
+ )
161
+ }
162
+ render(<Host />)
163
+ await userEvent.click(screen.getByRole('button', { name: /Assignees/ }))
164
+ await userEvent.click(screen.getByRole('option', { name: /Ada Lovelace/ }))
165
+ expect(onCommit).not.toHaveBeenCalled()
166
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
167
+ expect(onCommit).toHaveBeenCalledWith(['u1'])
168
+ })
169
+
170
+ it('edits tags in place, and Escape puts them back', async () => {
171
+ const onCommit = vi.fn()
172
+ render(
173
+ <>
174
+ <FieldRow.Tags label="Tags" value={['alpha']} onCommit={onCommit} />
175
+ <button type="button">Elsewhere</button>
176
+ </>,
177
+ )
178
+ await userEvent.click(screen.getByRole('button', { name: /Tags/ }))
179
+ const input = screen.getByRole('textbox')
180
+ await userEvent.type(input, 'beta{Enter}')
181
+ await userEvent.keyboard('{Escape}')
182
+ expect(onCommit).not.toHaveBeenCalled()
183
+ expect(screen.getByRole('button', { name: /Tags/ })).toHaveTextContent('alpha')
184
+
185
+ await userEvent.click(screen.getByRole('button', { name: /Tags/ }))
186
+ await userEvent.type(screen.getByRole('textbox'), 'gamma{Enter}')
187
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
188
+ expect(onCommit).toHaveBeenCalledWith(['alpha', 'gamma'])
189
+ })
190
+ })
191
+
192
+ // The file had ZERO `.focus()` calls before 2026-09-06: every editor here
193
+ // replaces the resting value, so Enter and Escape — the two commonest
194
+ // gestures in the detail surface — unmounted the focused element and
195
+ // dropped the caret to <body>.
196
+ describe('FieldRow focus restore', () => {
197
+ const resting = (name: RegExp) => screen.getByRole('button', { name })
198
+
199
+ it('Text: Enter commits and returns focus to the resting row', async () => {
200
+ render(<FieldRow.Text label="Next steps" value="" onCommit={noop} />)
201
+ await userEvent.click(resting(/Next steps/))
202
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), 'Ship it{Enter}')
203
+ expect(document.activeElement).toBe(resting(/Next steps/))
204
+ })
205
+
206
+ it('Text: Escape reverts and returns focus to the resting row', async () => {
207
+ render(<FieldRow.Text label="Next steps" value="Original" onCommit={noop} />)
208
+ await userEvent.click(resting(/Next steps/))
209
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), '{Escape}')
210
+ expect(document.activeElement).toBe(resting(/Next steps/))
211
+ })
212
+
213
+ it('Text (multiline): Cmd+Enter commits and returns focus', async () => {
214
+ render(<FieldRow.Text multiline label="Notes" value="" onCommit={noop} />)
215
+ await userEvent.click(resting(/Notes/))
216
+ const editor = screen.getByRole('textbox', { name: 'Notes' })
217
+ await userEvent.type(editor, 'Long form')
218
+ await userEvent.keyboard('{Meta>}{Enter}{/Meta}')
219
+ expect(document.activeElement).toBe(resting(/Notes/))
220
+ })
221
+
222
+ it('Tags: Escape returns focus to the resting row', async () => {
223
+ render(<FieldRow.Tags label="Tags" value={['alpha']} onCommit={noop} />)
224
+ await userEvent.click(resting(/Tags/))
225
+ await userEvent.keyboard('{Escape}')
226
+ expect(document.activeElement).toBe(resting(/Tags/))
227
+ })
228
+
229
+ it('Select: choosing an option returns focus to the resting row', async () => {
230
+ render(
231
+ <FieldRow.Select
232
+ label="Status"
233
+ value={null}
234
+ onCommit={noop}
235
+ options={[{ value: 'review', label: 'Review' }]}
236
+ />,
237
+ )
238
+ await userEvent.click(resting(/Status/))
239
+ await userEvent.click(screen.getByRole('option', { name: /Review/ }))
240
+ expect(document.activeElement).toBe(resting(/Status/))
241
+ })
242
+
243
+ it('People: closing the picker returns focus to the resting row', async () => {
244
+ render(
245
+ <FieldRow.People
246
+ label="Assignees"
247
+ value={[]}
248
+ people={[{ id: 'u1', name: 'Ada Lovelace' }]}
249
+ onCommit={noop}
250
+ />,
251
+ )
252
+ await userEvent.click(resting(/Assignees/))
253
+ await userEvent.keyboard('{Escape}')
254
+ expect(document.activeElement).toBe(resting(/Assignees/))
255
+ })
256
+
257
+ it('Date: saving returns focus to the resting row', async () => {
258
+ render(<FieldRow.Date label="Due date" value={null} onCommit={noop} now={FROZEN} />)
259
+ await userEvent.click(resting(/Due date/))
260
+ await userEvent.click(screen.getByRole('button', { name: 'Save date' }))
261
+ expect(document.activeElement).toBe(resting(/Due date/))
262
+ })
263
+
264
+ it('leaves focus alone when a blur-commit hands it to another control', async () => {
265
+ render(
266
+ <>
267
+ <FieldRow.Text label="Next steps" value="" onCommit={noop} />
268
+ <button type="button">Elsewhere</button>
269
+ </>,
270
+ )
271
+ await userEvent.click(resting(/Next steps/))
272
+ await userEvent.type(screen.getByRole('textbox', { name: 'Next steps' }), 'Typed')
273
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
274
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Elsewhere' }))
275
+ })
276
+ })
277
+
278
+ describe('FieldRow value semantics', () => {
279
+ it('associates a read-only value with its label', () => {
280
+ render(<FieldRow.Text readOnly label="Owner" value="Ada Lovelace" onCommit={noop} />)
281
+ // A bare <span> is role=generic, where a name is ignored — the value
282
+ // reached AT as a loose string with no tie to the label beside it.
283
+ expect(screen.getByRole('group', { name: 'Owner' })).toHaveTextContent('Ada Lovelace')
284
+ })
285
+
286
+ it('associates a static <FieldRow value> with its label', () => {
287
+ render(<FieldRow label="Created" filled value="Yesterday" />)
288
+ expect(screen.getByRole('group', { name: 'Created' })).toHaveTextContent('Yesterday')
289
+ })
290
+
291
+ it('tells you what each row will actually do', () => {
292
+ render(
293
+ <>
294
+ <FieldRow.Text label="Next steps" value="" onCommit={noop} />
295
+ <FieldRow.Date label="Due date" value={null} onCommit={noop} now={FROZEN} />
296
+ <FieldRow.Select label="Status" value={null} onCommit={noop} options={[]} />
297
+ <FieldRow.People label="Assignees" value={[]} people={[]} onCommit={noop} />
298
+ </>,
299
+ )
300
+ // One affordance, five behaviours — so each row states its own.
301
+ expect(screen.getByRole('button', { name: /Due date/ })).toHaveAttribute(
302
+ 'aria-haspopup',
303
+ 'dialog',
304
+ )
305
+ expect(screen.getByRole('button', { name: /Status/ })).toHaveAttribute(
306
+ 'aria-haspopup',
307
+ 'listbox',
308
+ )
309
+ expect(screen.getByRole('button', { name: /Assignees/ })).toHaveAttribute(
310
+ 'aria-haspopup',
311
+ 'listbox',
312
+ )
313
+ const text = screen.getByRole('button', { name: /Next steps/ })
314
+ expect(text).not.toHaveAttribute('aria-haspopup')
315
+ expect(text).toHaveAccessibleDescription(/Edits in place/)
316
+ })
317
+ })
318
+
319
+ describe('FieldList', () => {
320
+ const rows = (
321
+ <>
322
+ <FieldRow label="Due date" filled value="10 Sept" />
323
+ <FieldRow label="Assignees" filled={false} placeholder="Unassigned" />
324
+ </>
325
+ )
326
+
327
+ it('hides rows that do not match the search, without unmounting them', async () => {
328
+ render(<FieldList>{rows}</FieldList>)
329
+ await userEvent.type(screen.getByRole('searchbox', { name: 'Search fields' }), 'due')
330
+
331
+ const due = document.querySelector('[data-field-row]:not([hidden])')
332
+ expect(due).toHaveTextContent('Due date')
333
+ // Still in the DOM — a half-finished edit survives a keystroke here.
334
+ expect(document.querySelectorAll('[data-field-row]')).toHaveLength(2)
335
+ expect(document.querySelectorAll('[data-field-row][hidden]')).toHaveLength(1)
336
+ })
337
+
338
+ it('filters by filled and empty', async () => {
339
+ render(<FieldList>{rows}</FieldList>)
340
+ await userEvent.click(screen.getByRole('button', { name: 'Empty' }))
341
+ const visible = document.querySelectorAll('[data-field-row]:not([hidden])')
342
+ expect(visible).toHaveLength(1)
343
+ expect(visible[0]).toHaveTextContent('Unassigned')
344
+
345
+ await userEvent.click(screen.getByRole('button', { name: 'Filled' }))
346
+ const filled = document.querySelectorAll('[data-field-row]:not([hidden])')
347
+ expect(filled).toHaveLength(1)
348
+ expect(filled[0]).toHaveTextContent('Due date')
349
+ })
350
+
351
+ it('draws no Manage fields control unless the host supplies one', () => {
352
+ const view = render(<FieldList>{rows}</FieldList>)
353
+ expect(screen.queryByRole('button', { name: 'Manage fields' })).toBeNull()
354
+ view.rerender(<FieldList onManageFields={noop}>{rows}</FieldList>)
355
+ expect(screen.getByRole('button', { name: 'Manage fields' })).toBeInTheDocument()
356
+ })
357
+ })