@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
package/src/board.tsx ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Board + BoardColumn — the shared kanban primitive.
3
+ *
4
+ * ADR-146 D2: **one board primitive, two consumers.** The Tasks lens and the
5
+ * Community roadmap render the same columns, the same drag behaviour and the
6
+ * same chrome; they differ only in what goes INSIDE a card. So this primitive
7
+ * is generic over its card BODY (`renderCard`), never over its data — a
8
+ * Community card carries votes and a reporter where a Tasks card carries a
9
+ * ledger and dates, and pretending those are one shape is what produced the
10
+ * workspace's second kanban in the first place.
11
+ *
12
+ * Design: `tasks-board-design-language.md` §4, as corrected by
13
+ * `tasks-board-interface-standards.md` §1–§6.
14
+ *
15
+ * ## The column container
16
+ *
17
+ * PlanIQ nests cards inside a large rounded column rather than floating them.
18
+ * Adopted, because it reads as a real container and gives drag a visible drop
19
+ * target: `--surface-frame`, 1px `--border`, `--radius-2xl`, `--space-2` pad —
20
+ * which is exactly what makes the card's `--radius-lg` concentric.
21
+ *
22
+ * ## The drag-over state
23
+ *
24
+ * The column's BORDER steps to `--border-strong` and its shell to
25
+ * `--surface-hover`. Deliberately **no accent tint**: the accent marks the one
26
+ * most important thing in a view, and "a card is hovering here" is not it.
27
+ *
28
+ * The signal comes from `MultiSortableList`'s RESOLVED container, never from
29
+ * `useDroppable().isOver`. Under `closestCorners`, `over` resolves to a
30
+ * sortable ITEM whenever the column holds one, so the column droppable never
31
+ * wins and its `isOver` is `false` for the very column the card is over — the
32
+ * whole treatment was dead code until this was derived instead of read.
33
+ *
34
+ * ## Keyboard
35
+ *
36
+ * Cross-column movement runs through dnd-kit's `KeyboardSensor` (wired in
37
+ * `MultiSortableList`), and every icon-only control in the header carries an
38
+ * `aria-label`. Accessibility is a floor here, not polish.
39
+ */
40
+
41
+ import {
42
+ Fragment,
43
+ useId,
44
+ useRef,
45
+ useState,
46
+ type ReactNode,
47
+ type ComponentPropsWithoutRef,
48
+ } from 'react'
49
+ import { ChevronDown, ChevronRight, Plus, X } from 'lucide-react'
50
+ import { cn } from './lib/utils'
51
+ import { Button } from './button'
52
+ import { hueClass, type BoardHueKey } from './hue'
53
+ import { StatusGlyph, StatusRing, type BoardStatusKey } from './status'
54
+ import type { LedgerProgress } from './progress-ledger'
55
+ import {
56
+ MultiSortableList,
57
+ SortableDropZone,
58
+ SortableItem,
59
+ type MultiSortableMove,
60
+ type SortableContainers,
61
+ } from './sortable'
62
+ import type { TaskCardDragProps } from './task-card'
63
+
64
+
65
+ /**
66
+ * Heading level as a prop, not a hardcoded tag.
67
+ *
68
+ * A board sits under whatever heading its host page already renders, so the
69
+ * primitive cannot know whether its columns are h2 or h3. Hardcoding h3 gave
70
+ * the Tasks lens an h1 -> h3 skip the moment it grew a page heading. Levels are
71
+ * an accessibility contract, not styling: the visual weight comes from the
72
+ * class, and the tag only says where this sits in the document outline.
73
+ */
74
+ type HeadingLevel = 2 | 3 | 4 | 5
75
+
76
+ function ColumnHeading({
77
+ level,
78
+ ...rest
79
+ }: { level: HeadingLevel } & ComponentPropsWithoutRef<'h3'>) {
80
+ const Tag = `h${level}` as const
81
+ return <Tag {...rest} />
82
+ }
83
+
84
+ export interface BoardColumnSpec {
85
+ id: string
86
+ /** Operator language, sentence case. Wraps rather than clips. */
87
+ label: string
88
+ hue: BoardHueKey
89
+ /** Overflow menu trigger for column actions. Supply its own `aria-label`. */
90
+ menu?: ReactNode
91
+ /** Omit to hide the `+` affordance on this column. */
92
+ onAdd?: () => void
93
+ /** Accessible name for `+`. Defaults to `Add to <label>`. */
94
+ addLabel?: string
95
+ /** Shown when the column holds nothing. An invitation, not a mood. */
96
+ emptyMessage?: string
97
+ /**
98
+ * The lane's completion — the SAME numbers its cards' ledgers print, from
99
+ * `ledgerProgress()`. Renders a `<StatusRing>` in place of the hue dot, in
100
+ * the header and in the hidden-columns rail.
101
+ *
102
+ * It is a property of the LANE, not of the current filter: typing in a
103
+ * search box does not change how far along the work in a column is. The
104
+ * count beside it is what moves, and it says `visible / total` when a filter
105
+ * is on so the two figures cannot be read as measuring the same set.
106
+ */
107
+ progress?: LedgerProgress
108
+ /**
109
+ * The lane's discrete state, for when there is nothing to measure — an empty
110
+ * lane, or one whose items carry no checklist. `progress` wins when both are
111
+ * supplied: a real number always beats a category.
112
+ */
113
+ status?: BoardStatusKey
114
+ }
115
+
116
+ export interface BoardCardContext {
117
+ itemId: string
118
+ columnId: string
119
+ /** True while rendering inside the DragOverlay — no hover lift there. */
120
+ isOverlay: boolean
121
+ /** Undefined on the overlay and on a non-draggable board. */
122
+ drag?: TaskCardDragProps
123
+ }
124
+
125
+ export interface BoardItemLeadContext {
126
+ itemId: string
127
+ columnId: string
128
+ /** Position within the lane, in the live arrangement. */
129
+ index: number
130
+ /** The lane's item ids, in the live arrangement. */
131
+ items: readonly string[]
132
+ }
133
+
134
+ export interface BoardProps {
135
+ /**
136
+ * Document-outline level for the column heading. Default 3.
137
+ * Set it to match the host page: a lens with its own `<h1>` passes 2 so the
138
+ * outline reads h1 -> h2 -> h3 without a skip. Visual weight is unaffected.
139
+ */
140
+ headingLevel?: HeadingLevel
141
+ columns: readonly BoardColumnSpec[]
142
+ /** columnId -> item ids, in order. FILTERED — what the board renders. */
143
+ items: SortableContainers
144
+ /**
145
+ * The same map, UNFILTERED and over the same scope.
146
+ *
147
+ * This is the architectural key to the hidden-columns rail
148
+ * (`tasks-board-header-primitives.md` §3): without both maps there is
149
+ * nothing to subtract, and the per-lane `0 / n` — the count of what the
150
+ * filter is hiding IN THAT LANE — cannot be computed at all.
151
+ *
152
+ * Omit it on a board with no filtering. Then `totalItems === items` by
153
+ * definition, nothing is hidden, and neither the rail nor the footer can
154
+ * appear.
155
+ */
156
+ totalItems?: SortableContainers
157
+ renderCard: (context: BoardCardContext) => ReactNode
158
+ /**
159
+ * Rendered BEFORE the card at `index` in a lane, outside the sortable
160
+ * wrapper — a sub-group heading, a "today" marker, a run divider.
161
+ *
162
+ * Outside the wrapper on purpose: inside it the heading would be picked up
163
+ * and dragged along with the card it happens to precede. Return `null` for
164
+ * the positions that need nothing, which is most of them.
165
+ *
166
+ * The `items` it is indexed against is the LIVE arrangement, so a heading
167
+ * recomputes while a card is being dragged between lanes rather than
168
+ * lagging one commit behind.
169
+ */
170
+ renderItemLead?: (context: BoardItemLeadContext) => ReactNode
171
+ /**
172
+ * Commit a move. Omit (or pass `draggable={false}`) for a read-only board —
173
+ * Community's roadmap is fixture-backed until AP-11 lands a write path.
174
+ */
175
+ onMove?: (move: MultiSortableMove, next: SortableContainers) => void
176
+ draggable?: boolean
177
+ /** Human name for an item id — used in the drag announcements. */
178
+ labelForItem?: (itemId: string) => string
179
+ collapsedColumnIds?: readonly string[]
180
+ onToggleCollapsed?: (columnId: string) => void
181
+ /**
182
+ * Whether a lane holding nothing — and hiding nothing — keeps its place in
183
+ * the strip. Default `false`: the plain omit behaviour.
184
+ *
185
+ * A DRAGGABLE board almost always wants `true`, because an omitted lane is
186
+ * also a missing drop target. This is unrelated to the rail: a lane that is
187
+ * empty BECAUSE OF A FILTER leaves the strip either way, and says so.
188
+ */
189
+ /**
190
+ * Render a lane that has no items when NO filter is active. Default `true`.
191
+ *
192
+ * An empty lane is part of the board's shape and is a drop target, so
193
+ * omitting it silently is the surprising behaviour — a consumer that never
194
+ * passes this should not lose a lane the moment it empties. Hiding is the
195
+ * FILTERED case, and that is the hidden-columns rail's job, not this flag's.
196
+ */
197
+ showEmptyColumns?: boolean
198
+ /**
199
+ * Clears the host's filters. Renders the "Clear filters" button in the
200
+ * hidden-by-filters footer; without it the footer still states the count,
201
+ * because a dead button is worse than an absent one.
202
+ */
203
+ onClearFilters?: () => void
204
+ /** The board's own word for a card, for the footer copy. Sentence case. */
205
+ itemNoun?: { one: string; many: string }
206
+ /** Trailing element after the last column — typically an "Add column" button. */
207
+ columnsEnd?: ReactNode
208
+ className?: string
209
+ }
210
+
211
+ export function Board({
212
+ headingLevel = 3,
213
+ columns,
214
+ items,
215
+ totalItems,
216
+ renderCard,
217
+ renderItemLead,
218
+ onMove,
219
+ draggable = true,
220
+ labelForItem,
221
+ collapsedColumnIds,
222
+ onToggleCollapsed,
223
+ showEmptyColumns = true,
224
+ onClearFilters,
225
+ itemNoun = { one: 'item', many: 'items' },
226
+ columnsEnd,
227
+ className,
228
+ }: BoardProps) {
229
+ const collapsed = new Set(collapsedColumnIds ?? [])
230
+
231
+ // ---- The filtered / unfiltered partition -------------------------------
232
+ //
233
+ // Derived from the two maps rather than taken as a `filtersActive` flag:
234
+ // a lane is hidden BY A FILTER exactly when it holds nothing and its
235
+ // unfiltered self holds something, and that is knowable here. A flag would
236
+ // be a third source of truth for a fact the data already states.
237
+ //
238
+ // Partitioned on `items` (the committed prop), never on the live drag
239
+ // arrangement — otherwise dragging the last card out of a filtered lane
240
+ // would send that lane to the rail mid-drag, under the pointer.
241
+ const visibleIn = (columnId: string) => (items[columnId] ?? []).length
242
+ const totalIn = (columnId: string) =>
243
+ totalItems ? (totalItems[columnId] ?? []).length : visibleIn(columnId)
244
+ const hiddenIn = (columnId: string) => Math.max(0, totalIn(columnId) - visibleIn(columnId))
245
+
246
+ const railColumns = columns.filter(
247
+ (column) => visibleIn(column.id) === 0 && hiddenIn(column.id) > 0,
248
+ )
249
+ const railIds = new Set(railColumns.map((column) => column.id))
250
+ const stripColumns = columns.filter(
251
+ (column) =>
252
+ !railIds.has(column.id) && (showEmptyColumns || visibleIn(column.id) > 0),
253
+ )
254
+ const hiddenCount = columns.reduce((sum, column) => sum + hiddenIn(column.id), 0)
255
+ const labelForContainer = (columnId: string) =>
256
+ columns.find((column) => column.id === columnId)?.label ?? columnId
257
+
258
+ const columnFor = (itemId: string) =>
259
+ columns.find((column) => (items[column.id] ?? []).includes(itemId))?.id ?? ''
260
+
261
+ /* ---- clearing the filters must not drop focus -----------------------
262
+ *
263
+ * The "Clear filters" button lives in the footer that EXISTS because
264
+ * something is hidden. Pressing it takes the hidden count to zero, which
265
+ * unmounts the footer, which unmounts the button under the operator's
266
+ * finger — focus falls to `<body>` and a keyboard user restarts the
267
+ * document. The board strip is the nearest thing the button was about, so
268
+ * focus moves there.
269
+ *
270
+ * Moved BEFORE the callback rather than after: React batches the state
271
+ * update the consumer makes inside `onClearFilters`, so the commit that
272
+ * removes the button happens after this handler returns — by which time
273
+ * focus is already on the strip. Doing it in the other order would depend
274
+ * on the consumer not calling `flushSync`.
275
+ */
276
+ const scrollerRef = useRef<HTMLDivElement | null>(null)
277
+ const handleClearFilters = onClearFilters
278
+ ? () => {
279
+ scrollerRef.current?.focus()
280
+ onClearFilters()
281
+ }
282
+ : undefined
283
+
284
+ const scroller = (
285
+ arrangement: SortableContainers,
286
+ interactive: boolean,
287
+ overColumnId: string | null,
288
+ ) => (
289
+ // `tabIndex={-1}` makes the strip a focus TARGET without putting it in the
290
+ // tab ring: "Clear filters" unmounts itself, and this is where focus goes
291
+ // instead of `<body>` (see `handleClearFilters`). The baseline focus rule
292
+ // in styles.css deliberately excludes negative tabindex, so parking focus
293
+ // here paints no ring on a control that is not one.
294
+ <div ref={scrollerRef} tabIndex={-1} className={cn('ds-board-scroller', className)}>
295
+ {stripColumns.map((column) => {
296
+ const columnItems = arrangement[column.id] ?? []
297
+ const isCollapsed = collapsed.has(column.id)
298
+ const body = (
299
+ dropProps: { setNodeRef?: (node: HTMLElement | null) => void },
300
+ ) => (
301
+ <BoardColumn
302
+ headingLevel={headingLevel}
303
+ spec={column}
304
+ count={columnItems.length}
305
+ total={totalIn(column.id)}
306
+ collapsed={isCollapsed}
307
+ onToggleCollapsed={
308
+ onToggleCollapsed ? () => onToggleCollapsed(column.id) : undefined
309
+ }
310
+ setNodeRef={dropProps.setNodeRef}
311
+ isOver={overColumnId === column.id}
312
+ >
313
+ {columnItems.map((itemId, index) => (
314
+ <Fragment key={itemId}>
315
+ {renderItemLead
316
+ ? renderItemLead({
317
+ itemId,
318
+ columnId: column.id,
319
+ index,
320
+ items: columnItems,
321
+ })
322
+ : null}
323
+ {interactive ? (
324
+ <SortableItem id={itemId}>
325
+ {({
326
+ dragListeners,
327
+ dragAttributes,
328
+ setNodeRef,
329
+ setActivatorNodeRef,
330
+ style,
331
+ isDragging,
332
+ }) =>
333
+ renderCard({
334
+ itemId,
335
+ columnId: column.id,
336
+ isOverlay: false,
337
+ drag: {
338
+ // The card is the MEASURED node and nothing else. Both
339
+ // activators — pointer and keyboard — plus dnd-kit's
340
+ // aria wiring ride the grip, so a pointer-down on the
341
+ // card body is a click on the card, not a drag, and the
342
+ // card never becomes a role="button" holding its own
343
+ // buttons.
344
+ activatorProps: { ...dragAttributes, ...dragListeners },
345
+ activatorRef: setActivatorNodeRef,
346
+ setNodeRef,
347
+ style,
348
+ isDragging,
349
+ },
350
+ })
351
+ }
352
+ </SortableItem>
353
+ ) : (
354
+ <div>
355
+ {renderCard({ itemId, columnId: column.id, isOverlay: false })}
356
+ </div>
357
+ )}
358
+ </Fragment>
359
+ ))}
360
+ </BoardColumn>
361
+ )
362
+
363
+ return interactive ? (
364
+ <SortableDropZone key={column.id} id={column.id} items={columnItems}>
365
+ {/* `isOver` from the droppable is deliberately ignored — see the
366
+ file header. The drop target is the RESOLVED container. */}
367
+ {({ setNodeRef }) => body({ setNodeRef })}
368
+ </SortableDropZone>
369
+ ) : (
370
+ <div key={column.id} className="contents">
371
+ {body({})}
372
+ </div>
373
+ )
374
+ })}
375
+ {railColumns.length > 0 ? (
376
+ <BoardHiddenColumns columns={railColumns} totalIn={totalIn} />
377
+ ) : null}
378
+ {columnsEnd ? <div className="ds-board-columns-end">{columnsEnd}</div> : null}
379
+ </div>
380
+ )
381
+
382
+ /**
383
+ * The shell exists only to hang the footer under the scroller, so it is
384
+ * `display: contents` until there IS a footer — a board with no filtering
385
+ * keeps exactly the box tree it had before this prop existed.
386
+ */
387
+ const shell = (content: ReactNode) => (
388
+ <div className="ds-board-shell" data-footer={hiddenCount > 0 ? 'true' : undefined}>
389
+ {content}
390
+ {hiddenCount > 0 ? (
391
+ <BoardHiddenSummary
392
+ count={hiddenCount}
393
+ noun={itemNoun}
394
+ {...(handleClearFilters ? { onClearFilters: handleClearFilters } : {})}
395
+ />
396
+ ) : null}
397
+ </div>
398
+ )
399
+
400
+ if (!draggable || !onMove) {
401
+ return shell(scroller(items, false, null))
402
+ }
403
+
404
+ return shell(
405
+ <MultiSortableList
406
+ containers={items}
407
+ containerOrder={columns.map((column) => column.id)}
408
+ onMove={onMove}
409
+ labelForItem={labelForItem}
410
+ labelForContainer={labelForContainer}
411
+ // The DESTINATION column, not the origin: mid-drag the live arrangement
412
+ // has already moved the item, and the overlay must match the placeholder
413
+ // sitting under it rather than showing the same card in two hues.
414
+ renderOverlay={(itemId, containerId) =>
415
+ renderCard({
416
+ itemId,
417
+ columnId: containerId ?? columnFor(itemId),
418
+ isOverlay: true,
419
+ })
420
+ }
421
+ >
422
+ {(arrangement, overColumnId) => scroller(arrangement, true, overColumnId)}
423
+ </MultiSortableList>,
424
+ )
425
+ }
426
+
427
+ /**
428
+ * The lane's marker — a ring when there is a real number, a glyph when the
429
+ * state is discrete, the plain hue dot when the caller says neither.
430
+ *
431
+ * One component so the header and the rail row cannot drift apart, which is
432
+ * how the source ended up with four different answers to "this bucket is
433
+ * empty" in one app.
434
+ */
435
+ function LaneMarker({ spec }: { spec: BoardColumnSpec }) {
436
+ if (spec.progress && spec.progress.total > 0) {
437
+ return (
438
+ <StatusRing
439
+ percent={spec.progress.percent}
440
+ label={`${spec.label} is ${spec.progress.percent}% complete`}
441
+ />
442
+ )
443
+ }
444
+ if (spec.status) {
445
+ return <StatusGlyph status={spec.status} />
446
+ }
447
+ // A hue dot, so a column is identifiable without reading its label — and
448
+ // squared off, because it is a marker, not a status light.
449
+ return <span className="ds-board-column-dot" aria-hidden="true" />
450
+ }
451
+
452
+ /**
453
+ * The hidden-columns rail — the best idea in the source repo
454
+ * (`grouped-issues-view.tsx:80-113`), and the one that answers "how does the
455
+ * board show that a lane is hidden".
456
+ *
457
+ * Two things it deliberately is NOT:
458
+ *
459
+ * - **It is not a rotated column.** Nothing here uses `writing-mode` or a
460
+ * 90° transform. A row stays readable and, more to the point, it has
461
+ * somewhere to put the count — which a vertical strip does not.
462
+ * - **It is not the manual collapse.** A collapsed lane keeps its place in
463
+ * the strip, its caret and its own header; a filter-hidden lane leaves the
464
+ * strip entirely and reads as `0 / n`. Two different states, two different
465
+ * shapes, so neither can be mistaken for the other.
466
+ */
467
+ function BoardHiddenColumns({
468
+ columns,
469
+ totalIn,
470
+ }: {
471
+ columns: readonly BoardColumnSpec[]
472
+ totalIn: (columnId: string) => number
473
+ }) {
474
+ // Open by default: the whole point is that the hiding is visible.
475
+ const [open, setOpen] = useState(true)
476
+ const listId = useId()
477
+ const Caret = open ? ChevronDown : ChevronRight
478
+
479
+ return (
480
+ <aside className="ds-board-hidden-rail" aria-label="Hidden columns">
481
+ <button
482
+ type="button"
483
+ className="ds-board-hidden-rail-head"
484
+ aria-expanded={open}
485
+ aria-controls={listId}
486
+ onClick={() => setOpen((current) => !current)}
487
+ >
488
+ <Caret size={15} strokeWidth={2} aria-hidden="true" />
489
+ <span className="ds-board-hidden-rail-title">Hidden columns</span>
490
+ <span className="ds-board-hidden-rail-count tabular-nums">{columns.length}</span>
491
+ </button>
492
+ <ul id={listId} className="ds-board-hidden-rail-list" hidden={!open}>
493
+ {columns.map((column) => {
494
+ const total = totalIn(column.id)
495
+ return (
496
+ <li key={column.id} className={cn('ds-board-hidden-row', hueClass(column.hue))}>
497
+ <LaneMarker spec={column} />
498
+ <span className="ds-board-hidden-row-label">{column.label}</span>
499
+ {/* The figure a filtered board cannot compute without both maps:
500
+ what this lane is hiding. Read out in words rather than as
501
+ "zero slash five". */}
502
+ <span className="ds-board-hidden-row-count tabular-nums" aria-hidden="true">
503
+ 0 / {total}
504
+ </span>
505
+ <span className="sr-only">{`0 of ${total} shown`}</span>
506
+ </li>
507
+ )
508
+ })}
509
+ </ul>
510
+ </aside>
511
+ )
512
+ }
513
+
514
+ /** "{n} items hidden by filters", with the way out beside it. */
515
+ function BoardHiddenSummary({
516
+ count,
517
+ noun,
518
+ onClearFilters,
519
+ }: {
520
+ count: number
521
+ noun: { one: string; many: string }
522
+ onClearFilters?: () => void
523
+ }) {
524
+ return (
525
+ <div className="ds-board-hidden-summary">
526
+ {/* Announced on change: the count moves as the operator types, and the
527
+ number of things they can no longer see is worth hearing. */}
528
+ <span role="status" className="ds-board-hidden-summary-text tabular-nums">
529
+ {count} {count === 1 ? noun.one : noun.many} hidden by filters
530
+ </span>
531
+ {onClearFilters ? (
532
+ // Secondary, never ghost: a button reads as a button (CLAUDE.md §2).
533
+ // The trailing glyph is the reference's `Clear filters ✕` — decoration
534
+ // on a labelled button, so it is aria-hidden and carries no name of its
535
+ // own.
536
+ <Button
537
+ variant="secondary"
538
+ size="sm"
539
+ onClick={onClearFilters}
540
+ trailingIcon={<X size={13} strokeWidth={2} aria-hidden="true" />}
541
+ >
542
+ Clear filters
543
+ </Button>
544
+ ) : null}
545
+ </div>
546
+ )
547
+ }
548
+
549
+ export interface BoardColumnProps {
550
+ /**
551
+ * Document-outline level for the column heading. Default 3.
552
+ * Set it to match the host page: a lens with its own `<h1>` passes 2 so the
553
+ * outline reads h1 -> h2 -> h3 without a skip. Visual weight is unaffected.
554
+ */
555
+ headingLevel?: HeadingLevel
556
+ spec: BoardColumnSpec
557
+ /** Cards currently rendered in this lane. */
558
+ count: number
559
+ /**
560
+ * Cards in this lane before filtering. When it exceeds `count` the header
561
+ * reads `visible / total`, so the number beside the ring is never mistaken
562
+ * for the whole lane.
563
+ */
564
+ total?: number
565
+ collapsed?: boolean
566
+ onToggleCollapsed?: (() => void) | undefined
567
+ isOver?: boolean | undefined
568
+ setNodeRef?: ((node: HTMLElement | null) => void) | undefined
569
+ children?: ReactNode
570
+ className?: string | undefined
571
+ }
572
+
573
+ export function BoardColumn({
574
+ headingLevel = 3,
575
+ spec,
576
+ count,
577
+ total,
578
+ collapsed = false,
579
+ onToggleCollapsed,
580
+ isOver = false,
581
+ setNodeRef,
582
+ children,
583
+ className,
584
+ }: BoardColumnProps) {
585
+ const headingId = useId()
586
+ const Caret = collapsed ? ChevronRight : ChevronDown
587
+
588
+ return (
589
+ <section
590
+ ref={setNodeRef}
591
+ aria-labelledby={headingId}
592
+ className={cn('ds-board-column', hueClass(spec.hue), className)}
593
+ data-over={isOver ? 'true' : undefined}
594
+ // The hook the CSS needs to let ONE lane shrink. The scroller stretches
595
+ // its items so an expanded lane reaches the bottom of the page; without
596
+ // a per-item override a collapsed lane keeps the height of the tallest
597
+ // expanded one, and only the all-collapsed case appears to work.
598
+ data-collapsed={collapsed ? 'true' : undefined}
599
+ >
600
+ <header className="ds-board-column-head">
601
+ {onToggleCollapsed ? (
602
+ <Button
603
+ variant="icon"
604
+ size="sm"
605
+ aria-label={collapsed ? `Expand ${spec.label}` : `Collapse ${spec.label}`}
606
+ aria-expanded={!collapsed}
607
+ onClick={onToggleCollapsed}
608
+ >
609
+ <Caret size={15} strokeWidth={2} aria-hidden="true" />
610
+ </Button>
611
+ ) : null}
612
+ <LaneMarker spec={spec} />
613
+ <ColumnHeading id={headingId} className="ds-board-column-label" level={headingLevel}>
614
+ {spec.label}
615
+ </ColumnHeading>
616
+ {/* `2 / 9` under a filter, plain `9` otherwise — so the count and the
617
+ ring never look like two measurements of the same set. */}
618
+ {typeof total === 'number' && total > count ? (
619
+ <span className="ds-board-column-count tabular-nums">
620
+ <span aria-hidden="true">
621
+ {count} / {total}
622
+ </span>
623
+ <span className="sr-only">{`${count} of ${total} shown`}</span>
624
+ </span>
625
+ ) : (
626
+ <span className="ds-board-column-count tabular-nums">{count}</span>
627
+ )}
628
+ {/* Adjacent bordered controls sit 12px apart (interface-standards §3). */}
629
+ {spec.onAdd || spec.menu ? (
630
+ <span className="ds-board-column-actions">
631
+ {spec.onAdd ? (
632
+ <Button
633
+ variant="icon"
634
+ size="sm"
635
+ aria-label={spec.addLabel ?? `Add to ${spec.label}`}
636
+ onClick={spec.onAdd}
637
+ >
638
+ <Plus size={15} strokeWidth={2} aria-hidden="true" />
639
+ </Button>
640
+ ) : null}
641
+ {spec.menu}
642
+ </span>
643
+ ) : null}
644
+ </header>
645
+
646
+ {collapsed ? null : (
647
+ <div className="ds-board-column-body">
648
+ {count === 0 ? (
649
+ <p className="ds-board-column-empty">
650
+ {spec.emptyMessage ?? `Nothing in ${spec.label} yet. Drag one here, or add one.`}
651
+ </p>
652
+ ) : null}
653
+ {children}
654
+ </div>
655
+ )}
656
+ </section>
657
+ )
658
+ }