@charcuterie/ui 3.21.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.
- package/README.md +22 -0
- 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/DropRail/DropRail.d.ts +109 -0
- package/dist/DropRail/DropRail.d.ts.map +1 -0
- package/dist/DropRail/DropRail.js +277 -0
- package/dist/DropRail/DropRail.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +130 -0
- package/dist/NavBar/NavBar.d.ts.map +1 -0
- package/dist/NavBar/NavBar.js +114 -0
- package/dist/NavBar/NavBar.js.map +1 -0
- package/dist/NavBar/NavBarLink.d.ts +43 -0
- package/dist/NavBar/NavBarLink.d.ts.map +1 -0
- package/dist/NavBar/NavBarLink.js +67 -0
- package/dist/NavBar/NavBarLink.js.map +1 -0
- package/dist/NavBar/navBarCurrent.d.ts +37 -0
- package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
- package/dist/NavBar/navBarCurrent.js +48 -0
- package/dist/NavBar/navBarCurrent.js.map +1 -0
- 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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/useBoardDrag.ts +17 -2
- package/src/DropRail/DropRail.mdx +90 -0
- package/src/DropRail/DropRail.stories.tsx +328 -0
- package/src/DropRail/DropRail.test.tsx +154 -0
- package/src/DropRail/DropRail.tsx +496 -0
- package/src/NavBar/NavBar.mdx +157 -0
- package/src/NavBar/NavBar.stories.tsx +350 -0
- package/src/NavBar/NavBar.test.tsx +344 -0
- package/src/NavBar/NavBar.tsx +271 -0
- package/src/NavBar/NavBarLink.tsx +139 -0
- package/src/NavBar/navBarCurrent.test.ts +81 -0
- package/src/NavBar/navBarCurrent.ts +64 -0
- 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/icons.storyHelpers.tsx +12 -0
- package/src/index.ts +17 -0
- package/src/sourceRules.test.ts +37 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { MouseEvent, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CONTROL_SIZE_CLASS,
|
|
6
|
+
PANEL_ITEM_SIZE_CLASS,
|
|
7
|
+
} from "../controlStyles.ts"
|
|
8
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
9
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
10
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
11
|
+
import { toClassName } from "../toClassName.ts"
|
|
12
|
+
import type { NavBarItem } from "./NavBar.tsx"
|
|
13
|
+
|
|
14
|
+
export type NavBarLinkProps = {
|
|
15
|
+
isCurrent: boolean
|
|
16
|
+
item: NavBarItem
|
|
17
|
+
/**
|
|
18
|
+
* Fires when the reader activates this link **in a way that keeps
|
|
19
|
+
* them on the page** — a plain left click, Enter, Space. It is
|
|
20
|
+
* what closes the collapsed panel behind them.
|
|
21
|
+
*
|
|
22
|
+
* A modified click (ctrl, meta, shift, alt) opens a new tab and
|
|
23
|
+
* leaves this document exactly where it was, so it deliberately
|
|
24
|
+
* does not fire: shutting the menu the reader is still working
|
|
25
|
+
* through is the behaviour that makes people stop ctrl-clicking.
|
|
26
|
+
*/
|
|
27
|
+
onNavigate?: () => void
|
|
28
|
+
/** Where it is drawn. A bar row and a panel row paint differently. */
|
|
29
|
+
placement: "bar" | "panel"
|
|
30
|
+
size: ControlSize
|
|
31
|
+
/** Hands the bar row's box to the measuring hook. Bar only. */
|
|
32
|
+
trackElement?: (
|
|
33
|
+
key: string,
|
|
34
|
+
element: HTMLElement | null,
|
|
35
|
+
) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One destination, drawn either in the bar or in the collapsed
|
|
40
|
+
* panel — and it is a real `<a href>` in both.
|
|
41
|
+
*
|
|
42
|
+
* That is the whole reason this component exists rather than the
|
|
43
|
+
* `Menu` a collapsed `Toolbar` opens: `MenuItem` is `{ label,
|
|
44
|
+
* onSelect }`, so a nav folded into one becomes a row of
|
|
45
|
+
* `<button>`s. It looks identical in a screenshot and it loses
|
|
46
|
+
* middle-click, ctrl-click, "open in a new tab", "copy link
|
|
47
|
+
* address", the status bar, and the browser's own visited styling.
|
|
48
|
+
* `BuildingAnApp.mdx` names `<button onClick={navigate}>` as the
|
|
49
|
+
* fleet's most common navigation defect; a collapse mechanism that
|
|
50
|
+
* introduced it at narrow widths would be that defect on a timer.
|
|
51
|
+
*
|
|
52
|
+
* The router seam is `ButtonLink`'s, unchanged: an external
|
|
53
|
+
* destination or a fragment falls back to a plain anchor rather than
|
|
54
|
+
* being pushed onto a history stack that has no route for it.
|
|
55
|
+
*/
|
|
56
|
+
export const NavBarLink = ({
|
|
57
|
+
isCurrent,
|
|
58
|
+
item,
|
|
59
|
+
onNavigate,
|
|
60
|
+
placement,
|
|
61
|
+
size,
|
|
62
|
+
trackElement,
|
|
63
|
+
}: NavBarLinkProps): ReactNode => {
|
|
64
|
+
const RouterLink = useRouterLink()
|
|
65
|
+
|
|
66
|
+
const LinkElement = getIsRoutedHref(item.href)
|
|
67
|
+
? RouterLink
|
|
68
|
+
: "a"
|
|
69
|
+
|
|
70
|
+
const className =
|
|
71
|
+
placement === "bar"
|
|
72
|
+
? toClassName(
|
|
73
|
+
"inline-flex shrink-0 items-center rounded-md border border-transparent whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
|
|
74
|
+
CONTROL_SIZE_CLASS[size],
|
|
75
|
+
// Two written-out entries rather than one interpolated
|
|
76
|
+
// string, the same rule `SegmentedOption` follows:
|
|
77
|
+
// Tailwind's scanner cannot see a class that exists only
|
|
78
|
+
// at runtime, and `tailwindCandidates.test.ts` rejects a
|
|
79
|
+
// template literal in a className outright.
|
|
80
|
+
isCurrent
|
|
81
|
+
? "bg-intent-neutral-surface font-medium text-content-primary"
|
|
82
|
+
: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
83
|
+
FOCUS_RING_CLASS,
|
|
84
|
+
)
|
|
85
|
+
: toClassName(
|
|
86
|
+
"flex w-full items-center rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
|
|
87
|
+
PANEL_ITEM_SIZE_CLASS[size],
|
|
88
|
+
// `-hover`, not the plain surface: the panel is drawn on
|
|
89
|
+
// `surface-overlay`, which every dark scheme paints
|
|
90
|
+
// *lighter* than the base tint, so the plain one reads as
|
|
91
|
+
// no change at all. Same correction `MenuAction` carries.
|
|
92
|
+
isCurrent
|
|
93
|
+
? "bg-intent-neutral-surface-hover font-medium text-content-primary"
|
|
94
|
+
: "text-content-primary hover:bg-intent-neutral-surface-hover",
|
|
95
|
+
FOCUS_RING_CLASS,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<LinkElement
|
|
100
|
+
// The platform's own "you are here", and the reason the paint
|
|
101
|
+
// above is never the only signal: a highlight is invisible to
|
|
102
|
+
// a screen reader and to anyone who cannot tell the two
|
|
103
|
+
// surfaces apart.
|
|
104
|
+
aria-current={isCurrent ? "page" : undefined}
|
|
105
|
+
className={className}
|
|
106
|
+
href={item.href}
|
|
107
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
108
|
+
if (
|
|
109
|
+
event.defaultPrevented ||
|
|
110
|
+
event.button !== 0 ||
|
|
111
|
+
event.altKey ||
|
|
112
|
+
event.ctrlKey ||
|
|
113
|
+
event.metaKey ||
|
|
114
|
+
event.shiftKey
|
|
115
|
+
) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onNavigate?.()
|
|
120
|
+
}}
|
|
121
|
+
ref={(element: HTMLAnchorElement | null) => {
|
|
122
|
+
trackElement?.(item.key ?? item.href, element)
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{item.icon === undefined ? null : (
|
|
126
|
+
// Decoration beside the item's own text. An icon that
|
|
127
|
+
// announces itself turns "Settings" into "gear Settings".
|
|
128
|
+
<span
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
className="shrink-0 text-content-secondary"
|
|
131
|
+
>
|
|
132
|
+
{item.icon}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{item.label}
|
|
137
|
+
</LinkElement>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { getIsCurrentHref } from "./navBarCurrent.ts"
|
|
4
|
+
|
|
5
|
+
const isCurrent = (currentHref: string, href: string) =>
|
|
6
|
+
getIsCurrentHref({ currentHref, href })
|
|
7
|
+
|
|
8
|
+
test("a destination is current on its own path", () => {
|
|
9
|
+
expect(isCurrent("/backlog", "/backlog")).toBe(true)
|
|
10
|
+
|
|
11
|
+
expect(isCurrent("/backlog", "/board")).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A nav item names a **section**, and a section does not stop being
|
|
16
|
+
* the one you are in when you open something inside it.
|
|
17
|
+
*/
|
|
18
|
+
test("a parent path is current for its children", () => {
|
|
19
|
+
expect(isCurrent("/tasks/41", "/tasks")).toBe(true)
|
|
20
|
+
|
|
21
|
+
expect(isCurrent("/tasks/41/edit", "/tasks")).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The match is on whole segments. A bare `startsWith` marks `/task`
|
|
26
|
+
* as current for `/tasks`, which is a highlight on the wrong item
|
|
27
|
+
* and nothing to see in a review.
|
|
28
|
+
*/
|
|
29
|
+
test("a partial segment is not a match", () => {
|
|
30
|
+
expect(isCurrent("/tasks", "/task")).toBe(false)
|
|
31
|
+
|
|
32
|
+
expect(isCurrent("/backlogged", "/backlog")).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* react-router's `<NavLink to="/">` is active on every route unless
|
|
37
|
+
* the caller remembers `end` — the footgun behind every "why is Home
|
|
38
|
+
* always highlighted" bug. The root matches only itself here, and
|
|
39
|
+
* there is no prop to change it.
|
|
40
|
+
*/
|
|
41
|
+
test("the root is exact", () => {
|
|
42
|
+
expect(isCurrent("/", "/")).toBe(true)
|
|
43
|
+
|
|
44
|
+
expect(isCurrent("/backlog", "/")).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sorting a list does not move the reader to a different
|
|
49
|
+
* destination.
|
|
50
|
+
*/
|
|
51
|
+
test("the query string and the fragment are not part of the answer", () => {
|
|
52
|
+
expect(
|
|
53
|
+
isCurrent("/backlog?focus=search", "/backlog"),
|
|
54
|
+
).toBe(true)
|
|
55
|
+
|
|
56
|
+
expect(isCurrent("/backlog#top", "/backlog")).toBe(true)
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
isCurrent("/backlog", "/backlog?focus=search"),
|
|
60
|
+
).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("a trailing slash is the same place", () => {
|
|
64
|
+
expect(isCurrent("/backlog/", "/backlog")).toBe(true)
|
|
65
|
+
|
|
66
|
+
expect(isCurrent("/backlog", "/backlog/")).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `undefined` is what a caller passes when it does not know, and the
|
|
71
|
+
* honest reading of that is that nothing is current — not that
|
|
72
|
+
* everything is.
|
|
73
|
+
*/
|
|
74
|
+
test("no current path marks nothing", () => {
|
|
75
|
+
expect(
|
|
76
|
+
getIsCurrentHref({
|
|
77
|
+
currentHref: undefined,
|
|
78
|
+
href: "/backlog",
|
|
79
|
+
}),
|
|
80
|
+
).toBe(false)
|
|
81
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which destination the reader is on — the rule behind
|
|
3
|
+
* `aria-current="page"`, kept pure so it can be checked in Node.
|
|
4
|
+
*
|
|
5
|
+
* The library is router-agnostic (see `RouterLinkProvider`), so it
|
|
6
|
+
* cannot ask a router which link is active. The app hands it one
|
|
7
|
+
* string — `useLocation().pathname`, `window.location.pathname`,
|
|
8
|
+
* whatever it has — and this decides the rest.
|
|
9
|
+
*
|
|
10
|
+
* ## A parent path is current for its children
|
|
11
|
+
*
|
|
12
|
+
* `/tasks` is current while the reader is on `/tasks/41`, because a
|
|
13
|
+
* nav item names a **section** and a section does not stop being the
|
|
14
|
+
* one you are in when you open something inside it. The match is on
|
|
15
|
+
* whole segments: `/task` is *not* current for `/tasks`, which a bare
|
|
16
|
+
* `startsWith` would get wrong.
|
|
17
|
+
*
|
|
18
|
+
* ## `/` is exact, and that is deliberately not react-router
|
|
19
|
+
*
|
|
20
|
+
* react-router's `<NavLink to="/">` is active on **every** route
|
|
21
|
+
* unless the caller remembers `end`, which is the footgun behind
|
|
22
|
+
* every "why is Home always highlighted" bug in the fleet. The root
|
|
23
|
+
* matches only itself here, and there is no prop to change it: a nav
|
|
24
|
+
* with two current items is a nav that has stopped saying anything.
|
|
25
|
+
*
|
|
26
|
+
* ## The query string and the fragment are not part of the answer
|
|
27
|
+
*
|
|
28
|
+
* `/backlog?focus=search` is still the Backlog. Sorting a list does
|
|
29
|
+
* not move the reader to a different destination.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const toComparablePath = (href: string): string => {
|
|
33
|
+
const path = href.split(/[#?]/u)[0] ?? ""
|
|
34
|
+
|
|
35
|
+
// A trailing slash is the same place, so it must not change the
|
|
36
|
+
// answer — but `/` itself has to survive being trimmed.
|
|
37
|
+
return path.length > 1 ? path.replace(/\/+$/u, "") : path
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const getIsCurrentHref = ({
|
|
41
|
+
currentHref,
|
|
42
|
+
href,
|
|
43
|
+
}: {
|
|
44
|
+
/** Where the reader is. `undefined` means "nothing is current". */
|
|
45
|
+
currentHref: string | undefined
|
|
46
|
+
/** Where this destination goes. */
|
|
47
|
+
href: string
|
|
48
|
+
}): boolean => {
|
|
49
|
+
if (currentHref === undefined) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const current = toComparablePath(currentHref)
|
|
54
|
+
|
|
55
|
+
const target = toComparablePath(href)
|
|
56
|
+
|
|
57
|
+
if (target === "" || target === "/") {
|
|
58
|
+
return current === target
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
current === target || current.startsWith(`${target}/`)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|