@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
|
@@ -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
|
@@ -276,12 +276,31 @@ export type {
|
|
|
276
276
|
export { Menu } from "./Menu/Menu.tsx"
|
|
277
277
|
export type { ModalProps } from "./Modal/Modal.tsx"
|
|
278
278
|
export { Modal } from "./Modal/Modal.tsx"
|
|
279
|
+
export type {
|
|
280
|
+
NavLayout,
|
|
281
|
+
NavProps,
|
|
282
|
+
} from "./Nav/Nav.tsx"
|
|
283
|
+
export { Nav } from "./Nav/Nav.tsx"
|
|
284
|
+
export type {
|
|
285
|
+
NavItem,
|
|
286
|
+
NavRailItem,
|
|
287
|
+
} from "./Nav/navItems.ts"
|
|
288
|
+
export {
|
|
289
|
+
getIsCurrentHref,
|
|
290
|
+
resolveActiveKey,
|
|
291
|
+
} from "./Nav/navItems.ts"
|
|
292
|
+
export type {
|
|
293
|
+
NavLayoutMode,
|
|
294
|
+
NavLayoutState,
|
|
295
|
+
ResolvedNavLayout,
|
|
296
|
+
UseNavLayoutOptions,
|
|
297
|
+
} from "./Nav/useNavLayout.ts"
|
|
298
|
+
export { useNavLayout } from "./Nav/useNavLayout.ts"
|
|
279
299
|
export type {
|
|
280
300
|
NavBarItem,
|
|
281
301
|
NavBarProps,
|
|
282
302
|
} from "./NavBar/NavBar.tsx"
|
|
283
303
|
export { NavBar } from "./NavBar/NavBar.tsx"
|
|
284
|
-
export { getIsCurrentHref } from "./NavBar/navBarCurrent.ts"
|
|
285
304
|
export type {
|
|
286
305
|
PickerOption,
|
|
287
306
|
PickerProps,
|
|
@@ -316,6 +335,12 @@ export type {
|
|
|
316
335
|
RailSide,
|
|
317
336
|
} from "./Rail/Rail.tsx"
|
|
318
337
|
export { Rail } from "./Rail/Rail.tsx"
|
|
338
|
+
export type {
|
|
339
|
+
ReorderListItem,
|
|
340
|
+
ReorderListProps,
|
|
341
|
+
ReorderListRenderArguments,
|
|
342
|
+
} from "./ReorderList/ReorderList.tsx"
|
|
343
|
+
export { ReorderList } from "./ReorderList/ReorderList.tsx"
|
|
319
344
|
export { AnchorLink } from "./RouterLink/AnchorLink.tsx"
|
|
320
345
|
export {
|
|
321
346
|
RouterLinkProvider,
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -512,7 +512,32 @@ 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
|
-
|
|
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
|
+
//
|
|
529
|
+
// `Nav` — the destinations as a rail, a collapsed icon rail or a
|
|
530
|
+
// bottom strip — is +1 -> 60. It is the other half of `NavBar`,
|
|
531
|
+
// which folds a horizontal row and cannot become a vertical one:
|
|
532
|
+
// no media query moves an element across `Shell`'s grid, so the
|
|
533
|
+
// rail is a different corner of the page rather than a restyling
|
|
534
|
+
// of the same one. Two members stay out of this count by the
|
|
535
|
+
// `<Name>/<Name>.tsx` rule and are exported from the barrel
|
|
536
|
+
// anyway — `navItems.ts`, because `resolveActiveKey` is the
|
|
537
|
+
// active-path rule an app's tests want to assert directly, and
|
|
538
|
+
// `useNavLayout.ts`, because the app has to read the width rule to
|
|
539
|
+
// know which corner to render into.
|
|
540
|
+
expect(componentNames.length).toBe(60)
|
|
516
541
|
|
|
517
542
|
for (const name of componentNames) {
|
|
518
543
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { ControlSize } from "@charcuterie/tokens";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import type { NavBarItem } from "./NavBar.tsx";
|
|
4
|
-
export type NavBarLinkProps = {
|
|
5
|
-
isCurrent: boolean;
|
|
6
|
-
item: NavBarItem;
|
|
7
|
-
/**
|
|
8
|
-
* Fires when the reader activates this link **in a way that keeps
|
|
9
|
-
* them on the page** — a plain left click, Enter, Space. It is
|
|
10
|
-
* what closes the collapsed panel behind them.
|
|
11
|
-
*
|
|
12
|
-
* A modified click (ctrl, meta, shift, alt) opens a new tab and
|
|
13
|
-
* leaves this document exactly where it was, so it deliberately
|
|
14
|
-
* does not fire: shutting the menu the reader is still working
|
|
15
|
-
* through is the behaviour that makes people stop ctrl-clicking.
|
|
16
|
-
*/
|
|
17
|
-
onNavigate?: () => void;
|
|
18
|
-
/** Where it is drawn. A bar row and a panel row paint differently. */
|
|
19
|
-
placement: "bar" | "panel";
|
|
20
|
-
size: ControlSize;
|
|
21
|
-
/** Hands the bar row's box to the measuring hook. Bar only. */
|
|
22
|
-
trackElement?: (key: string, element: HTMLElement | null) => void;
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* One destination, drawn either in the bar or in the collapsed
|
|
26
|
-
* panel — and it is a real `<a href>` in both.
|
|
27
|
-
*
|
|
28
|
-
* That is the whole reason this component exists rather than the
|
|
29
|
-
* `Menu` a collapsed `Toolbar` opens: `MenuItem` is `{ label,
|
|
30
|
-
* onSelect }`, so a nav folded into one becomes a row of
|
|
31
|
-
* `<button>`s. It looks identical in a screenshot and it loses
|
|
32
|
-
* middle-click, ctrl-click, "open in a new tab", "copy link
|
|
33
|
-
* address", the status bar, and the browser's own visited styling.
|
|
34
|
-
* `BuildingAnApp.mdx` names `<button onClick={navigate}>` as the
|
|
35
|
-
* fleet's most common navigation defect; a collapse mechanism that
|
|
36
|
-
* introduced it at narrow widths would be that defect on a timer.
|
|
37
|
-
*
|
|
38
|
-
* The router seam is `ButtonLink`'s, unchanged: an external
|
|
39
|
-
* destination or a fragment falls back to a plain anchor rather than
|
|
40
|
-
* being pushed onto a history stack that has no route for it.
|
|
41
|
-
*/
|
|
42
|
-
export declare const NavBarLink: ({ isCurrent, item, onNavigate, placement, size, trackElement, }: NavBarLinkProps) => ReactNode;
|
|
43
|
-
//# sourceMappingURL=NavBarLink.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"NavBarLink.d.ts","sourceRoot":"","sources":["../../src/NavBar/NavBarLink.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,OAAO,CAAA;AAUlD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAE9C,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,OAAO,CAAA;IAClB,IAAI,EAAE,UAAU,CAAA;IAChB;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,sEAAsE;IACtE,SAAS,EAAE,KAAK,GAAG,OAAO,CAAA;IAC1B,IAAI,EAAE,WAAW,CAAA;IACjB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,CACb,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GAAG,IAAI,KACxB,IAAI,CAAA;CACV,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,UAAU,GAAI,iEAOxB,eAAe,KAAG,SA4EpB,CAAA"}
|