@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.
- package/README.md +11 -0
- package/dist/Board/Board.d.ts +6 -3
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +19 -1
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +42 -7
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +54 -8
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/Board/useBoardDrag.d.ts +15 -1
- package/dist/Board/useBoardDrag.d.ts.map +1 -1
- package/dist/Board/useBoardDrag.js +3 -3
- package/dist/Board/useBoardDrag.js.map +1 -1
- package/dist/Nav/Nav.d.ts +167 -0
- package/dist/Nav/Nav.d.ts.map +1 -0
- package/dist/Nav/Nav.js +165 -0
- package/dist/Nav/Nav.js.map +1 -0
- package/dist/Nav/NavLink.d.ts +43 -0
- package/dist/Nav/NavLink.d.ts.map +1 -0
- package/dist/Nav/NavLink.js +111 -0
- package/dist/Nav/NavLink.js.map +1 -0
- package/dist/Nav/navItems.d.ts +67 -0
- package/dist/Nav/navItems.d.ts.map +1 -0
- package/dist/Nav/navItems.js +104 -0
- package/dist/Nav/navItems.js.map +1 -0
- package/dist/Nav/useNavLayout.d.ts +86 -0
- package/dist/Nav/useNavLayout.d.ts.map +1 -0
- package/dist/Nav/useNavLayout.js +114 -0
- package/dist/Nav/useNavLayout.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +37 -111
- package/dist/NavBar/NavBar.d.ts.map +1 -1
- package/dist/NavBar/NavBar.js +30 -110
- package/dist/NavBar/NavBar.js.map +1 -1
- package/dist/ReorderList/ReorderList.d.ts +131 -0
- package/dist/ReorderList/ReorderList.d.ts.map +1 -0
- package/dist/ReorderList/ReorderList.js +154 -0
- package/dist/ReorderList/ReorderList.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +25 -2
- package/src/Board/Board.stories.tsx +52 -1
- package/src/Board/Board.test.tsx +82 -0
- package/src/Board/Board.tsx +24 -4
- package/src/Board/BoardCard.tsx +103 -21
- package/src/Board/boardMoveHandleContainer.test.ts +54 -0
- package/src/Board/useBoardDrag.ts +17 -2
- package/src/Nav/Nav.mdx +169 -0
- package/src/Nav/Nav.stories.tsx +402 -0
- package/src/Nav/Nav.test.tsx +338 -0
- package/src/Nav/Nav.tsx +406 -0
- package/src/Nav/NavLink.tsx +220 -0
- package/src/Nav/navItems.test.ts +163 -0
- package/src/Nav/navItems.ts +175 -0
- package/src/Nav/useNavLayout.ts +197 -0
- package/src/NavBar/NavBar.mdx +26 -0
- package/src/NavBar/NavBar.tsx +54 -240
- package/src/ReorderList/ReorderList.mdx +83 -0
- package/src/ReorderList/ReorderList.stories.tsx +149 -0
- package/src/ReorderList/ReorderList.test.tsx +190 -0
- package/src/ReorderList/ReorderList.tsx +331 -0
- package/src/index.ts +26 -1
- package/src/sourceRules.test.ts +26 -1
- package/dist/NavBar/NavBarLink.d.ts +0 -43
- package/dist/NavBar/NavBarLink.d.ts.map +0 -1
- package/dist/NavBar/NavBarLink.js +0 -67
- package/dist/NavBar/NavBarLink.js.map +0 -1
- package/dist/NavBar/navBarCurrent.d.ts +0 -37
- package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
- package/dist/NavBar/navBarCurrent.js +0 -48
- package/dist/NavBar/navBarCurrent.js.map +0 -1
- package/src/NavBar/NavBarLink.tsx +0 -139
- package/src/NavBar/navBarCurrent.test.ts +0 -81
- package/src/NavBar/navBarCurrent.ts +0 -64
package/src/NavBar/NavBar.tsx
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* the
|
|
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
|
|
63
|
+
isMenuVisible,
|
|
146
64
|
items,
|
|
147
65
|
label,
|
|
148
|
-
menuAlign
|
|
66
|
+
menuAlign,
|
|
149
67
|
menuIcon,
|
|
150
|
-
menuLabel
|
|
151
|
-
menuPlacement
|
|
152
|
-
size
|
|
153
|
-
}: NavBarProps): ReactNode =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|