@charcuterie/ui 3.22.0 → 3.23.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.
@@ -0,0 +1,149 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import { toClassName } from "../toClassName.ts"
5
+ import type { ReorderListItem } from "./ReorderList.tsx"
6
+ import { ReorderList } from "./ReorderList.tsx"
7
+
8
+ /**
9
+ * **Invented, all of it** — the same rule `Board.stories.tsx` and
10
+ * `DropRail.stories.tsx` state, for the same reason. The lists this
11
+ * component was built for are a real household's, a published repo
12
+ * must never carry that, and a committed screenshot is worse than a
13
+ * committed string because no text scrub can reach inside a PNG.
14
+ */
15
+ const CHECKLIST: ReorderListItem[] = [
16
+ { key: "measure", label: "Measure the alcove" },
17
+ { key: "cut", label: "Cut the shelf to width" },
18
+ { key: "sand", label: "Sand the cut edge" },
19
+ { key: "mount", label: "Mount the brackets" },
20
+ { key: "level", label: "Check it is level" },
21
+ ]
22
+
23
+ /**
24
+ * The two buttons and the handle, drawn the way a host draws them.
25
+ *
26
+ * The **position is the handle**, which is not a stylistic choice: a
27
+ * list that is ordered wants its position printed anyway, so the
28
+ * number costs the row nothing — and this library ships no icons, so
29
+ * a `⠿` would render as an empty box in any font that lacks it.
30
+ */
31
+ const StoryList = ({
32
+ initialItems = CHECKLIST,
33
+ isSingle = false,
34
+ }: {
35
+ initialItems?: ReorderListItem[]
36
+ isSingle?: boolean
37
+ }) => {
38
+ const [items, setItems] = useState(
39
+ isSingle ? initialItems.slice(0, 1) : initialItems,
40
+ )
41
+
42
+ return (
43
+ <ReorderList
44
+ className="flex max-w-md flex-col gap-1"
45
+ items={items}
46
+ itemClassName="list-none"
47
+ label="Shelf build"
48
+ onReorder={(fromIndex, toIndex) => {
49
+ setItems((current) => {
50
+ const next = [...current]
51
+
52
+ const [moved] = next.splice(fromIndex, 1)
53
+
54
+ if (moved) {
55
+ next.splice(toIndex, 0, moved)
56
+ }
57
+
58
+ return next
59
+ })
60
+ }}
61
+ renderItem={({
62
+ gripProps,
63
+ isDragged,
64
+ isFirst,
65
+ isLast,
66
+ item,
67
+ moveBy,
68
+ position,
69
+ }) => (
70
+ <div
71
+ className={toClassName(
72
+ "flex items-center gap-2 rounded-md border border-border-subtle bg-surface-raised p-2",
73
+ isDragged && "opacity-50",
74
+ )}
75
+ >
76
+ <button
77
+ aria-label={`Move ${item.label} earlier`}
78
+ className="rounded-sm px-1 text-content-secondary hover:bg-surface-sunken disabled:opacity-40"
79
+ disabled={isFirst}
80
+ onClick={() => {
81
+ moveBy(-1)
82
+ }}
83
+ type="button"
84
+ >
85
+ ↑
86
+ </button>
87
+
88
+ <span
89
+ className="cursor-grab select-none rounded-sm px-1 text-content-muted text-xs tabular-nums"
90
+ {...gripProps}
91
+ >
92
+ {position}
93
+ </span>
94
+
95
+ <button
96
+ aria-label={`Move ${item.label} later`}
97
+ className="rounded-sm px-1 text-content-secondary hover:bg-surface-sunken disabled:opacity-40"
98
+ disabled={isLast}
99
+ onClick={() => {
100
+ moveBy(1)
101
+ }}
102
+ type="button"
103
+ >
104
+ ↓
105
+ </button>
106
+
107
+ <span className="text-content-primary text-sm">
108
+ {item.label}
109
+ </span>
110
+ </div>
111
+ )}
112
+ />
113
+ )
114
+ }
115
+
116
+ const meta = {
117
+ title: "Components/Layout/ReorderList",
118
+ component: ReorderList,
119
+ parameters: { layout: "padded" },
120
+ args: {
121
+ items: CHECKLIST,
122
+ label: "Shelf build",
123
+ onReorder: () => {},
124
+ renderItem: ({ item }) => item.label,
125
+ },
126
+ } satisfies Meta<typeof ReorderList<ReorderListItem>>
127
+
128
+ export default meta
129
+
130
+ type Story = StoryObj<typeof meta>
131
+
132
+ /**
133
+ * A checklist in the order it will be worked through. Press ↑ or ↓,
134
+ * or press and hold the position and drag it somewhere else.
135
+ */
136
+ export const Default: Story = {
137
+ render: () => <StoryList />,
138
+ }
139
+
140
+ /**
141
+ * **One row gets no controls at all.** A single item cannot be put
142
+ * anywhere else, so the handle is inert and the buttons are both
143
+ * disabled — the component says so through `gripProps` being empty,
144
+ * rather than leaving each host to remember it. Every hand-rolled
145
+ * copy of this shape forgot.
146
+ */
147
+ export const SingleItem: Story = {
148
+ render: () => <StoryList isSingle />,
149
+ }
@@ -0,0 +1,190 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import {
3
+ expect,
4
+ userEvent,
5
+ type within,
6
+ } from "storybook/test"
7
+ import { test } from "vitest"
8
+
9
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
10
+ import { mountStory } from "../mountStory.testHelpers.ts"
11
+ import * as stories from "./ReorderList.stories.tsx"
12
+
13
+ const { Default, SingleItem } = composeStories(stories)
14
+
15
+ /**
16
+ * The rows come back in the DOM order the list draws them, which is
17
+ * the order under test. Reading the labels rather than the positions
18
+ * is deliberate: the printed number is derived from the index, so
19
+ * asserting on it would pass even if the rows had not moved.
20
+ */
21
+ const getRowLabels = (canvas: ReturnType<typeof within>) =>
22
+ canvas
23
+ .getAllByRole("listitem")
24
+ .map((row: HTMLElement) => row.textContent ?? "")
25
+ .filter((text: string) => text !== "")
26
+
27
+ test("moves a row later with the button, and says where it went", async () => {
28
+ const { canvas } = await mountStory(Default)
29
+
30
+ await userEvent.click(
31
+ canvas.getByRole("button", {
32
+ name: "Move Measure the alcove later",
33
+ }),
34
+ )
35
+
36
+ expect(getRowLabels(canvas)[0]).toContain(
37
+ "Cut the shelf to width",
38
+ )
39
+
40
+ expect(getRowLabels(canvas)[1]).toContain(
41
+ "Measure the alcove",
42
+ )
43
+
44
+ /*
45
+ * The POSITION, not just the fact of a move. "Moved" leaves a
46
+ * screen-reader user unable to tell the top of a list from the
47
+ * bottom of thirty.
48
+ */
49
+ expect(
50
+ canvas.getByRole("status", {
51
+ name: "Shelf build activity",
52
+ }),
53
+ ).toHaveTextContent(
54
+ "Moved Measure the alcove to position 2 of 5.",
55
+ )
56
+ })
57
+
58
+ test("moves a row earlier, and the ends stay disabled", async () => {
59
+ const { canvas } = await mountStory(Default)
60
+
61
+ expect(
62
+ canvas.getByRole("button", {
63
+ name: "Move Measure the alcove earlier",
64
+ }),
65
+ ).toBeDisabled()
66
+
67
+ expect(
68
+ canvas.getByRole("button", {
69
+ name: "Move Check it is level later",
70
+ }),
71
+ ).toBeDisabled()
72
+
73
+ await userEvent.click(
74
+ canvas.getByRole("button", {
75
+ name: "Move Sand the cut edge earlier",
76
+ }),
77
+ )
78
+
79
+ expect(getRowLabels(canvas)[1]).toContain(
80
+ "Sand the cut edge",
81
+ )
82
+ })
83
+
84
+ /**
85
+ * The handle is a pointer enhancement, so it must never be the only
86
+ * way to move a row — and on a list that cannot be reordered it must
87
+ * not advertise a gesture that does nothing.
88
+ */
89
+ test("a one-row list offers no drag handle", async () => {
90
+ const { canvas } = await mountStory(SingleItem)
91
+
92
+ expect(canvas.queryByTitle("Drag to reorder")).toBeNull()
93
+ })
94
+
95
+ test("the handle takes the gesture away from the browser's scroll", async () => {
96
+ const { canvas } = await mountStory(Default)
97
+
98
+ const grip = canvas.getAllByTitle("Drag to reorder")[0]
99
+
100
+ /*
101
+ * `touch-action: none` is the whole difference between a drag
102
+ * that works on a desktop and one that also works on the tablet
103
+ * this component was built for. Without it the browser claims
104
+ * the gesture for a scroll and the pointermove stream stops one
105
+ * frame in.
106
+ */
107
+ expect(grip).toHaveStyle({ touchAction: "none" })
108
+ })
109
+
110
+ /**
111
+ * The gesture itself, driven as a real pointer rather than as
112
+ * dispatched `PointerEvent`s.
113
+ *
114
+ * A hand-built event is not enough here, and the difference is not
115
+ * pedantry: `setPointerCapture` rejects an id that matches no
116
+ * ACTIVE pointer, so a synthetic sequence dies inside the handler
117
+ * before a single listener is registered — and the test then
118
+ * passes or fails for a reason the component does not have.
119
+ */
120
+ test("drags a row past the one below it", async () => {
121
+ const { canvas } = await mountStory(Default)
122
+
123
+ const grip = canvas.getAllByTitle("Drag to reorder")[0]
124
+
125
+ const secondRow = canvas.getAllByRole("listitem")[1]
126
+
127
+ if (!grip || !secondRow) {
128
+ throw new Error("The story drew no rows to drag.")
129
+ }
130
+
131
+ const to = secondRow.getBoundingClientRect()
132
+
133
+ await userEvent.pointer([
134
+ { keys: "[MouseLeft>]", target: grip },
135
+ {
136
+ coords: {
137
+ x: to.x + to.width / 2,
138
+ y: to.y + to.height - 2,
139
+ },
140
+ },
141
+ { keys: "[/MouseLeft]" },
142
+ ])
143
+
144
+ expect(getRowLabels(canvas)[0]).toContain(
145
+ "Cut the shelf to width",
146
+ )
147
+
148
+ expect(getRowLabels(canvas)[1]).toContain(
149
+ "Measure the alcove",
150
+ )
151
+ })
152
+
153
+ /**
154
+ * A press that goes nowhere must move nothing.
155
+ *
156
+ * The movement threshold is the reason the position can be both a
157
+ * printed number and a handle: without it, a handle is a control
158
+ * you cannot press by accident and then change your mind about,
159
+ * and every stray tap on a row reorders the list.
160
+ */
161
+ test("a press with no movement reorders nothing", async () => {
162
+ const { canvas } = await mountStory(Default)
163
+
164
+ const grip = canvas.getAllByTitle("Drag to reorder")[0]
165
+
166
+ if (!grip) {
167
+ throw new Error("The story drew no rows to drag.")
168
+ }
169
+
170
+ await userEvent.pointer([
171
+ { keys: "[MouseLeft>]", target: grip },
172
+ { keys: "[/MouseLeft]", target: grip },
173
+ ])
174
+
175
+ expect(getRowLabels(canvas)[0]).toContain(
176
+ "Measure the alcove",
177
+ )
178
+
179
+ expect(
180
+ canvas.getByRole("status", {
181
+ name: "Shelf build activity",
182
+ }),
183
+ ).toHaveTextContent("")
184
+ })
185
+
186
+ test("has no axe violations", async () => {
187
+ const { canvasElement } = await mountStory(Default)
188
+
189
+ await expectNoAxeViolations(canvasElement)
190
+ })
@@ -0,0 +1,331 @@
1
+ import type { ReactNode, Ref } from "react"
2
+ import { useCallback, useState } from "react"
3
+
4
+ import {
5
+ getIsMoveMeaningful,
6
+ toSettledIndex,
7
+ } from "../Board/boardMove.ts"
8
+ import { useBoardDrag } from "../Board/useBoardDrag.ts"
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
11
+
12
+ /**
13
+ * The attribute the drag's hit test reads on a row. A `data-`
14
+ * attribute rather than a class, the same contract `BoardCard` and
15
+ * `DropRail` already write down: a class is a style and a
16
+ * consumer's tooling may rename it.
17
+ */
18
+ const ITEM_ATTRIBUTE = "data-reorder-item"
19
+
20
+ /**
21
+ * The single lane this component registers with the board's drag.
22
+ * A reorder list is a board with one lane and no lane picker, so
23
+ * the key is a constant and never leaves this file.
24
+ */
25
+ const LANE_KEY = "reorder-list"
26
+
27
+ export type ReorderListItem = {
28
+ key: string
29
+ /**
30
+ * Plain text, and it is not optional. Every accessible name the
31
+ * component writes is built from it — "Move <label> earlier" —
32
+ * and so is the sentence the live region reads after a move. A
33
+ * button announced as "↑" names nothing.
34
+ */
35
+ label: string
36
+ }
37
+
38
+ export type ReorderListRenderArguments<
39
+ TItem extends ReorderListItem,
40
+ > = {
41
+ /**
42
+ * Spread onto whatever the host wants the drag to start FROM.
43
+ * Absent — as an empty object — when the list is too short to
44
+ * reorder, so a one-row list has no dead handle on it.
45
+ *
46
+ * It is a **handle**, never the whole row: a row holds links,
47
+ * and a link is draggable already.
48
+ */
49
+ gripProps: {
50
+ onPointerDown?: (event: {
51
+ clientX: number
52
+ clientY: number
53
+ currentTarget: EventTarget & Element
54
+ pointerId: number
55
+ }) => void
56
+ style?: { touchAction: "none" }
57
+ title?: string
58
+ }
59
+ index: number
60
+ isDragged: boolean
61
+ isFirst: boolean
62
+ isLast: boolean
63
+ item: TItem
64
+ /**
65
+ * The KEYBOARD path, and it is the primary one. The host draws
66
+ * the two buttons because only the host knows where they sit in
67
+ * its own row; the component supplies what they do and whether
68
+ * each end is disabled.
69
+ */
70
+ moveBy: (offset: -1 | 1) => void
71
+ /** 1-based, for printing. */
72
+ position: number
73
+ }
74
+
75
+ export type ReorderListProps<
76
+ TItem extends ReorderListItem,
77
+ > = {
78
+ className?: string
79
+ /**
80
+ * The container element. `ul` by default, because an ordered
81
+ * pile of rows is a list; `div` for a host whose own CSS grid
82
+ * owns the shape and would have to undo list semantics to get
83
+ * it.
84
+ */
85
+ elementType?: "div" | "ol" | "ul"
86
+ /**
87
+ * The container element, for a host that needs it too.
88
+ *
89
+ * The component keeps its own hold on that element — the drag
90
+ * measures its rows off it — so this is MERGED rather than
91
+ * forwarded. Docket plays a FLIP animation off the same
92
+ * element, and without this the two uses would have to fight
93
+ * over one `ref`.
94
+ */
95
+ elementRef?: Ref<HTMLElement>
96
+ itemClassName?: string
97
+ items: readonly TItem[]
98
+ /** The list's accessible name. */
99
+ label: string
100
+ /**
101
+ * `fromIndex` and `toIndex` are both indexes into `items`, and
102
+ * `toIndex` is already corrected for the row's own removal — so
103
+ * a handler is `splice(from, 1)` then `splice(to, 0, row)` with
104
+ * no arithmetic of its own.
105
+ */
106
+ onReorder: (fromIndex: number, toIndex: number) => void
107
+ renderItem: (
108
+ renderArguments: ReorderListRenderArguments<TItem>,
109
+ ) => ReactNode
110
+ }
111
+
112
+ /**
113
+ * A vertical list whose rows can be put in a different order — by
114
+ * two buttons, or by dragging a handle.
115
+ *
116
+ * ### Why it is not a fourth hand-rolled reorder
117
+ *
118
+ * Docket wrote this twice: a subtask checklist on HTML5
119
+ * `draggable`, and a phase queue with the buttons and no drag at
120
+ * all. `Board` wrote a third, properly, on Pointer Events. Three
121
+ * implementations of one shape is the case the workspace's
122
+ * component rule exists for, so the geometry here is **`Board`'s
123
+ * own** — `useBoardDrag` with a single registered lane and the
124
+ * item selector pointed at this component's rows. Nothing about
125
+ * the measuring, the snapshot, the threshold or the pointer
126
+ * capture is written twice.
127
+ *
128
+ * ### The buttons are primary, the drag is the enhancement
129
+ *
130
+ * WCAG 2.5.7 requires a single-pointer alternative to any dragging
131
+ * movement, and the board's decision already argues the stronger
132
+ * version: the buttons are not a fallback bolted onto a drag, they
133
+ * are the only path that works from a keyboard, under a screen
134
+ * reader, or under a thumb in the Narrow View. So `renderItem`
135
+ * receives `moveBy` before it receives `gripProps`, and a host
136
+ * that draws no handle at all is a supported host.
137
+ *
138
+ * ### It takes no drag-and-drop dependency
139
+ *
140
+ * The board priced four libraries at 7–32 KB gzipped and took none
141
+ * of them, because a published library's dependency list is
142
+ * inherited by every consumer
143
+ * ([decision](../../../docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md)).
144
+ * This adds nothing to that arithmetic — it is the same 1.4 KB,
145
+ * called twice.
146
+ *
147
+ * ### `draggable="true"` is the instinct this replaces
148
+ *
149
+ * Native HTML5 drag-and-drop **does not fire on touch at all**, so
150
+ * the checklist Docket shipped on it cannot be reordered on the
151
+ * tablet its owner reads it on. Pointer Events are one code path
152
+ * for mouse, touch and pen, and `setPointerCapture` keeps the drag
153
+ * alive once the row has moved out from under the finger.
154
+ *
155
+ * ### What it deliberately does not do
156
+ *
157
+ * It renders no row, no handle, no buttons and no glyph. The host
158
+ * renders all four through `renderItem`, which is what lets one
159
+ * component serve a checklist, a queue and a card list whose CSS
160
+ * grids have nothing in common.
161
+ */
162
+ export const ReorderList = <TItem extends ReorderListItem>({
163
+ className,
164
+ elementRef,
165
+ elementType = "ul",
166
+ itemClassName,
167
+ items,
168
+ label,
169
+ onReorder,
170
+ renderItem,
171
+ }: ReorderListProps<TItem>): ReactNode => {
172
+ const [announcement, setAnnouncement] = useState("")
173
+
174
+ /**
175
+ * A single row cannot be put anywhere else, so it gets no grip
176
+ * and no buttons. This is checked here rather than in every
177
+ * host, because "hide the control when it can do nothing" is the
178
+ * part each hand-rolled copy forgot.
179
+ */
180
+ const isReorderable = items.length > 1
181
+
182
+ const commit = useCallback(
183
+ (fromIndex: number, toIndex: number) => {
184
+ setAnnouncement(
185
+ `Moved ${items[fromIndex]?.label ?? ""} to position ${String(toIndex + 1)} of ${String(items.length)}.`,
186
+ )
187
+
188
+ onReorder(fromIndex, toIndex)
189
+ },
190
+ [items, onReorder],
191
+ )
192
+
193
+ const { drag, registerLane, startDrag } = useBoardDrag({
194
+ itemSelector: `[${ITEM_ATTRIBUTE}]`,
195
+ onDrop: (origin, target) => {
196
+ if (
197
+ !getIsMoveMeaningful({
198
+ fromIndex: origin.fromIndex,
199
+ fromLaneKey: origin.fromLaneKey,
200
+ toIndex: target.index,
201
+ toLaneKey: target.laneKey,
202
+ })
203
+ ) {
204
+ return
205
+ }
206
+
207
+ commit(
208
+ origin.fromIndex,
209
+ toSettledIndex({
210
+ fromIndex: origin.fromIndex,
211
+ fromLaneKey: origin.fromLaneKey,
212
+ toIndex: target.index,
213
+ toLaneKey: target.laneKey,
214
+ }),
215
+ )
216
+ },
217
+ })
218
+
219
+ const ListElement = elementType
220
+
221
+ const ItemElement =
222
+ elementType === "div" ? "div" : ("li" as const)
223
+
224
+ return (
225
+ <>
226
+ <ListElement
227
+ aria-label={label}
228
+ className={toClassName("relative", className)}
229
+ ref={(element: HTMLElement | null) => {
230
+ registerLane(LANE_KEY)(element)
231
+
232
+ if (typeof elementRef === "function") {
233
+ elementRef(element)
234
+
235
+ return
236
+ }
237
+
238
+ if (elementRef) {
239
+ elementRef.current = element
240
+ }
241
+ }}
242
+ >
243
+ {items.map((item, index) => (
244
+ <ItemElement
245
+ className={itemClassName}
246
+ key={item.key}
247
+ {...{ [ITEM_ATTRIBUTE]: item.key }}
248
+ >
249
+ {renderItem({
250
+ gripProps: isReorderable
251
+ ? {
252
+ onPointerDown: (pointerEvent) => {
253
+ startDrag(pointerEvent, {
254
+ fromIndex: index,
255
+ fromLaneKey: LANE_KEY,
256
+ itemKey: item.key,
257
+ })
258
+ },
259
+ /*
260
+ * Without it the browser claims the gesture
261
+ * for a scroll and the `pointermove` stream
262
+ * stops one frame in — which reads as "drag
263
+ * works on the desktop and not on the
264
+ * tablet", the exact bug this component
265
+ * exists to end.
266
+ */
267
+ style: { touchAction: "none" },
268
+ title: "Drag to reorder",
269
+ }
270
+ : {},
271
+ index,
272
+ isDragged: drag?.itemKey === item.key,
273
+ isFirst: index === 0,
274
+ isLast: index === items.length - 1,
275
+ item,
276
+ moveBy: (offset) => {
277
+ const toIndex = index + offset
278
+
279
+ if (
280
+ !isReorderable ||
281
+ toIndex < 0 ||
282
+ toIndex > items.length - 1
283
+ ) {
284
+ return
285
+ }
286
+
287
+ commit(index, toIndex)
288
+ },
289
+ position: index + 1,
290
+ })}
291
+ </ItemElement>
292
+ ))}
293
+
294
+ {/*
295
+ * A real element rather than a border on a row, and
296
+ * absolutely positioned against a snapshot taken at grab.
297
+ * Inserting it must not move the rows below it: that
298
+ * feedback loop is what makes an indicator jump, and the
299
+ * reasoning is written out in `useBoardDrag.ts`.
300
+ */}
301
+ {drag?.indicatorOffset == null ? null : (
302
+ <ItemElement
303
+ aria-hidden="true"
304
+ className="pointer-events-none absolute end-0 start-0 h-0.5 rounded-full bg-intent-accent-solid"
305
+ style={{
306
+ insetBlockStart: `${String(drag.indicatorOffset)}px`,
307
+ }}
308
+ />
309
+ )}
310
+ </ListElement>
311
+
312
+ {/*
313
+ * `aria-label` on a `role="status"`, per the library's own
314
+ * rule — a live region with no name is announced with no
315
+ * context, and this one fires while the reader's attention
316
+ * is on a row rather than on the list.
317
+ *
318
+ * It names the POSITION, because "moved" leaves a
319
+ * screen-reader user unable to tell the top of a list from
320
+ * the bottom of thirty. That is the one piece of feedback a
321
+ * sighted user gets free from watching the row land.
322
+ */}
323
+ <VisuallyHidden
324
+ aria-label={`${label} activity`}
325
+ role="status"
326
+ >
327
+ {announcement}
328
+ </VisuallyHidden>
329
+ </>
330
+ )
331
+ }
package/src/index.ts CHANGED
@@ -316,6 +316,12 @@ export type {
316
316
  RailSide,
317
317
  } from "./Rail/Rail.tsx"
318
318
  export { Rail } from "./Rail/Rail.tsx"
319
+ export type {
320
+ ReorderListItem,
321
+ ReorderListProps,
322
+ ReorderListRenderArguments,
323
+ } from "./ReorderList/ReorderList.tsx"
324
+ export { ReorderList } from "./ReorderList/ReorderList.tsx"
319
325
  export { AnchorLink } from "./RouterLink/AnchorLink.tsx"
320
326
  export {
321
327
  RouterLinkProvider,
@@ -512,7 +512,20 @@ test("the barrel is the only place components are re-exported", async () => {
512
512
  // is too, while still being exported from the barrel — an app that
513
513
  // marks a destination current anywhere else must ask the same
514
514
  // question the same way.
515
- expect(componentNames.length).toBe(58)
515
+ //
516
+ // `ReorderList` — a vertical list put in a different order, by
517
+ // two buttons or by dragging a handle — is +1 -> 59. It is the
518
+ // clearest case yet for the fleet's build-it-here rule: Docket
519
+ // had written the shape twice (a subtask checklist on HTML5
520
+ // `draggable`, which does not fire on touch at all, and a phase
521
+ // queue with the buttons and no drag), and `Board` had written
522
+ // it a third time correctly. So this one does not fork the
523
+ // geometry — it CALLS `useBoardDrag` with a single registered
524
+ // lane and the item selector pointed at its own rows, the way
525
+ // `VirtualizedGrid` calls `useAdaptiveColumns`. It renders no
526
+ // row, no handle and no buttons; `renderItem` is what lets one
527
+ // component serve three grids that have nothing in common.
528
+ expect(componentNames.length).toBe(59)
516
529
 
517
530
  for (const name of componentNames) {
518
531
  expect(barrel).toContain(`export { ${name} }`)