@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
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
|
+
}
|