@charcuterie/ui 3.22.0 → 3.24.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 (76) hide show
  1. package/README.md +11 -0
  2. package/dist/Board/Board.d.ts +6 -3
  3. package/dist/Board/Board.d.ts.map +1 -1
  4. package/dist/Board/Board.js +19 -1
  5. package/dist/Board/Board.js.map +1 -1
  6. package/dist/Board/BoardCard.d.ts +42 -7
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +54 -8
  9. package/dist/Board/BoardCard.js.map +1 -1
  10. package/dist/Board/useBoardDrag.d.ts +15 -1
  11. package/dist/Board/useBoardDrag.d.ts.map +1 -1
  12. package/dist/Board/useBoardDrag.js +3 -3
  13. package/dist/Board/useBoardDrag.js.map +1 -1
  14. package/dist/Nav/Nav.d.ts +167 -0
  15. package/dist/Nav/Nav.d.ts.map +1 -0
  16. package/dist/Nav/Nav.js +165 -0
  17. package/dist/Nav/Nav.js.map +1 -0
  18. package/dist/Nav/NavLink.d.ts +43 -0
  19. package/dist/Nav/NavLink.d.ts.map +1 -0
  20. package/dist/Nav/NavLink.js +111 -0
  21. package/dist/Nav/NavLink.js.map +1 -0
  22. package/dist/Nav/navItems.d.ts +67 -0
  23. package/dist/Nav/navItems.d.ts.map +1 -0
  24. package/dist/Nav/navItems.js +104 -0
  25. package/dist/Nav/navItems.js.map +1 -0
  26. package/dist/Nav/useNavLayout.d.ts +86 -0
  27. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  28. package/dist/Nav/useNavLayout.js +114 -0
  29. package/dist/Nav/useNavLayout.js.map +1 -0
  30. package/dist/NavBar/NavBar.d.ts +37 -111
  31. package/dist/NavBar/NavBar.d.ts.map +1 -1
  32. package/dist/NavBar/NavBar.js +30 -110
  33. package/dist/NavBar/NavBar.js.map +1 -1
  34. package/dist/ReorderList/ReorderList.d.ts +131 -0
  35. package/dist/ReorderList/ReorderList.d.ts.map +1 -0
  36. package/dist/ReorderList/ReorderList.js +154 -0
  37. package/dist/ReorderList/ReorderList.js.map +1 -0
  38. package/dist/index.d.ts +8 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Board/Board.mdx +25 -2
  44. package/src/Board/Board.stories.tsx +52 -1
  45. package/src/Board/Board.test.tsx +82 -0
  46. package/src/Board/Board.tsx +24 -4
  47. package/src/Board/BoardCard.tsx +103 -21
  48. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  49. package/src/Board/useBoardDrag.ts +17 -2
  50. package/src/Nav/Nav.mdx +169 -0
  51. package/src/Nav/Nav.stories.tsx +402 -0
  52. package/src/Nav/Nav.test.tsx +338 -0
  53. package/src/Nav/Nav.tsx +406 -0
  54. package/src/Nav/NavLink.tsx +220 -0
  55. package/src/Nav/navItems.test.ts +163 -0
  56. package/src/Nav/navItems.ts +175 -0
  57. package/src/Nav/useNavLayout.ts +197 -0
  58. package/src/NavBar/NavBar.mdx +26 -0
  59. package/src/NavBar/NavBar.tsx +54 -240
  60. package/src/ReorderList/ReorderList.mdx +83 -0
  61. package/src/ReorderList/ReorderList.stories.tsx +149 -0
  62. package/src/ReorderList/ReorderList.test.tsx +190 -0
  63. package/src/ReorderList/ReorderList.tsx +331 -0
  64. package/src/index.ts +26 -1
  65. package/src/sourceRules.test.ts +26 -1
  66. package/dist/NavBar/NavBarLink.d.ts +0 -43
  67. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  68. package/dist/NavBar/NavBarLink.js +0 -67
  69. package/dist/NavBar/NavBarLink.js.map +0 -1
  70. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  71. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  72. package/dist/NavBar/navBarCurrent.js +0 -48
  73. package/dist/NavBar/navBarCurrent.js.map +0 -1
  74. package/src/NavBar/NavBarLink.tsx +0 -139
  75. package/src/NavBar/navBarCurrent.test.ts +0 -81
  76. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -1,271 +1,85 @@
1
- import { useVisibility } from "@charcuterie/logic"
2
1
  import type { ControlSize } from "@charcuterie/tokens"
3
2
  import type { Placement } from "@floating-ui/react"
4
3
  import type { ReactNode } from "react"
5
4
 
6
- import { Button } from "../Button/Button.tsx"
7
- import { IconButton } from "../IconButton/IconButton.tsx"
8
- import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
9
- import { Popover } from "../Popover/Popover.tsx"
10
- import { useToolbarOverflow } from "../Toolbar/useToolbarOverflow.ts"
11
- import { toClassName } from "../toClassName.ts"
12
- import { NavBarLink } from "./NavBarLink.tsx"
13
- import { getIsCurrentHref } from "./navBarCurrent.ts"
5
+ import { Nav } from "../Nav/Nav.tsx"
6
+ import type { NavItem } from "../Nav/navItems.ts"
14
7
 
15
- export type NavBarItem = {
16
- /**
17
- * Where it goes. Routed through the injected `RouterLink` when it
18
- * is this app's own path, and left to the platform when it is not.
19
- */
20
- href: string
21
- /** Decoration beside the label. Never the accessible name. */
22
- icon?: ReactNode
23
- /** Defaults to `href`, which is already unique in a nav. */
24
- key?: string
25
- /** The visible text, and the accessible name. */
26
- label: string
27
- }
8
+ /**
9
+ * @deprecated Use `Nav`'s `NavItem`. Removed in the next major.
10
+ */
11
+ export type NavBarItem = NavItem
28
12
 
13
+ /**
14
+ * @deprecated Use `NavProps` with `layout="bar"`. Removed in the
15
+ * next major.
16
+ */
29
17
  export type NavBarProps = {
30
18
  className?: string
31
- /**
32
- * Where the reader is — `useLocation().pathname` in a
33
- * react-router app. `aria-current="page"` is derived from it by
34
- * `getIsCurrentHref`, which matches a parent path for its
35
- * children and treats `/` as exact.
36
- *
37
- * Omitted means nothing is current, which is the honest answer on
38
- * a route no destination owns.
39
- */
40
19
  currentHref?: string
41
- /**
42
- * **Initial** only, and the bar owns it from then on — the same
43
- * uncontrolled contract every other component here has. It exists
44
- * so a story (and a screenshot) can show the menu open; an app
45
- * almost never passes it.
46
- */
47
20
  isMenuVisible?: boolean
48
21
  items: readonly NavBarItem[]
49
- /**
50
- * Required, and it becomes the `<nav>`'s `aria-label`. Two unnamed
51
- * navigation landmarks in one page is axe's `landmark-unique`, and
52
- * more to the point it is a screen reader announcing "navigation"
53
- * twice with no way to tell which is the main one.
54
- */
55
22
  label: string
56
- /**
57
- * Where the trigger sits in the bar's track once the row has
58
- * folded. `"end"` when the header puts its own actions at the
59
- * far edge, so the fold control joins them instead of hugging
60
- * the wordmark with the whole track empty after it.
61
- *
62
- * It reaches **only** the folded row, which holds exactly one
63
- * item — so it cannot disturb where the links sit when the bar
64
- * is whole, and there is deliberately no prop that can.
65
- */
66
23
  menuAlign?: "end" | "start"
67
- /**
68
- * The collapsed trigger's glyph. **No default** — the library
69
- * ships no icons, and `☰` renders as nothing where the font lacks
70
- * it. With no icon the trigger is a text button reading
71
- * `menuLabel`, which works everywhere.
72
- */
73
24
  menuIcon?: ReactNode
74
- /**
75
- * Names the trigger and, through it, the panel it opens. A
76
- * `role="dialog"` takes no accessible name from its content.
77
- */
78
25
  menuLabel?: string
79
26
  menuPlacement?: Placement
80
27
  size?: ControlSize
81
28
  }
82
29
 
83
30
  /**
84
- * The app's destinations as real links, in one line — folding into a
85
- * single menu button, whole, when that line does not fit.
86
- *
87
- * ## Why this is not `Toolbar`
88
- *
89
- * `Toolbar` already solves collapse-into-an-overflow, and this
90
- * component **reuses its measurement** (`useToolbarOverflow`) rather
91
- * than growing a second one. What it cannot reuse is the rendering:
92
- * a `ToolbarAction` is `{ label, onSelect }` and a collapsed one
93
- * becomes a `MenuItem`, so a nav folded into a `Toolbar` is a row of
94
- * `<button>`s. Navigation is an `<a href>` — see `NavBarLink`.
95
- *
96
- * The overflow is a `Popover` (`role="dialog"`) rather than a `Menu`
97
- * for the same reason: `role="menu"` permits only the `menuitem`
98
- * family, and a `menuitem` is something you *do*, not somewhere you
99
- * *go*.
100
- *
101
- * ## It folds WHOLE, and that is the decision
31
+ * @deprecated Use `Nav` with `layout="bar"` — which is `Nav`'s
32
+ * default, so `<Nav activeHref={…} items={…} label="Main" />` is the
33
+ * whole migration. **Removed in the next major.**
102
34
  *
103
- * `Toolbar` collapses **progressively** — items drop off the end one
104
- * at a time — because its items are ranked: the two things you do to
105
- * a running job matter more than the two you do to the deck. A nav
106
- * is not ranked. Its order is the shape of the product (Docket's
107
- * reads as the pipeline: Triage → Backlog → Phases → Board →
108
- * Archives), and a half-collapsed one splits that shape across two
109
- * places with no rule the reader can learn — "some of the app is up
110
- * here and the rest is behind a button" is worse than either whole
111
- * answer. So the bar shows **every** destination or **none**, and
112
- * the menu, when it opens, is the complete list in the product's own
113
- * order.
35
+ * This is now a thin adapter over `Nav` and keeps every behaviour it
36
+ * shipped with: the measured whole-row fold, the real `<a href>` in
37
+ * both the bar and the panel, and the same props under the same
38
+ * names. Only two things differ, and both are fixes:
114
39
  *
115
- * ## Measured, not breakpointed
40
+ * 1. `currentHref` is `activeHref` on `Nav`.
41
+ * 2. **Two items can no longer both be current.** This component
42
+ * asked the question once per item, so a bar holding `/settings`
43
+ * and `/settings/labels` marked *both* while the reader was on
44
+ * the deeper one — against its own decision record, which says a
45
+ * nav with two current items has stopped saying anything. `Nav`
46
+ * takes the deepest match. If an app was relying on the parent
47
+ * also lighting up, it was relying on a defect.
116
48
  *
117
- * There is no `collapseAt` prop and no media query. The bar reads
118
- * the width its links actually have and folds when their sum stops
119
- * fitting, so a longer label, a tenth destination, a
120
- * `data-density="kiosk"` or a reader at 175% browser zoom each move
121
- * the fold on their own. A hardcoded pixel number is wrong the first
122
- * time any of those changes — mux-magic collapses at `480px` and
123
- * plex-channels at `760px`, and neither number has a reason.
49
+ * ## Why it went away
124
50
  *
125
- * ⚠️ **The bar must not be sized by its own contents.** It measures
126
- * its own box to decide what to draw in it, so a container that
127
- * shrink-wraps makes the answer its own input: put it in a
128
- * `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row. The root
129
- * carries `min-w-0 flex-1` for the flex case; a grid track is the
130
- * caller's to declare. Same trap `useAdaptiveColumns` documents from
131
- * the other end.
132
- *
133
- * ## Mounted exactly once
134
- *
135
- * A destination is in the bar or in the panel, never both. The
136
- * fleet's habit is to render the whole nav twice and hide one copy
137
- * with `hidden md:flex` — mux-magic's `PageHeader` across two
138
- * ~55-line blocks, mail-sifter's `TriageQueue` again — which puts
139
- * every link in the DOM at every width, so an agent driving the page
140
- * finds two of each and cannot tell which one a human can see.
51
+ * It answered half the question. A horizontal row that folds is one
52
+ * shape an app's destinations take; a labelled side rail, a
53
+ * collapsed icon rail and a Narrow-View bottom strip are the others,
54
+ * and this component could not become any of them — so an app that
55
+ * wanted a rail was back to hand-rolling, which is the state this
56
+ * was built to end. `Nav` is the same list drawn in whichever of the
57
+ * five places fits, and `useNavLayout` is the width rule that says
58
+ * which.
141
59
  */
142
60
  export const NavBar = ({
143
61
  className,
144
62
  currentHref,
145
- isMenuVisible = false,
63
+ isMenuVisible,
146
64
  items,
147
65
  label,
148
- menuAlign = "start",
66
+ menuAlign,
149
67
  menuIcon,
150
- menuLabel = "Menu",
151
- menuPlacement = "bottom-start",
152
- size = "sm",
153
- }: NavBarProps): ReactNode => {
154
- const itemKeys = items.map(
155
- (item) => item.key ?? item.href,
156
- )
157
-
158
- const { containerRef, trackItem, visibleCount } =
159
- useToolbarOverflow({ itemKeys })
160
-
161
- /**
162
- * All or nothing. `visibleCount` is the progressive answer and
163
- * this is the only thing read off it: the moment one link stops
164
- * fitting, every link goes into the menu together.
165
- *
166
- * The hook's `trackTrigger` is deliberately not wired up, and the
167
- * missing measurement costs nothing here. It exists so a
168
- * progressive bar can ask "how many fit **beside a trigger**",
169
- * which is a question this bar never asks: either every link fits
170
- * with no trigger at all, or the row is one trigger and nothing
171
- * else. `chooseVisibleCount` tests the whole-row case first and
172
- * with the trigger costed at zero, so that branch is exact.
173
- */
174
- const isCollapsed = visibleCount < items.length
175
-
176
- const { hide, isVisible, toggle } = useVisibility({
177
- isVisible: isMenuVisible,
178
- })
179
-
180
- const panelItemSize = usePanelItemSize("lg")
181
-
182
- const renderLink = (
183
- item: NavBarItem,
184
- placement: "bar" | "panel",
185
- ) => (
186
- <NavBarLink
187
- isCurrent={getIsCurrentHref({
188
- currentHref,
189
- href: item.href,
190
- })}
191
- item={item}
192
- key={item.key ?? item.href}
193
- onNavigate={placement === "panel" ? hide : undefined}
194
- placement={placement}
195
- size={placement === "bar" ? size : panelItemSize}
196
- trackElement={
197
- placement === "bar" ? trackItem : undefined
198
- }
199
- />
200
- )
201
-
202
- const trigger =
203
- menuIcon === undefined ? (
204
- <Button
205
- appearance="ghost"
206
- intent="neutral"
207
- onClick={toggle}
208
- size={size}
209
- >
210
- {menuLabel}
211
- </Button>
212
- ) : (
213
- <IconButton
214
- appearance="ghost"
215
- intent="neutral"
216
- label={menuLabel}
217
- onClick={toggle}
218
- size={size}
219
- >
220
- {menuIcon}
221
- </IconButton>
222
- )
223
-
224
- return (
225
- <nav
226
- aria-label={label}
227
- className={toClassName(
228
- "flex min-w-0 flex-1 items-center gap-1",
229
- // Two written-out entries rather than one interpolated
230
- // string: Tailwind's scanner cannot see a class that
231
- // exists only at runtime.
232
- isCollapsed && menuAlign === "end"
233
- ? "justify-end"
234
- : "justify-start",
235
- className,
236
- )}
237
- ref={containerRef}
238
- >
239
- {isCollapsed ? (
240
- <Popover
241
- // The default panel is `max-w-xs p-3` — sized for a
242
- // paragraph of prose. This one holds full-bleed rows that
243
- // bring their own inset, so the padding shrinks to the
244
- // gutter around them and the width is left to the list.
245
- className="max-w-none p-1"
246
- heading={menuLabel}
247
- isVisible={isVisible}
248
- onDismiss={hide}
249
- placement={menuPlacement}
250
- trigger={trigger}
251
- >
252
- {/*
253
- A list, so a screen reader says how many destinations
254
- there are before the reader starts down them — the one
255
- thing the bar's own row communicates by simply being
256
- visible all at once.
257
- */}
258
- <ul className="flex min-w-48 list-none flex-col gap-0.5">
259
- {items.map((item) => (
260
- <li key={item.key ?? item.href}>
261
- {renderLink(item, "panel")}
262
- </li>
263
- ))}
264
- </ul>
265
- </Popover>
266
- ) : (
267
- items.map((item) => renderLink(item, "bar"))
268
- )}
269
- </nav>
270
- )
271
- }
68
+ menuLabel,
69
+ menuPlacement,
70
+ size,
71
+ }: NavBarProps): ReactNode => (
72
+ <Nav
73
+ activeHref={currentHref}
74
+ className={className}
75
+ isMenuVisible={isMenuVisible}
76
+ items={items}
77
+ label={label}
78
+ layout="bar"
79
+ menuAlign={menuAlign}
80
+ menuIcon={menuIcon}
81
+ menuLabel={menuLabel}
82
+ menuPlacement={menuPlacement}
83
+ size={size}
84
+ />
85
+ )
@@ -0,0 +1,83 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ReorderListStories from "./ReorderList.stories";
4
+
5
+ <Meta of={ReorderListStories} />
6
+
7
+ # ReorderList
8
+
9
+ A vertical list put in a different order — by two buttons, or by dragging a handle.
10
+
11
+ <Canvas of={ReorderListStories.Default} />
12
+
13
+ ## Props
14
+
15
+ <Controls />
16
+
17
+ ## Why the library owns this
18
+
19
+ The shape had been written four times before this file existed. Docket wrote it twice: a
20
+ subtask checklist on HTML5 `draggable`, and a phase queue with the buttons and no drag at
21
+ all. `Board` wrote it once, properly, on Pointer Events. Four implementations of one
22
+ behaviour is the case the fleet's build-it-here rule exists for.
23
+
24
+ So this component does not fork the geometry — it **calls** `useBoardDrag` with a single
25
+ registered lane and the item selector pointed at its own rows, the same way
26
+ `VirtualizedGrid` calls `useAdaptiveColumns`. The snapshot, the movement threshold, the
27
+ pointer capture and the drop-index arithmetic exist once in this package, and a fix to any
28
+ of them reaches the board and the list together.
29
+
30
+ ## The buttons are primary, the drag is the enhancement
31
+
32
+ WCAG 2.5.7 requires a single-pointer alternative to any dragging movement, and the board's
33
+ [decision](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md)
34
+ argues the stronger version: the buttons are not a fallback bolted onto a drag. They are the
35
+ only path that works from a keyboard, under a screen reader, or under a thumb in the Narrow
36
+ View.
37
+
38
+ `renderItem` therefore receives `moveBy`, `isFirst` and `isLast` **before** it receives
39
+ `gripProps`, and a host that draws no handle at all is a supported host.
40
+
41
+ ## `draggable="true"` is the instinct this replaces
42
+
43
+ Native HTML5 drag-and-drop costs nothing and is the wrong answer. It **does not fire on
44
+ touch at all**, so a checklist built on it cannot be reordered on a tablet; its drag image is
45
+ a browser-drawn ghost with no styling hook worth using; and `dataTransfer` is readable only
46
+ inside a real `drop`, which makes a live drop indicator awkward for no gain.
47
+
48
+ Pointer Events cost the same — nothing — and are one code path for mouse, touch and pen.
49
+ `setPointerCapture` keeps the drag alive after the row has moved out from under the finger.
50
+
51
+ The handle carries `touch-action: none`. Without it the browser claims the gesture for a
52
+ scroll and the `pointermove` stream stops one frame in, which reads as "the drag works on my
53
+ desktop and not on my tablet".
54
+
55
+ ## One row gets no controls
56
+
57
+ <Canvas of={ReorderListStories.SingleItem} />
58
+
59
+ A single item cannot be put anywhere else. `gripProps` comes back empty and `moveBy` does
60
+ nothing, so a host cannot leave a dead handle on a one-row list by forgetting to check —
61
+ which is the part every hand-rolled copy of this shape did forget.
62
+
63
+ ## What it deliberately does not render
64
+
65
+ No row, no handle, no buttons and no glyph. The host renders all four through `renderItem`,
66
+ which is what lets one component serve a checklist, a phase queue and a card list whose CSS
67
+ grids have nothing in common. The library ships no icons either, so the position number
68
+ doubling as the handle is the pattern to copy: a list that is ordered wants the number
69
+ printed anyway.
70
+
71
+ ## The move is announced, with its position
72
+
73
+ Every commit writes a sentence into a named `role="status"` region: *"Moved Sand the cut
74
+ edge to position 2 of 5."* The **position** is there because "moved" leaves a screen-reader
75
+ user unable to tell the top of a list from the bottom of thirty — the one piece of feedback
76
+ a sighted user gets free from watching the row land, and the one every drag implementation
77
+ forgets to say out loud.
78
+
79
+ ## `toIndex` is already corrected
80
+
81
+ `onReorder(fromIndex, toIndex)` hands back an index into `items` that has already accounted
82
+ for the row's own removal, so a handler is `splice(from, 1)` then `splice(to, 0, row)` with
83
+ no arithmetic of its own. That is `toSettledIndex`, shared with `Board`.
@@ -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
+ }