@charcuterie/ui 3.34.0 → 4.1.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/dist/ActionTiles/ActionTiles.d.ts +66 -8
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
- package/dist/ActionTiles/ActionTiles.js +88 -11
- package/dist/ActionTiles/ActionTiles.js.map +1 -1
- package/dist/Main/Main.d.ts +18 -19
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +21 -10
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
- package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
- package/dist/Main/ScrollMemoryProvider.js +30 -0
- package/dist/Main/ScrollMemoryProvider.js.map +1 -0
- package/dist/Main/scrollMemory.d.ts +54 -28
- package/dist/Main/scrollMemory.d.ts.map +1 -1
- package/dist/Main/scrollMemory.js +81 -40
- package/dist/Main/scrollMemory.js.map +1 -1
- package/dist/Main/scrollMemoryContext.d.ts +18 -0
- package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
- package/dist/Main/scrollMemoryContext.js +18 -0
- package/dist/Main/scrollMemoryContext.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +20 -18
- package/dist/Main/useScrollMemory.d.ts.map +1 -1
- package/dist/Main/useScrollMemory.js +69 -42
- package/dist/Main/useScrollMemory.js.map +1 -1
- package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
- package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitAvatar.js +29 -0
- package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.js +263 -0
- package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
- package/dist/PortraitTiles/portraitColour.d.ts +49 -0
- package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
- package/dist/PortraitTiles/portraitColour.js +55 -0
- package/dist/PortraitTiles/portraitColour.js.map +1 -0
- package/dist/categoricalStyles.d.ts +27 -0
- package/dist/categoricalStyles.d.ts.map +1 -1
- package/dist/categoricalStyles.js +49 -0
- package/dist/categoricalStyles.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
- package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterAdapter.js +50 -0
- package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
- package/dist/reactRouter/index.d.ts +14 -8
- package/dist/reactRouter/index.d.ts.map +1 -1
- package/dist/reactRouter/index.js +13 -8
- package/dist/reactRouter/index.js.map +1 -1
- package/dist/tileStyles.d.ts +15 -0
- package/dist/tileStyles.d.ts.map +1 -1
- package/dist/tileStyles.js +26 -1
- package/dist/tileStyles.js.map +1 -1
- package/package.json +2 -2
- package/src/ActionTiles/ActionTiles.mdx +64 -6
- package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
- package/src/ActionTiles/ActionTiles.test.tsx +205 -5
- package/src/ActionTiles/ActionTiles.tsx +274 -110
- package/src/Main/Main.mdx +35 -7
- package/src/Main/Main.stories.tsx +129 -64
- package/src/Main/Main.test.tsx +58 -0
- package/src/Main/Main.tsx +20 -20
- package/src/Main/ScrollMemoryProvider.tsx +48 -0
- package/src/Main/scrollMemory.test.ts +70 -16
- package/src/Main/scrollMemory.ts +98 -40
- package/src/Main/scrollMemoryContext.ts +21 -0
- package/src/Main/useScrollMemory.ts +83 -43
- package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
- package/src/PortraitTiles/PortraitTiles.mdx +153 -0
- package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
- package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
- package/src/PortraitTiles/PortraitTiles.tsx +577 -0
- package/src/PortraitTiles/portraitColour.test.ts +39 -0
- package/src/PortraitTiles/portraitColour.ts +67 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
- package/src/RouterLink/RouterLink.mdx +53 -16
- package/src/categoricalStyles.ts +57 -0
- package/src/index.ts +12 -0
- package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
- package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
- package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
- package/src/reactRouter/index.ts +14 -8
- package/src/sourceRules.test.ts +19 -3
- package/src/tileStyles.ts +30 -1
|
@@ -139,6 +139,45 @@ export const Tiles: Story = {
|
|
|
139
139
|
},
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The tile at all three sizes.
|
|
144
|
+
*
|
|
145
|
+
* `AllVariants` above covers the three heights of a **row**, and
|
|
146
|
+
* every tile story took the default `md` — so the largest tile had
|
|
147
|
+
* no snapshot at all, and `TILE_PADDING_CLASS.lg` could be changed
|
|
148
|
+
* without a single pixel moving in the report. It was, on
|
|
149
|
+
* 2026-09-02, when `lg` grew to a landing-page tile's padding.
|
|
150
|
+
*
|
|
151
|
+
* The padding is shared with `ActionTiles` through `tileStyles.ts`,
|
|
152
|
+
* which is the promise this board is here to photograph.
|
|
153
|
+
*/
|
|
154
|
+
export const TileSizes: Story = {
|
|
155
|
+
args: {
|
|
156
|
+
items: IMPORT_ITEMS,
|
|
157
|
+
itemShape: "tile",
|
|
158
|
+
label: "On import",
|
|
159
|
+
},
|
|
160
|
+
render: (controlProps) => (
|
|
161
|
+
<StorySection title="One box at three sizes, and the same box ActionTiles reads from.">
|
|
162
|
+
<StoryGrid columns={3}>
|
|
163
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
164
|
+
<StoryCell
|
|
165
|
+
align="stretch"
|
|
166
|
+
key={size}
|
|
167
|
+
label={size}
|
|
168
|
+
>
|
|
169
|
+
<RadioGroup
|
|
170
|
+
{...controlProps}
|
|
171
|
+
label={`On import at ${size}`}
|
|
172
|
+
size={size}
|
|
173
|
+
/>
|
|
174
|
+
</StoryCell>
|
|
175
|
+
))}
|
|
176
|
+
</StoryGrid>
|
|
177
|
+
</StorySection>
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
142
181
|
/**
|
|
143
182
|
* A tile may lead with an icon. The library ships none — these are
|
|
144
183
|
* story-only SVGs, exactly what an app passes — and the icon is
|
|
@@ -27,38 +27,68 @@ point — so an app that never imports this path never resolves it.
|
|
|
27
27
|
yarn add @charcuterie/ui react-router
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
**One component at the root**, and it must sit **inside** the router:
|
|
31
|
+
|
|
30
32
|
```tsx
|
|
31
33
|
// src/main.tsx
|
|
32
|
-
import {
|
|
33
|
-
import { ReactRouterLink } from "@charcuterie/ui/react-router"
|
|
34
|
+
import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
|
|
34
35
|
import { StrictMode } from "react"
|
|
35
36
|
import { createRoot } from "react-dom/client"
|
|
36
|
-
import {
|
|
37
|
-
createBrowserRouter,
|
|
38
|
-
RouterProvider,
|
|
39
|
-
} from "react-router"
|
|
37
|
+
import { BrowserRouter } from "react-router"
|
|
40
38
|
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
const router = createBrowserRouter(routes)
|
|
39
|
+
import { App } from "./App.tsx"
|
|
44
40
|
|
|
45
41
|
createRoot(document.getElementById("root")).render(
|
|
46
42
|
<StrictMode>
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
43
|
+
<BrowserRouter>
|
|
44
|
+
<ReactRouterAdapter>
|
|
45
|
+
<App />
|
|
46
|
+
</ReactRouterAdapter>
|
|
47
|
+
</BrowserRouter>
|
|
50
48
|
</StrictMode>,
|
|
51
49
|
)
|
|
52
50
|
```
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
Inside, because it reads `useLocation()`. Above the routes, because what it provides belongs
|
|
53
|
+
to the whole app — a link rendered by a route element and a link rendered by app-level
|
|
54
|
+
chrome both see it.
|
|
57
55
|
|
|
58
56
|
Already on `react-router-dom` v7, as bambuddy is? Nothing changes. `react-router-dom` v7 is
|
|
59
57
|
a re-export shim over `react-router`, and the adapter imports `Link` from the latter, so one
|
|
60
58
|
import covers both installs.
|
|
61
59
|
|
|
60
|
+
### Two seams, and why they arrive together
|
|
61
|
+
|
|
62
|
+
The adapter wires the **link seam** and the **scroll memory**, because a per-seam opt-in is
|
|
63
|
+
what the fleet has already failed at. `Main`'s scroll memory shipped as a `scrollKey` prop;
|
|
64
|
+
one app wired it, and the other three carried on losing the reader's place with the library
|
|
65
|
+
sitting right there supporting it. One name at the root means a seam added later arrives in
|
|
66
|
+
every app that already renders this, on its next release.
|
|
67
|
+
|
|
68
|
+
`RouterLinkProvider`, `ReactRouterLink` and `ScrollMemoryProvider` all stay exported for an
|
|
69
|
+
app that wants one half.
|
|
70
|
+
|
|
71
|
+
### On `createBrowserRouter`
|
|
72
|
+
|
|
73
|
+
`RouterProvider` takes no children, so the adapter goes in the root route's element:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const routes = [
|
|
77
|
+
{
|
|
78
|
+
element: (
|
|
79
|
+
<ReactRouterAdapter>
|
|
80
|
+
<Outlet />
|
|
81
|
+
</ReactRouterAdapter>
|
|
82
|
+
),
|
|
83
|
+
children: appRoutes,
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
An app that wants only the link seam can keep `RouterLinkProvider` outside `RouterProvider`
|
|
89
|
+
instead, as it did before this adapter existed — that provider is plain React context and
|
|
90
|
+
needs no router of its own.
|
|
91
|
+
|
|
62
92
|
That is the whole integration. Every link in the app is now soft:
|
|
63
93
|
|
|
64
94
|
```tsx
|
|
@@ -166,6 +196,10 @@ asserts `getByRole("link", { name: … })` and its `href` with no router in the
|
|
|
166
196
|
With a provider, inject a stand-in that marks what it was handed — `TextLink.stories.tsx`'s
|
|
167
197
|
`Routed` story is exactly that, and `TextLink.test.tsx` reads it.
|
|
168
198
|
|
|
199
|
+
The scroll memory is the same: no provider, no memory. A test that wants it renders
|
|
200
|
+
`ScrollMemoryProvider` with a `{ key, path }` it controls, rather than a router — which is
|
|
201
|
+
what `Main.stories.tsx` does.
|
|
202
|
+
|
|
169
203
|
## API
|
|
170
204
|
|
|
171
205
|
| Export | From | What |
|
|
@@ -175,4 +209,7 @@ With a provider, inject a stand-in that marks what it was handed — `TextLink.s
|
|
|
175
209
|
| `RouterLinkComponent` / `RouterLinkProps` | `@charcuterie/ui` | The contract: `ComponentPropsWithRef<"a">` with `href` required. |
|
|
176
210
|
| `AnchorLink` | `@charcuterie/ui` | The plain `<a href>` default. |
|
|
177
211
|
| `getIsRoutedHref(href)` | `@charcuterie/ui` | The destination test above. |
|
|
178
|
-
| `
|
|
212
|
+
| `ScrollMemoryProvider` | `@charcuterie/ui` | The scroll seam. `entry` is `{ key, path }` for the current history entry. |
|
|
213
|
+
| `ScrollEntry` | `@charcuterie/ui` | That pair's type. |
|
|
214
|
+
| `ReactRouterAdapter` | `@charcuterie/ui/react-router` | Both seams, wired. The one thing an app renders. Optional peer. |
|
|
215
|
+
| `ReactRouterLink` | `@charcuterie/ui/react-router` | The link adapter alone. Optional peer. |
|
package/src/categoricalStyles.ts
CHANGED
|
@@ -242,3 +242,60 @@ export const CATEGORICAL_CONTENT_CLASS: Record<
|
|
|
242
242
|
9: "text-categorical-9-content",
|
|
243
243
|
10: "text-categorical-10-content",
|
|
244
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The hover BORDER, which `CATEGORICAL_HOVER_CLASS` deliberately
|
|
248
|
+
* does not carry.
|
|
249
|
+
*
|
|
250
|
+
* That map answers "what does this surface do under the pointer",
|
|
251
|
+
* and a `Badge` has no border state to change. A tile does: it is a
|
|
252
|
+
* bordered card, and the whole point of colouring one is that the
|
|
253
|
+
* box reacts in its own hue rather than in the neutral
|
|
254
|
+
* `border-strong` every other box uses. Kept separate rather than
|
|
255
|
+
* added as a fifth appearance key, because an appearance is a claim
|
|
256
|
+
* about the fill and this is not one.
|
|
257
|
+
*
|
|
258
|
+
* Written out in full for the reason this file opens with.
|
|
259
|
+
*/
|
|
260
|
+
export const CATEGORICAL_HOVER_BORDER_CLASS: Record<
|
|
261
|
+
CategoricalIndex,
|
|
262
|
+
string
|
|
263
|
+
> = {
|
|
264
|
+
1: "hover:border-categorical-1-border",
|
|
265
|
+
2: "hover:border-categorical-2-border",
|
|
266
|
+
3: "hover:border-categorical-3-border",
|
|
267
|
+
4: "hover:border-categorical-4-border",
|
|
268
|
+
5: "hover:border-categorical-5-border",
|
|
269
|
+
6: "hover:border-categorical-6-border",
|
|
270
|
+
7: "hover:border-categorical-7-border",
|
|
271
|
+
8: "hover:border-categorical-8-border",
|
|
272
|
+
9: "hover:border-categorical-9-border",
|
|
273
|
+
10: "hover:border-categorical-10-border",
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A halo the width of a ring, in the hue's SURFACE.
|
|
278
|
+
*
|
|
279
|
+
* `surface` and not `border`: this sits directly against the solid
|
|
280
|
+
* fill it surrounds, so it is a soft step out of the colour rather
|
|
281
|
+
* than a second line around it. `PortraitTiles` draws it around a
|
|
282
|
+
* face, where a hard outline reads as a frame and this reads as
|
|
283
|
+
* light.
|
|
284
|
+
*
|
|
285
|
+
* Written out in full for the reason this file opens with.
|
|
286
|
+
*/
|
|
287
|
+
export const CATEGORICAL_RING_CLASS: Record<
|
|
288
|
+
CategoricalIndex,
|
|
289
|
+
string
|
|
290
|
+
> = {
|
|
291
|
+
1: "ring-categorical-1-surface",
|
|
292
|
+
2: "ring-categorical-2-surface",
|
|
293
|
+
3: "ring-categorical-3-surface",
|
|
294
|
+
4: "ring-categorical-4-surface",
|
|
295
|
+
5: "ring-categorical-5-surface",
|
|
296
|
+
6: "ring-categorical-6-surface",
|
|
297
|
+
7: "ring-categorical-7-surface",
|
|
298
|
+
8: "ring-categorical-8-surface",
|
|
299
|
+
9: "ring-categorical-9-surface",
|
|
300
|
+
10: "ring-categorical-10-surface",
|
|
301
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export type {
|
|
|
39
39
|
export { Accordion } from "./Accordion/Accordion.tsx"
|
|
40
40
|
export type {
|
|
41
41
|
ActionTileItem,
|
|
42
|
+
ActionTilesAccent,
|
|
42
43
|
ActionTilesProps,
|
|
43
44
|
} from "./ActionTiles/ActionTiles.tsx"
|
|
44
45
|
export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
|
|
@@ -129,7 +130,9 @@ export { copyText } from "./CopyButton/copyText.ts"
|
|
|
129
130
|
export {
|
|
130
131
|
CATEGORICAL_APPEARANCE_CLASS,
|
|
131
132
|
CATEGORICAL_CONTENT_CLASS,
|
|
133
|
+
CATEGORICAL_HOVER_BORDER_CLASS,
|
|
132
134
|
CATEGORICAL_HOVER_CLASS,
|
|
135
|
+
CATEGORICAL_RING_CLASS,
|
|
133
136
|
CATEGORICAL_SOLID_FILL_CLASS,
|
|
134
137
|
} from "./categoricalStyles.ts"
|
|
135
138
|
export type { BadgeSize } from "./controlStyles.ts"
|
|
@@ -253,6 +256,9 @@ export type {
|
|
|
253
256
|
export { LogViewer } from "./LogViewer/LogViewer.tsx"
|
|
254
257
|
export type { MainProps } from "./Main/Main.tsx"
|
|
255
258
|
export { Main } from "./Main/Main.tsx"
|
|
259
|
+
export type { ScrollMemoryProviderProps } from "./Main/ScrollMemoryProvider.tsx"
|
|
260
|
+
export { ScrollMemoryProvider } from "./Main/ScrollMemoryProvider.tsx"
|
|
261
|
+
export type { ScrollEntry } from "./Main/scrollMemory.ts"
|
|
256
262
|
export type {
|
|
257
263
|
MarkdownEditorProps,
|
|
258
264
|
MarkdownImageUpload,
|
|
@@ -322,6 +328,12 @@ export type {
|
|
|
322
328
|
export { Picker } from "./Picker/Picker.tsx"
|
|
323
329
|
export type { PopoverProps } from "./Popover/Popover.tsx"
|
|
324
330
|
export { Popover } from "./Popover/Popover.tsx"
|
|
331
|
+
export type {
|
|
332
|
+
PortraitTileItem,
|
|
333
|
+
PortraitTilesLayout,
|
|
334
|
+
PortraitTilesProps,
|
|
335
|
+
} from "./PortraitTiles/PortraitTiles.tsx"
|
|
336
|
+
export { PortraitTiles } from "./PortraitTiles/PortraitTiles.tsx"
|
|
325
337
|
export type {
|
|
326
338
|
ProgressBarProps,
|
|
327
339
|
ProgressBarSize,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import {
|
|
3
|
+
MemoryRouter,
|
|
4
|
+
Route,
|
|
5
|
+
Routes,
|
|
6
|
+
useNavigate,
|
|
7
|
+
} from "react-router"
|
|
8
|
+
|
|
9
|
+
import { Button } from "../Button/Button.tsx"
|
|
10
|
+
import { Card } from "../Card/Card.tsx"
|
|
11
|
+
import { Header } from "../Header/Header.tsx"
|
|
12
|
+
import { Main } from "../Main/Main.tsx"
|
|
13
|
+
import { Shell } from "../Shell/Shell.tsx"
|
|
14
|
+
import { TextLink } from "../TextLink/TextLink.tsx"
|
|
15
|
+
import { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one component an app on react-router renders at its root.
|
|
19
|
+
*
|
|
20
|
+
* `Main.stories.tsx` proves the scroll memory against a hand-rolled
|
|
21
|
+
* history stack, which is the right subject for the mechanism. This
|
|
22
|
+
* one proves the **wiring**: a real router, a real `useLocation()`,
|
|
23
|
+
* and both seams arriving from one component — the piece that ships
|
|
24
|
+
* broken silently, because a page whose links still work and whose
|
|
25
|
+
* list still scrolls looks fine.
|
|
26
|
+
*
|
|
27
|
+
* `MemoryRouter` rather than `BrowserRouter`, so the story does not
|
|
28
|
+
* write to the Storybook page's own history.
|
|
29
|
+
*/
|
|
30
|
+
const meta = {
|
|
31
|
+
component: ReactRouterAdapter,
|
|
32
|
+
parameters: {
|
|
33
|
+
layout: "fullscreen",
|
|
34
|
+
},
|
|
35
|
+
title: "Components/Layout/ReactRouterAdapter",
|
|
36
|
+
} satisfies Meta<typeof ReactRouterAdapter>
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
|
|
40
|
+
type Story = StoryObj<typeof meta>
|
|
41
|
+
|
|
42
|
+
const EPISODE_TITLES = Array.from(
|
|
43
|
+
{ length: 40 },
|
|
44
|
+
(_unused, index) => `Episode ${index + 1}`,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const BackControl = () => {
|
|
48
|
+
const navigate = useNavigate()
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Button
|
|
52
|
+
appearance="outline"
|
|
53
|
+
onClick={() => {
|
|
54
|
+
void navigate(-1)
|
|
55
|
+
}}
|
|
56
|
+
size="sm"
|
|
57
|
+
>
|
|
58
|
+
Back
|
|
59
|
+
</Button>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EpisodeList = () => (
|
|
64
|
+
<Main>
|
|
65
|
+
<div className="flex flex-col gap-3">
|
|
66
|
+
{EPISODE_TITLES.map((title) => (
|
|
67
|
+
<Card heading={title} key={title} padding="sm">
|
|
68
|
+
<TextLink href={`/library/${title}`}>
|
|
69
|
+
Open {title}
|
|
70
|
+
</TextLink>
|
|
71
|
+
</Card>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</Main>
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const EpisodePage = () => (
|
|
78
|
+
<Main>
|
|
79
|
+
<Card heading="Episode">
|
|
80
|
+
<p className="text-content-secondary text-sm">
|
|
81
|
+
Short enough that the scrollport collapses behind
|
|
82
|
+
it. Press Back.
|
|
83
|
+
</p>
|
|
84
|
+
</Card>
|
|
85
|
+
</Main>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scroll the list, press a link, then press Back.
|
|
90
|
+
*
|
|
91
|
+
* Nothing on this page is told about scrolling or about the router.
|
|
92
|
+
* `TextLink` becomes a soft navigation and `Main` keeps the
|
|
93
|
+
* reader's place because `ReactRouterAdapter` is above them both.
|
|
94
|
+
*/
|
|
95
|
+
export const Default: Story = {
|
|
96
|
+
render: () => (
|
|
97
|
+
<MemoryRouter initialEntries={["/library"]}>
|
|
98
|
+
<ReactRouterAdapter>
|
|
99
|
+
<Shell>
|
|
100
|
+
<Header
|
|
101
|
+
actions={<BackControl />}
|
|
102
|
+
heading="Library"
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
<Routes>
|
|
106
|
+
<Route
|
|
107
|
+
element={<EpisodeList />}
|
|
108
|
+
path="/library"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<Route
|
|
112
|
+
element={<EpisodePage />}
|
|
113
|
+
path="/library/:episode"
|
|
114
|
+
/>
|
|
115
|
+
</Routes>
|
|
116
|
+
</Shell>
|
|
117
|
+
</ReactRouterAdapter>
|
|
118
|
+
</MemoryRouter>
|
|
119
|
+
),
|
|
120
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { afterEach, test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { forgetScrollOffsets } from "../Main/scrollMemory.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import {
|
|
9
|
+
DESKTOP,
|
|
10
|
+
setViewport,
|
|
11
|
+
} from "../viewport.testHelpers.ts"
|
|
12
|
+
import * as stories from "./ReactRouterAdapter.stories.tsx"
|
|
13
|
+
|
|
14
|
+
const { Default } = composeStories(stories)
|
|
15
|
+
|
|
16
|
+
// The offsets are module state, so they outlive a mount. A test
|
|
17
|
+
// must not.
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
forgetScrollOffsets()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The wiring, against a real router.
|
|
24
|
+
*
|
|
25
|
+
* `Main.test.tsx` proves the scroll mechanism against a hand-rolled
|
|
26
|
+
* history stack — the right subject for a `ResizeObserver` and a
|
|
27
|
+
* clamped offset. What it cannot prove is that the entry reaches
|
|
28
|
+
* `Main` at all when the value comes from `useLocation()` rather
|
|
29
|
+
* than from a story's own `useState`. That is the seam every app
|
|
30
|
+
* now depends on, and it is the kind that fails quietly: the links
|
|
31
|
+
* still navigate and the list still scrolls.
|
|
32
|
+
*/
|
|
33
|
+
test("an app renders one component and gets both seams", async () => {
|
|
34
|
+
await setViewport(DESKTOP)
|
|
35
|
+
|
|
36
|
+
const { canvas } = await mountStory(Default)
|
|
37
|
+
|
|
38
|
+
const main = canvas.getByRole("main")
|
|
39
|
+
|
|
40
|
+
// The link seam: react-router's `<Link>` renders an `<a href>`,
|
|
41
|
+
// so this is also what an unwired app looks like — the assertion
|
|
42
|
+
// that separates them is the absence of a page load below.
|
|
43
|
+
const link = expectAgentDrivable(canvas, {
|
|
44
|
+
name: "Open Episode 12",
|
|
45
|
+
role: "link",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await expect(link).toHaveAttribute(
|
|
49
|
+
"href",
|
|
50
|
+
"/library/Episode 12",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
main.scrollTop = 900
|
|
54
|
+
|
|
55
|
+
await waitFor(async () => {
|
|
56
|
+
await expect(main.scrollTop).toBe(900)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
globalThis.requestAnimationFrame(() => {
|
|
61
|
+
globalThis.requestAnimationFrame(resolve)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await userEvent.click(link)
|
|
66
|
+
|
|
67
|
+
// A soft navigation: the route changed inside the same document.
|
|
68
|
+
// A plain `<a>` would have left the story mounted and unchanged.
|
|
69
|
+
//
|
|
70
|
+
// `waitFor`, because the route commit is a state update and the
|
|
71
|
+
// click resolves before it. It passed locally and failed on CI
|
|
72
|
+
// for exactly that reason.
|
|
73
|
+
await waitFor(async () => {
|
|
74
|
+
await expect(
|
|
75
|
+
canvas.getByRole("heading", { name: "Episode" }),
|
|
76
|
+
).toBeVisible()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await userEvent.click(
|
|
80
|
+
expectAgentDrivable(canvas, {
|
|
81
|
+
name: "Back",
|
|
82
|
+
role: "button",
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// The scroll seam, from the same one component.
|
|
87
|
+
await waitFor(async () => {
|
|
88
|
+
await expect(canvas.getByRole("main").scrollTop).toBe(
|
|
89
|
+
900,
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useLocation } from "react-router"
|
|
3
|
+
|
|
4
|
+
import { ScrollMemoryProvider } from "../Main/ScrollMemoryProvider.tsx"
|
|
5
|
+
import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
|
|
6
|
+
import { ReactRouterLink } from "./ReactRouterLink.tsx"
|
|
7
|
+
|
|
8
|
+
export type ReactRouterAdapterProps = {
|
|
9
|
+
children?: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every seam this library has, wired to react-router, in one
|
|
14
|
+
* component at the app root.
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <BrowserRouter>
|
|
18
|
+
* <ReactRouterAdapter>
|
|
19
|
+
* <Routes>…</Routes>
|
|
20
|
+
* </ReactRouterAdapter>
|
|
21
|
+
* </BrowserRouter>
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* It must sit **inside** the router, because it reads
|
|
25
|
+
* `useLocation()`, and **outside** the routes, because what it
|
|
26
|
+
* provides belongs to the whole app.
|
|
27
|
+
*
|
|
28
|
+
* ## Why one component and not two providers
|
|
29
|
+
*
|
|
30
|
+
* There are two seams today — the link seam and the scroll memory —
|
|
31
|
+
* and the fleet proved that a per-app opt-in does not reach the
|
|
32
|
+
* fleet. `Main`'s scroll memory shipped as a `scrollKey` prop, one
|
|
33
|
+
* app wired it, and the other three kept losing the reader's place
|
|
34
|
+
* with the library sitting right there supporting it. A seam an app
|
|
35
|
+
* has to know about is a seam most apps will not have.
|
|
36
|
+
*
|
|
37
|
+
* So the root wiring is one name to remember, and a seam added later
|
|
38
|
+
* arrives in every app that already renders this on its next
|
|
39
|
+
* release. `RouterLinkProvider`, `ReactRouterLink` and
|
|
40
|
+
* `ScrollMemoryProvider` all stay exported for an app that wants one
|
|
41
|
+
* half, or that renders its own root for another reason.
|
|
42
|
+
*
|
|
43
|
+
* ## What it does NOT do
|
|
44
|
+
*
|
|
45
|
+
* It does not render a router, and it takes no route table. This
|
|
46
|
+
* library has no opinion about either, and an app that wants
|
|
47
|
+
* `createBrowserRouter` should keep it.
|
|
48
|
+
*/
|
|
49
|
+
export const ReactRouterAdapter = ({
|
|
50
|
+
children,
|
|
51
|
+
}: ReactRouterAdapterProps): ReactNode => {
|
|
52
|
+
const location = useLocation()
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<RouterLinkProvider link={ReactRouterLink}>
|
|
56
|
+
<ScrollMemoryProvider
|
|
57
|
+
entry={{
|
|
58
|
+
key: location.key,
|
|
59
|
+
path: location.pathname,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</ScrollMemoryProvider>
|
|
64
|
+
</RouterLinkProvider>
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/reactRouter/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@charcuterie/ui/react-router` — the react-router adapter for
|
|
3
|
-
*
|
|
2
|
+
* `@charcuterie/ui/react-router` — the react-router adapter for this
|
|
3
|
+
* library's seams.
|
|
4
4
|
*
|
|
5
5
|
* **A subpath export with an optional peer dependency**, and both
|
|
6
6
|
* halves of that matter. The base package must stay router-free: the
|
|
@@ -13,17 +13,23 @@
|
|
|
13
13
|
* asserts exactly that — the main entry is measured for what it
|
|
14
14
|
* reaches, so this cannot leak into it by accident.
|
|
15
15
|
*
|
|
16
|
-
* The wiring is one
|
|
16
|
+
* The wiring is one component at the app root; `Guides/Routing` in
|
|
17
17
|
* Storybook is the copy-pasteable version.
|
|
18
18
|
*
|
|
19
19
|
* ```tsx
|
|
20
|
-
* import {
|
|
21
|
-
* import { ReactRouterLink } from "@charcuterie/ui/react-router"
|
|
20
|
+
* import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
|
|
22
21
|
*
|
|
23
|
-
* <
|
|
24
|
-
* <
|
|
25
|
-
*
|
|
22
|
+
* <BrowserRouter>
|
|
23
|
+
* <ReactRouterAdapter>
|
|
24
|
+
* <Routes>…</Routes>
|
|
25
|
+
* </ReactRouterAdapter>
|
|
26
|
+
* </BrowserRouter>
|
|
26
27
|
* ```
|
|
28
|
+
*
|
|
29
|
+
* `ReactRouterLink` stays exported for an app that wires
|
|
30
|
+
* `RouterLinkProvider` itself.
|
|
27
31
|
*/
|
|
28
32
|
|
|
33
|
+
export type { ReactRouterAdapterProps } from "./ReactRouterAdapter.tsx"
|
|
34
|
+
export { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
|
|
29
35
|
export { ReactRouterLink } from "./ReactRouterLink.tsx"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -236,6 +236,14 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
|
|
|
236
236
|
"Main",
|
|
237
237
|
"MarkdownEditor",
|
|
238
238
|
"MediaTile",
|
|
239
|
+
// `PortraitTiles` declares its container on the SET rather than
|
|
240
|
+
// on a tile, and that is the whole design: the question is not
|
|
241
|
+
// "how wide is this one face" but "does this picker have room to
|
|
242
|
+
// stack". A phone shows one full-width column of rows; a wall
|
|
243
|
+
// display shows a row of tall columns. Querying a tile would
|
|
244
|
+
// answer neither, because a track is about the same width in
|
|
245
|
+
// both.
|
|
246
|
+
"PortraitTiles",
|
|
239
247
|
// `Stepper`'s container is the one that decides whether a
|
|
240
248
|
// `horizontal` sequence is actually horizontal. Below `--cq-md`
|
|
241
249
|
// it falls back to the vertical column, because four steps side
|
|
@@ -375,8 +383,10 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
375
383
|
// and `ButtonLink`, both a real `<a href>`, differing in paint
|
|
376
384
|
// rather than semantics: +2 -> 38, on top of `AdaptiveGrid`'s 36.
|
|
377
385
|
// `AnchorLink`,
|
|
378
|
-
// `RouterLinkProvider`,
|
|
379
|
-
//
|
|
386
|
+
// `RouterLinkProvider`, `ReactRouterLink`, `ScrollMemoryProvider`
|
|
387
|
+
// and `ReactRouterAdapter` are the router seam rather
|
|
388
|
+
// than components anybody stories, and live in `RouterLink/`,
|
|
389
|
+
// `Main/` and
|
|
380
390
|
// `reactRouter/` — outside this count by the `<Name>/<Name>.tsx`
|
|
381
391
|
// rule, exactly as `Overlay/`'s parts are.
|
|
382
392
|
// The unified app shell adds four: `Shell`, `Header`, `Rail`,
|
|
@@ -569,7 +579,13 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
569
579
|
// `isRange` is right there and wrong here for a reason that is
|
|
570
580
|
// written down — a second text field is still a text field, while a
|
|
571
581
|
// second thumb cannot share one `aria-valuenow`.
|
|
572
|
-
|
|
582
|
+
//
|
|
583
|
+
// `PortraitTiles` is +1 -> 65. It is the points-market kid picker:
|
|
584
|
+
// a face, a name and one number, which is a different question
|
|
585
|
+
// from `ActionTiles`' "what do you want to do" and would have
|
|
586
|
+
// meant one component whose icon is sometimes a 20px glyph beside
|
|
587
|
+
// a title and sometimes a 144px circle above it.
|
|
588
|
+
expect(componentNames.length).toBe(65)
|
|
573
589
|
|
|
574
590
|
for (const name of componentNames) {
|
|
575
591
|
expect(barrel).toContain(`export { ${name} }`)
|
package/src/tileStyles.ts
CHANGED
|
@@ -38,7 +38,36 @@ export const TILE_PADDING_CLASS: Record<
|
|
|
38
38
|
> = {
|
|
39
39
|
sm: "px-3 py-2.5",
|
|
40
40
|
md: "px-3.5 py-3",
|
|
41
|
-
lg
|
|
41
|
+
// `lg` is the landing-page tile — mux-magic's "Pick a tool",
|
|
42
|
+
// Gallery Downloader's two cards — where the tile is the only
|
|
43
|
+
// thing on the page and reads as a panel rather than as a row.
|
|
44
|
+
// It was `px-4 py-3.5`, which is a control's padding wearing a
|
|
45
|
+
// card's name, and the owner called the result "very boring"
|
|
46
|
+
// when it shipped in QueuePilot.
|
|
47
|
+
lg: "px-6 py-5",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a tile with an accent edge adds to its LEADING padding.
|
|
52
|
+
*
|
|
53
|
+
* The bar is drawn by `Card`'s accent-edge pseudo-element, which is
|
|
54
|
+
* an overlay rather than a border — so it takes no space in the box
|
|
55
|
+
* model and lands on top of the text unless the tile makes room for
|
|
56
|
+
* it. `Card` has the same arrangement and the same answer.
|
|
57
|
+
*
|
|
58
|
+
* A separate map rather than a second full padding scale, because
|
|
59
|
+
* only the leading side changes and the shared box is the promise
|
|
60
|
+
* the 2026-09-01 record made. It is applied ONLY when an edge is
|
|
61
|
+
* drawn: a radio tile has no bar, and giving it this padding would
|
|
62
|
+
* be dead space on the side its own control sits on.
|
|
63
|
+
*/
|
|
64
|
+
export const TILE_ACCENT_EDGE_PADDING_CLASS: Record<
|
|
65
|
+
ControlSize,
|
|
66
|
+
string
|
|
67
|
+
> = {
|
|
68
|
+
sm: "ps-4",
|
|
69
|
+
md: "ps-4.5",
|
|
70
|
+
lg: "ps-7",
|
|
42
71
|
}
|
|
43
72
|
|
|
44
73
|
/**
|