@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,420 @@
1
+ /**
2
+ * The board kit — Board / BoardColumn / TaskCard / ProgressLedger, plus the
3
+ * tint-ladder helper (ADR-146 slice A).
4
+ *
5
+ * These pin the contracts that are easy to break silently and that neither
6
+ * typecheck nor lint can see:
7
+ *
8
+ * - the ledger is never colour-only (count AND percentage as text)
9
+ * - `red` can never become a board hue
10
+ * - a card's rows really are optional, so an item with nothing renders a
11
+ * title and no empty scaffolding
12
+ * - media declares its aspect ratio up front and degrades to a placeholder
13
+ * rather than a collapsed box
14
+ * - every icon-only control is named
15
+ *
16
+ * `css: false` in this package's vitest config, so nothing here asserts a
17
+ * painted value — that is the eyeball pass's job, not this file's.
18
+ */
19
+
20
+ import { describe, expect, it, vi } from 'vitest'
21
+ import { fireEvent, render, screen, within } from '@testing-library/react'
22
+ import { Board } from '../board'
23
+ import { TaskCard } from '../task-card'
24
+ import { ProgressLedger } from '../progress-ledger'
25
+ import { BOARD_HUE_KEYS, hueClass, hueForKey } from '../hue'
26
+
27
+ describe('hue ladder', () => {
28
+ it('excludes red — it is byte-identical to --accent in light mode', () => {
29
+ expect(BOARD_HUE_KEYS).not.toContain('red')
30
+ expect(BOARD_HUE_KEYS.length).toBeGreaterThan(0)
31
+ })
32
+
33
+ it('returns BOTH classes, because .hue-blue alone renders slate', () => {
34
+ expect(hueClass('blue')).toBe('hue hue-blue')
35
+ })
36
+
37
+ it('is deterministic per identity', () => {
38
+ expect(hueForKey('Dana Okafor')).toBe(hueForKey('Dana Okafor'))
39
+ expect(BOARD_HUE_KEYS).toContain(hueForKey('Dana Okafor'))
40
+ })
41
+ })
42
+
43
+ describe('ProgressLedger', () => {
44
+ const segments = [
45
+ {
46
+ id: 'g1',
47
+ label: 'Design',
48
+ cells: [
49
+ { id: 't1', done: true },
50
+ { id: 't2', done: true },
51
+ ],
52
+ },
53
+ {
54
+ id: 'g2',
55
+ label: 'Build',
56
+ cells: [
57
+ { id: 't3', done: true },
58
+ { id: 't4', done: false, current: true },
59
+ { id: 't5', done: false },
60
+ ],
61
+ },
62
+ ]
63
+
64
+ it('carries the count and the percentage as TEXT, not only as colour', () => {
65
+ render(<ProgressLedger segments={segments} phase="Building" />)
66
+ expect(screen.getByText('3/5')).toBeInTheDocument()
67
+ expect(screen.getByText('60%')).toBeInTheDocument()
68
+ expect(screen.getByText('Building')).toBeInTheDocument()
69
+ })
70
+
71
+ it('names itself for assistive tech with the same numbers', () => {
72
+ render(<ProgressLedger segments={segments} ariaLabel="Monitoring dashboard" />)
73
+ expect(
74
+ screen.getByRole('group', {
75
+ name: 'Progress for Monitoring dashboard: 3 of 5 done, 60 percent',
76
+ }),
77
+ ).toBeInTheDocument()
78
+ })
79
+
80
+ it('emits the group when a segment is activated', () => {
81
+ const onSelect = vi.fn()
82
+ render(<ProgressLedger segments={segments} onSelectSegment={onSelect} />)
83
+ fireEvent.click(screen.getByRole('button', { name: 'Build: 1 of 3 done' }))
84
+ expect(onSelect).toHaveBeenCalledWith('g2')
85
+ })
86
+
87
+ it('gives an interactive segment a 24px hit area, not a 12px one', () => {
88
+ render(<ProgressLedger segments={segments} onSelectSegment={vi.fn()} />)
89
+ // The cells are 12px tall by design (countable, not tappable). When the
90
+ // run becomes a control it has to clear the 24px accessibility floor, and
91
+ // the primitive enforces that rather than asking each caller to.
92
+ expect(screen.getByRole('button', { name: 'Design: 2 of 2 done' })).toHaveStyle({
93
+ minBlockSize: '24px',
94
+ })
95
+ })
96
+
97
+ it('renders no segment buttons when there is nowhere to go', () => {
98
+ // Interactivity is OPT-IN. Six dead 12px tab stops shipped on the Tasks
99
+ // board because the consumer passed an empty handler; the primitive's job
100
+ // is to make the inert case the default, and this pins it.
101
+ render(<ProgressLedger segments={segments} />)
102
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
103
+ })
104
+
105
+ it('survives an item with no tasks rather than dividing by zero', () => {
106
+ render(<ProgressLedger segments={[]} />)
107
+ expect(screen.getByText('0/0')).toBeInTheDocument()
108
+ expect(screen.getByText('0%')).toBeInTheDocument()
109
+ })
110
+ })
111
+
112
+ describe('TaskCard', () => {
113
+ it('renders a title alone — every other row is optional', () => {
114
+ const { container } = render(<TaskCard title="Quarterly reporting refresh" />)
115
+ expect(
116
+ screen.getByRole('heading', { name: 'Quarterly reporting refresh' }),
117
+ ).toBeInTheDocument()
118
+ // No footer, no tag row, no media: the card reserves nothing for absent data.
119
+ expect(container.querySelectorAll('img')).toHaveLength(0)
120
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
121
+ })
122
+
123
+ it('declares the media aspect ratio up front so it cannot reflow on load', () => {
124
+ const { container } = render(
125
+ <TaskCard
126
+ title="Spring intake"
127
+ media={[
128
+ { src: 'blob:one', alt: 'Hero' },
129
+ { src: 'blob:two', alt: 'Alt' },
130
+ ]}
131
+ />,
132
+ )
133
+ const frame = container.querySelector('img')?.parentElement
134
+ expect(frame).toHaveStyle({ aspectRatio: '16 / 10' })
135
+ // Two images: the first renders, the rest are badged.
136
+ expect(screen.getByAltText('Hero')).toBeInTheDocument()
137
+ expect(screen.getByText('+1')).toBeInTheDocument()
138
+ })
139
+
140
+ it('falls back to a placeholder, not a collapsed box, when an image fails', () => {
141
+ render(<TaskCard title="Spring intake" media={[{ src: 'blob:gone', alt: 'Hero' }]} />)
142
+ fireEvent.error(screen.getByAltText('Hero'))
143
+ expect(screen.getByText('Image unavailable: Hero')).toBeInTheDocument()
144
+ })
145
+
146
+ it('names the drag grip, so keyboard drag is reachable and announced', () => {
147
+ render(
148
+ <TaskCard
149
+ title="Monitoring dashboard"
150
+ drag={{ activatorProps: { role: 'button', tabIndex: 0 } }}
151
+ />,
152
+ )
153
+ expect(
154
+ screen.getByRole('button', { name: 'Reorder Monitoring dashboard' }),
155
+ ).toBeInTheDocument()
156
+ })
157
+
158
+ it('does not conjure group 1 for a grip alone — it rides the title row', () => {
159
+ const { container } = render(
160
+ <TaskCard
161
+ title="Monitoring dashboard"
162
+ drag={{ activatorProps: { role: 'button', tabIndex: 0 } }}
163
+ />,
164
+ )
165
+ const grip = screen.getByRole('button', { name: 'Reorder Monitoring dashboard' })
166
+ const heading = screen.getByRole('heading', { name: 'Monitoring dashboard' })
167
+ // Same row: the grip's parent contains the heading's own column.
168
+ expect(grip.parentElement).toContainElement(heading)
169
+ // The card is title + nothing else, so it renders exactly one group.
170
+ const article = container.querySelector('article')
171
+ expect(article?.children).toHaveLength(1)
172
+ })
173
+
174
+ it('keeps group 1 when it has real content, with the grip in it', () => {
175
+ render(
176
+ <TaskCard
177
+ title="Monitoring dashboard"
178
+ tags={['design']}
179
+ drag={{ activatorProps: { role: 'button', tabIndex: 0 } }}
180
+ />,
181
+ )
182
+ const grip = screen.getByRole('button', { name: 'Reorder Monitoring dashboard' })
183
+ expect(grip.parentElement).toContainElement(screen.getByText('#design'))
184
+ })
185
+
186
+ it('lets a long title wrap rather than clipping it with no way to read on', () => {
187
+ render(<TaskCard title="A title long enough to need a third line on a narrow column" />)
188
+ expect(
189
+ screen.getByRole('heading', {
190
+ name: 'A title long enough to need a third line on a narrow column',
191
+ }),
192
+ ).not.toHaveClass('line-clamp-2')
193
+ })
194
+
195
+ it('renders counts and a consumer footer control side by side', () => {
196
+ render(
197
+ <TaskCard
198
+ title="Saved filter presets"
199
+ counts={[{ icon: null, value: 4, label: 'comments' }]}
200
+ footerEnd={<button type="button">Vote</button>}
201
+ />,
202
+ )
203
+ expect(screen.getByText('4')).toBeInTheDocument()
204
+ expect(screen.getByText('comments')).toBeInTheDocument()
205
+ expect(screen.getByRole('button', { name: 'Vote' })).toBeInTheDocument()
206
+ })
207
+
208
+ it('caps the tag row and reports the overflow', () => {
209
+ render(<TaskCard title="x" tags={['Alpha', 'Beta', 'Gamma', 'Delta']} />)
210
+ expect(screen.getByText('#alpha')).toBeInTheDocument()
211
+ expect(screen.getByText('+1')).toBeInTheDocument()
212
+ })
213
+
214
+ /* ── The card is clickable, and it is NOT a drag handle ──────────────── */
215
+
216
+ it('opens from the card body, and the title does not open it twice', () => {
217
+ const onOpen = vi.fn()
218
+ const { container } = render(
219
+ <TaskCard
220
+ title="Monitoring dashboard"
221
+ note="They liked the Behance set."
222
+ onOpen={onOpen}
223
+ />,
224
+ )
225
+ expect(container.querySelector('article')).toHaveAttribute('data-interactive', 'true')
226
+
227
+ fireEvent.click(screen.getByText('They liked the Behance set.'))
228
+ expect(onOpen).toHaveBeenCalledTimes(1)
229
+
230
+ // The title is the KEYBOARD control and calls onOpen itself. The card's
231
+ // own handler must stand down behind it rather than firing a second time.
232
+ fireEvent.click(screen.getByRole('button', { name: 'Monitoring dashboard' }))
233
+ expect(onOpen).toHaveBeenCalledTimes(2)
234
+ })
235
+
236
+ it('leaves a control inside the card to itself', () => {
237
+ const onOpen = vi.fn()
238
+ render(
239
+ <TaskCard
240
+ title="Saved filter presets"
241
+ onOpen={onOpen}
242
+ footerEnd={<button type="button">Vote</button>}
243
+ />,
244
+ )
245
+ fireEvent.click(screen.getByRole('button', { name: 'Vote' }))
246
+ expect(onOpen).not.toHaveBeenCalled()
247
+ })
248
+
249
+ it('claims no affordance at all without onOpen', () => {
250
+ const { container } = render(<TaskCard title="Quarterly reporting refresh" />)
251
+ expect(container.querySelector('article')).not.toHaveAttribute('data-interactive')
252
+ })
253
+
254
+ it('leaves the shadow to CSS, so the hover and focus steps can win', () => {
255
+ // An inline box-shadow outranks every stylesheet rule, which is why the
256
+ // hover step had never applied. `data-overlay` is what selects the heavier
257
+ // one now — the element must carry NO inline box-shadow at all.
258
+ const { container, rerender } = render(<TaskCard title="Spring intake" />)
259
+ const article = container.querySelector('article')
260
+ expect(article?.style.boxShadow).toBe('')
261
+ expect(article).not.toHaveAttribute('data-overlay')
262
+
263
+ rerender(<TaskCard title="Spring intake" isOverlay />)
264
+ expect(container.querySelector('article')?.style.boxShadow).toBe('')
265
+ expect(container.querySelector('article')).toHaveAttribute('data-overlay', 'true')
266
+ })
267
+
268
+ it('starts a drag from the grip and NEVER from the card body', () => {
269
+ // The defect this pins: `{...dragHandleProps}` on the <article> made every
270
+ // pointer-down on the card the start of a drag, so the card could not be
271
+ // clicked and read `cursor: grab` everywhere.
272
+ const onPointerDown = vi.fn()
273
+ const { container } = render(
274
+ <TaskCard
275
+ title="Monitoring dashboard"
276
+ drag={{ activatorProps: { role: 'button', tabIndex: 0, onPointerDown } }}
277
+ />,
278
+ )
279
+ const article = container.querySelector('article')
280
+ expect(article).not.toHaveAttribute('role')
281
+ expect(article).not.toHaveAttribute('tabindex')
282
+
283
+ fireEvent.pointerDown(article!)
284
+ expect(onPointerDown).not.toHaveBeenCalled()
285
+
286
+ fireEvent.pointerDown(
287
+ screen.getByRole('button', { name: 'Reorder Monitoring dashboard' }),
288
+ )
289
+ expect(onPointerDown).toHaveBeenCalledTimes(1)
290
+ })
291
+ })
292
+
293
+ describe('Board', () => {
294
+ const columns = [
295
+ { id: 'planned', label: 'Planned', hue: 'slate' as const },
296
+ { id: 'building', label: 'Building', hue: 'blue' as const },
297
+ ]
298
+
299
+ it('renders one column per spec, with its count and its cards', () => {
300
+ render(
301
+ <Board
302
+ columns={columns}
303
+ items={{ planned: ['a'], building: [] }}
304
+ draggable={false}
305
+ renderCard={({ itemId }) => <TaskCard title={`Item ${itemId}`} />}
306
+ />,
307
+ )
308
+ const planned = screen.getByRole('region', { name: 'Planned' })
309
+ expect(within(planned).getByText('1')).toBeInTheDocument()
310
+ expect(within(planned).getByRole('heading', { name: 'Item a' })).toBeInTheDocument()
311
+ })
312
+
313
+ it('invites an action in an empty column instead of showing a mood', () => {
314
+ render(
315
+ <Board
316
+ columns={columns}
317
+ items={{ planned: [], building: [] }}
318
+ // An empty lane keeps its place only on request now — a draggable
319
+ // board wants it (an omitted lane is a missing drop target), a
320
+ // read-only one usually does not.
321
+ showEmptyColumns
322
+ draggable={false}
323
+ renderCard={() => null}
324
+ />,
325
+ )
326
+ expect(
327
+ screen.getByText('Nothing in Building yet. Drag one here, or add one.'),
328
+ ).toBeInTheDocument()
329
+ })
330
+
331
+ it('names the collapse and add controls, which are icon-only', () => {
332
+ const onAdd = vi.fn()
333
+ const onToggle = vi.fn()
334
+ render(
335
+ <Board
336
+ columns={[{ ...columns[0]!, onAdd }]}
337
+ items={{ planned: [] }}
338
+ showEmptyColumns
339
+ draggable={false}
340
+ onToggleCollapsed={onToggle}
341
+ renderCard={() => null}
342
+ />,
343
+ )
344
+ fireEvent.click(screen.getByRole('button', { name: 'Add to Planned' }))
345
+ expect(onAdd).toHaveBeenCalled()
346
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse Planned' }))
347
+ expect(onToggle).toHaveBeenCalledWith('planned')
348
+ })
349
+
350
+ it('hides a collapsed column body but keeps its header and count', () => {
351
+ render(
352
+ <Board
353
+ columns={columns}
354
+ items={{ planned: ['a'], building: [] }}
355
+ draggable={false}
356
+ collapsedColumnIds={['planned']}
357
+ onToggleCollapsed={vi.fn()}
358
+ renderCard={({ itemId }) => <TaskCard title={`Item ${itemId}`} />}
359
+ />,
360
+ )
361
+ expect(screen.queryByRole('heading', { name: 'Item a' })).not.toBeInTheDocument()
362
+ const planned = screen.getByRole('region', { name: 'Planned' })
363
+ expect(within(planned).getByText('1')).toBeInTheDocument()
364
+ expect(screen.getByRole('button', { name: 'Expand Planned' })).toBeInTheDocument()
365
+ })
366
+
367
+ it('wires dnd-kit to the grip, leaving the card a plain article', () => {
368
+ // End-to-end version of the TaskCard test above: `dragAttributes` used to
369
+ // be `{ ...attributes, ...listeners }` — identical to `dragHandleProps` —
370
+ // so the "split" that exists to keep role/tabIndex off the card was not a
371
+ // split at all.
372
+ const { container } = render(
373
+ <Board
374
+ columns={columns}
375
+ items={{ planned: ['a'], building: [] }}
376
+ onMove={vi.fn()}
377
+ labelForItem={(itemId) => `Item ${itemId}`}
378
+ renderCard={({ itemId, drag }) => (
379
+ <TaskCard title={`Item ${itemId}`} {...(drag ? { drag } : {})} />
380
+ )}
381
+ />,
382
+ )
383
+ const article = container.querySelector('article')
384
+ expect(article).not.toHaveAttribute('role')
385
+ expect(article).not.toHaveAttribute('tabindex')
386
+ expect(article).not.toHaveAttribute('aria-roledescription')
387
+
388
+ // The grip is the handle, so it is what assistive tech is told about.
389
+ const grip = screen.getByRole('button', { name: 'Reorder Item a' })
390
+ expect(grip).toHaveAttribute('aria-roledescription')
391
+ expect(grip).toHaveAttribute('aria-describedby')
392
+ })
393
+
394
+ it('marks a collapsed column so ONE lane can shrink on its own', () => {
395
+ // The scroller stretches its items, so an expanded lane reaches the bottom
396
+ // of the page. Without a per-item hook a collapsed lane inherits the
397
+ // tallest row's height and only the all-collapsed case appears to work —
398
+ // which is how it was reported. `[data-collapsed]` is what the CSS's
399
+ // `align-self: start` hangs on, and it is invisible to typecheck and lint,
400
+ // so it is pinned here.
401
+ render(
402
+ <Board
403
+ columns={columns}
404
+ items={{ planned: ['a'], building: ['b'] }}
405
+ draggable={false}
406
+ collapsedColumnIds={['planned']}
407
+ onToggleCollapsed={vi.fn()}
408
+ renderCard={({ itemId }) => <TaskCard title={`Item ${itemId}`} />}
409
+ />,
410
+ )
411
+ expect(screen.getByRole('region', { name: 'Planned' })).toHaveAttribute(
412
+ 'data-collapsed',
413
+ 'true',
414
+ )
415
+ // And the neighbour is NOT marked — the attribute is per lane, not per board.
416
+ expect(screen.getByRole('region', { name: 'Building' })).not.toHaveAttribute(
417
+ 'data-collapsed',
418
+ )
419
+ })
420
+ })