@charcuterie/ui 3.34.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/Main/Main.d.ts +18 -19
  2. package/dist/Main/Main.d.ts.map +1 -1
  3. package/dist/Main/Main.js +21 -10
  4. package/dist/Main/Main.js.map +1 -1
  5. package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
  6. package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
  7. package/dist/Main/ScrollMemoryProvider.js +30 -0
  8. package/dist/Main/ScrollMemoryProvider.js.map +1 -0
  9. package/dist/Main/scrollMemory.d.ts +54 -28
  10. package/dist/Main/scrollMemory.d.ts.map +1 -1
  11. package/dist/Main/scrollMemory.js +81 -40
  12. package/dist/Main/scrollMemory.js.map +1 -1
  13. package/dist/Main/scrollMemoryContext.d.ts +18 -0
  14. package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
  15. package/dist/Main/scrollMemoryContext.js +18 -0
  16. package/dist/Main/scrollMemoryContext.js.map +1 -0
  17. package/dist/Main/useScrollMemory.d.ts +20 -18
  18. package/dist/Main/useScrollMemory.d.ts.map +1 -1
  19. package/dist/Main/useScrollMemory.js +69 -42
  20. package/dist/Main/useScrollMemory.js.map +1 -1
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
  26. package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
  27. package/dist/reactRouter/ReactRouterAdapter.js +50 -0
  28. package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
  29. package/dist/reactRouter/index.d.ts +14 -8
  30. package/dist/reactRouter/index.d.ts.map +1 -1
  31. package/dist/reactRouter/index.js +13 -8
  32. package/dist/reactRouter/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Main/Main.mdx +35 -7
  35. package/src/Main/Main.stories.tsx +129 -64
  36. package/src/Main/Main.test.tsx +58 -0
  37. package/src/Main/Main.tsx +20 -20
  38. package/src/Main/ScrollMemoryProvider.tsx +48 -0
  39. package/src/Main/scrollMemory.test.ts +70 -16
  40. package/src/Main/scrollMemory.ts +98 -40
  41. package/src/Main/scrollMemoryContext.ts +21 -0
  42. package/src/Main/useScrollMemory.ts +83 -43
  43. package/src/RouterLink/RouterLink.mdx +53 -16
  44. package/src/index.ts +3 -0
  45. package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
  46. package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
  47. package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
  48. package/src/reactRouter/index.ts +14 -8
  49. package/src/sourceRules.test.ts +4 -2
@@ -1,73 +1,131 @@
1
1
  /**
2
- * What the browser will not remember for you.
2
+ * What the browser will not remember for you, and the one thing it
3
+ * would never have got wrong.
3
4
  *
4
- * `Shell` gives `<main>` the page's only vertical scrollport, so
5
- * the header and the rails stay put while the content moves —
6
- * that is the whole reason `Shell.test.tsx` asserts "only main
7
- * scrolls vertically, keeping the rail available".
8
- *
9
- * It costs exactly one thing, and this module is it. A browser
10
- * remembers **the document scroller's** offset per history entry
11
- * and puts it back on Back. It does that for a cross-document
12
- * load, and `history.scrollRestoration` is the switch for it. It
13
- * does **not** do it for an arbitrary element with
14
- * `overflow-y: auto`, and it does not do it at all for a
15
- * same-document `pushState` navigation, which is every link press
16
- * in a single-page app. So a page that scrolls its own `<main>`
17
- * loses the reader's place on Back, and no browser setting brings
18
- * it back:
5
+ * `Shell` gives `<main>` the page's only vertical scrollport, so the
6
+ * header and the rails stay put while the content moves — that is
7
+ * what `Shell.test.tsx` asserts. It costs exactly one thing, and
8
+ * this module is it. A browser remembers **the document scroller's**
9
+ * offset per history entry and puts it back on Back. It does not do
10
+ * that for an arbitrary element with `overflow-y: auto`, and it does
11
+ * not do it at all for a same-document `pushState` navigation, which
12
+ * is every link press in a single-page app:
19
13
  *
20
14
  * > *"If I scroll, click on an item, then go back, it resets me to
21
15
  * > the top. […] It should remember where the scroll position was
22
16
  * > previously. Isn't this handled by Chrome already?"*
23
17
  *
24
- * The offsets are held **in memory**, not in `sessionStorage`. A
25
- * history key survives a reload, but the DOM it described does
26
- * not: the app re-fetches, re-measures and often re-orders, so a
27
- * pixel offset from before the reload points at a different row.
28
- * Restoring a stale number is worse than restoring nothing,
29
- * because it looks deliberate.
18
+ * ## An entry remembers its PATH as well as its offset
19
+ *
20
+ * Not decoration — the second field is what keeps this from being a
21
+ * worse bug than the one it fixes. A history entry is not a page. A
22
+ * filter chip, an expanded group, a selected tab: each writes a
23
+ * search param, and **`setSearchParams` navigates**, so each mints a
24
+ * history entry the memory has never seen. Treating an unseen entry
25
+ * as "start at the top" therefore threw the reader to the top of the
26
+ * list every time they opened a group — which is exactly what
27
+ * shipped, and what the path comparison exists to prevent.
28
+ *
29
+ * So the rule is about the PAGE, not the entry:
30
+ *
31
+ * - **An entry we have seen** → its remembered offset. This is the
32
+ * whole feature: Back and Forward land where you left them.
33
+ * - **A new entry on the SAME path** → leave the scrollport alone.
34
+ * A search param changed; the reader did not go anywhere. This is
35
+ * also precisely what the app did before this memory existed, so
36
+ * it can never be a regression.
37
+ * - **A new entry on a DIFFERENT path** → the top. A page you have
38
+ * not seen starts at its beginning, which is what a browser does
39
+ * for a document.
40
+ *
41
+ * ## The offsets are in memory, never `sessionStorage`
42
+ *
43
+ * A history key survives a reload; the DOM it described does not.
44
+ * The app re-fetches, re-measures and often re-orders, so a pixel
45
+ * offset from before the reload points at a different row. Restoring
46
+ * a stale number is worse than restoring nothing, because it looks
47
+ * deliberate.
30
48
  */
31
49
 
32
50
  /**
33
51
  * How many history entries keep an offset. A reader goes back
34
52
  * through a handful of screens, never fifty, so this is a ceiling
35
53
  * against a long session rather than a working set — the map holds
36
- * one small number per entry and evicts the least recently
37
- * written.
54
+ * one small record per entry and evicts the least recently written.
38
55
  */
39
56
  const REMEMBERED_ENTRY_LIMIT = 50
40
57
 
41
- const rememberedOffsets = new Map<string, number>()
58
+ export type ScrollEntry = {
59
+ /** The history entry — react-router's `useLocation().key`. */
60
+ key: string
61
+ /** The page it belongs to — `useLocation().pathname`. */
62
+ path: string
63
+ }
64
+
65
+ type RememberedEntry = {
66
+ offset: number
67
+ path: string
68
+ }
69
+
70
+ const rememberedEntries = new Map<string, RememberedEntry>()
71
+
72
+ /**
73
+ * The entry the scrollport is showing. Module state rather than a
74
+ * ref, because the question it answers — *did the reader change page
75
+ * or only a search param?* — is asked by the incoming entry's effect
76
+ * about the OUTGOING one, and by then the outgoing effect has
77
+ * already been cleaned up.
78
+ */
79
+ let currentEntry: ScrollEntry | null = null
42
80
 
43
81
  export const rememberScrollOffset = (
44
- key: string,
82
+ entry: ScrollEntry,
45
83
  offset: number,
46
84
  ) => {
47
- // Delete before set, so insertion order is least-recent-first
48
- // and the eviction below takes the right one. A plain `set` on
49
- // an existing key keeps its original position.
50
- rememberedOffsets.delete(key)
51
- rememberedOffsets.set(key, offset)
85
+ // Delete before set, so insertion order is least-recent-first and
86
+ // the eviction below takes the right one. A plain `set` on an
87
+ // existing key keeps its original position.
88
+ rememberedEntries.delete(entry.key)
89
+ rememberedEntries.set(entry.key, {
90
+ offset,
91
+ path: entry.path,
92
+ })
52
93
 
53
- for (const oldestKey of rememberedOffsets.keys()) {
54
- if (rememberedOffsets.size <= REMEMBERED_ENTRY_LIMIT) {
94
+ for (const oldestKey of rememberedEntries.keys()) {
95
+ if (rememberedEntries.size <= REMEMBERED_ENTRY_LIMIT) {
55
96
  break
56
97
  }
57
98
 
58
- rememberedOffsets.delete(oldestKey)
99
+ rememberedEntries.delete(oldestKey)
59
100
  }
60
101
  }
61
102
 
62
103
  /**
63
- * `0` for an entry nobody has scrolled, which is also the right
64
- * answer for an entry nobody has *seen* — a link press lands at
65
- * the top of the new page.
104
+ * Where the scrollport should go for this entry, or `null` for
105
+ * **leave it alone** — which is a third answer rather than a missing
106
+ * one, and the difference between a search param and a page.
66
107
  */
67
- export const recallScrollOffset = (key: string) =>
68
- rememberedOffsets.get(key) ?? 0
108
+ export const resolveScrollOffset = (entry: ScrollEntry) => {
109
+ const remembered = rememberedEntries.get(entry.key)
110
+
111
+ if (remembered) {
112
+ return remembered.offset
113
+ }
114
+
115
+ const isSamePage =
116
+ currentEntry !== null &&
117
+ currentEntry.path === entry.path
118
+
119
+ return isSamePage ? null : 0
120
+ }
121
+
122
+ /** Called by the hook once it owns the scrollport for this entry. */
123
+ export const enterScrollEntry = (entry: ScrollEntry) => {
124
+ currentEntry = entry
125
+ }
69
126
 
70
127
  /** Test-only. Module state outlives a mount; a test must not. */
71
128
  export const forgetScrollOffsets = () => {
72
- rememberedOffsets.clear()
129
+ rememberedEntries.clear()
130
+ currentEntry = null
73
131
  }
@@ -0,0 +1,21 @@
1
+ import { createContext } from "react"
2
+
3
+ import type { ScrollEntry } from "./scrollMemory.ts"
4
+
5
+ /**
6
+ * How the history entry reaches `Main` without `Main` knowing a
7
+ * router exists.
8
+ *
9
+ * `@charcuterie/ui`'s main entry is router-free and stays that way —
10
+ * `sourceRules.test.ts` asserts it, and the fleet runs five
11
+ * different routers. So the entry arrives as a plain
12
+ * `{ key, path }`, and the one component that knows how to read it
13
+ * from react-router lives in the `./react-router` subpath.
14
+ *
15
+ * A context rather than a prop, because the alternative is a prop
16
+ * every app has to remember on every `Main` it renders. It was a
17
+ * prop first, exactly once, and three of the four apps that needed
18
+ * it did not have it.
19
+ */
20
+ export const ScrollMemoryContext =
21
+ createContext<ScrollEntry | null>(null)
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useLayoutEffect, useRef } from "react"
2
2
 
3
3
  import {
4
- recallScrollOffset,
4
+ enterScrollEntry,
5
5
  rememberScrollOffset,
6
+ resolveScrollOffset,
7
+ type ScrollEntry,
6
8
  } from "./scrollMemory.ts"
7
9
 
8
10
  /**
@@ -11,11 +13,11 @@ import {
11
13
  *
12
14
  * The offset cannot be applied until the scrollport is tall enough
13
15
  * to hold it, and on a client-rendered page it is not: the commit
14
- * that changes the route draws a skeleton, and the rows land a
15
- * fetch later. So the restore re-applies as the content grows, and
16
- * gives up after this. A page that takes longer than three seconds
17
- * to fill has already been read from the top, and moving it then
18
- * is a worse answer than leaving it where the reader put it.
16
+ * that changes the route draws a skeleton, and the rows land a fetch
17
+ * later. So the restore re-applies as the content grows, and gives
18
+ * up after this. A page that takes longer than three seconds to fill
19
+ * has already been read from the top, and moving it then is a worse
20
+ * answer than leaving it where the reader put it.
19
21
  */
20
22
  const RESTORE_WINDOW_MS = 3000
21
23
 
@@ -28,41 +30,42 @@ const READER_INTENT_EVENTS = [
28
30
  ] as const
29
31
 
30
32
  /**
31
- * Remember where this history entry was scrolled to, and put it
32
- * back when the reader returns to it.
33
+ * Remember where this history entry was scrolled to, and put it back
34
+ * when the reader returns to it.
33
35
  *
34
- * `scrollMemory.ts` says why the browser will not. This is the
35
- * part that has to survive the two things a real page does:
36
+ * `scrollMemory.ts` says why the browser will not, and what an entry
37
+ * that has never been seen should do. This is the part that has to
38
+ * survive the two things a real page does:
36
39
  *
37
40
  * 1. **The content arrives late.** An offset applied against a
38
- * 600px skeleton clamps to `0` and looks like it did nothing.
39
- * A `ResizeObserver` on the scrollport and on its content
40
- * column re-applies the offset every time either grows, until
41
- * the offset lands or the window closes.
42
- * 2. **The reader scrolls during all that.** Any wheel, touch,
43
- * key or pointer press ends the restore immediately. The
44
- * alternative is a page that jumps out from under somebody who
45
- * has already started reading.
41
+ * 600px skeleton clamps to `0` and looks like it did nothing. A
42
+ * `ResizeObserver` on the scrollport and on its content column
43
+ * re-applies the offset every time either grows, until the
44
+ * offset lands or the window closes.
45
+ * 2. **The reader scrolls during all that.** Any wheel, touch, key
46
+ * or pointer press ends the restore immediately. The alternative
47
+ * is a page that jumps out from under somebody who has already
48
+ * started reading.
46
49
  *
47
50
  * The save is a passive `scroll` listener, suppressed while a
48
51
  * restore is in flight. Without that suppression the feature eats
49
52
  * itself: writing `scrollTop` on a still-short page clamps to `0`
50
- * and fires a `scroll` event reading `0`, which would be saved
51
- * over the very offset being restored.
53
+ * and fires a `scroll` event reading `0`, which would be saved over
54
+ * the very offset being restored.
52
55
  *
53
- * @param scrollKey Identifies the history entry — react-router's
54
- * `useLocation().key`. `undefined` turns the memory off, which is
55
- * what an app with no router gets.
56
+ * @param entry The history entry and the page it belongs to.
57
+ * `null` turns the memory off, which is what an app that renders
58
+ * no `ScrollMemoryProvider` gets.
56
59
  */
57
60
  export const useScrollMemory = (
58
- scrollKey: string | undefined,
61
+ entry: ScrollEntry | null | undefined,
59
62
  ) => {
60
63
  const elementRef = useRef<HTMLElement | null>(null)
61
64
  const isRestoringRef = useRef(false)
62
65
 
63
- // A ref rather than a query for the `<main>` id: the effect
64
- // below runs in the same commit that mounts the node, and only a
65
- // ref can say which node that was.
66
+ // A ref rather than a query for the `<main>` id: the effect below
67
+ // runs in the same commit that mounts the node, and only a ref can
68
+ // say which node that was.
66
69
  //
67
70
  // `unknown` in, narrowed here, so the returned function is both a
68
71
  // `RefCallback<HTMLElement>` and something `mergeRefs` accepts —
@@ -73,18 +76,43 @@ export const useScrollMemory = (
73
76
  node instanceof HTMLElement ? node : null
74
77
  }, [])
75
78
 
79
+ const key = entry?.key
80
+ const path = entry?.path
81
+
76
82
  useLayoutEffect(() => {
77
83
  const element = elementRef.current
78
84
 
79
- if (!element || scrollKey === undefined) {
85
+ if (
86
+ !element ||
87
+ key === undefined ||
88
+ path === undefined
89
+ ) {
80
90
  return undefined
81
91
  }
82
92
 
83
- const offset = recallScrollOffset(scrollKey)
84
-
85
- // The arrow defers the read of `applyOffset`, so the three
86
- // pieces can be declared in the order they depend on each
87
- // other rather than in the order they are used.
93
+ const currentEntry = { key, path }
94
+
95
+ // `null` means leave the scrollport alone: a search param
96
+ // changed and the reader did not go anywhere. It is read BEFORE
97
+ // this entry becomes the current one, because the answer depends
98
+ // on the page the reader is arriving from.
99
+ const offset = resolveScrollOffset(currentEntry)
100
+
101
+ enterScrollEntry(currentEntry)
102
+
103
+ // Seed the entry now rather than waiting for a `scroll` event.
104
+ // A reader who opens a group and then follows a link never
105
+ // scrolls in between, so without this the entry they came from
106
+ // is one the memory has never seen — and Back would send them to
107
+ // the top of a list they were half-way down.
108
+ rememberScrollOffset(
109
+ currentEntry,
110
+ offset ?? element.scrollTop,
111
+ )
112
+
113
+ // The arrow defers the read of `applyOffset`, so the pieces can
114
+ // be declared in the order they depend on each other rather than
115
+ // in the order they are used.
88
116
  const observer = new ResizeObserver(() => {
89
117
  applyOffset()
90
118
  })
@@ -93,12 +121,9 @@ export const useScrollMemory = (
93
121
  stopRestoring()
94
122
  }, RESTORE_WINDOW_MS)
95
123
 
96
- const stopRestoring = () => {
97
- if (!isRestoringRef.current) {
98
- return
99
- }
124
+ let isRestoring = offset !== null
100
125
 
101
- isRestoringRef.current = false
126
+ const teardown = () => {
102
127
  observer.disconnect()
103
128
  globalThis.clearTimeout(deadline)
104
129
 
@@ -110,8 +135,18 @@ export const useScrollMemory = (
110
135
  }
111
136
  }
112
137
 
138
+ const stopRestoring = () => {
139
+ if (!isRestoring) {
140
+ return
141
+ }
142
+
143
+ isRestoring = false
144
+ isRestoringRef.current = false
145
+ teardown()
146
+ }
147
+
113
148
  const applyOffset = () => {
114
- if (!isRestoringRef.current) {
149
+ if (!isRestoring || offset === null) {
115
150
  return
116
151
  }
117
152
 
@@ -128,10 +163,10 @@ export const useScrollMemory = (
128
163
  return
129
164
  }
130
165
 
131
- rememberScrollOffset(scrollKey, element.scrollTop)
166
+ rememberScrollOffset(currentEntry, element.scrollTop)
132
167
  }
133
168
 
134
- isRestoringRef.current = true
169
+ isRestoringRef.current = isRestoring
135
170
 
136
171
  for (const eventName of READER_INTENT_EVENTS) {
137
172
  element.addEventListener(eventName, stopRestoring, {
@@ -155,10 +190,15 @@ export const useScrollMemory = (
155
190
  applyOffset()
156
191
 
157
192
  return () => {
158
- stopRestoring()
193
+ // Unconditional, unlike `stopRestoring` — a restore that
194
+ // already landed still has an observer and four listeners on
195
+ // an element the next entry is about to use, and a stale
196
+ // observer that survives its effect applies a stale offset.
197
+ isRestoring = false
198
+ teardown()
159
199
  element.removeEventListener("scroll", onScroll)
160
200
  }
161
- }, [scrollKey])
201
+ }, [key, path])
162
202
 
163
203
  return setElement
164
204
  }
@@ -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 { RouterLinkProvider } from "@charcuterie/ui"
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 { routes } from "./routes.tsx"
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
- <RouterLinkProvider link={ReactRouterLink}>
48
- <RouterProvider router={router} />
49
- </RouterLinkProvider>
43
+ <BrowserRouter>
44
+ <ReactRouterAdapter>
45
+ <App />
46
+ </ReactRouterAdapter>
47
+ </BrowserRouter>
50
48
  </StrictMode>,
51
49
  )
52
50
  ```
53
51
 
54
- `RouterLinkProvider` goes **outside** `RouterProvider` here on purpose: it is plain React
55
- context and needs no router of its own, and putting it outermost means a link rendered by a
56
- route element and a link rendered by an app-level chrome component both see it.
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
- | `ReactRouterLink` | `@charcuterie/ui/react-router` | The one shipped adapter. Optional peer. |
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/index.ts CHANGED
@@ -253,6 +253,9 @@ export type {
253
253
  export { LogViewer } from "./LogViewer/LogViewer.tsx"
254
254
  export type { MainProps } from "./Main/Main.tsx"
255
255
  export { Main } from "./Main/Main.tsx"
256
+ export type { ScrollMemoryProviderProps } from "./Main/ScrollMemoryProvider.tsx"
257
+ export { ScrollMemoryProvider } from "./Main/ScrollMemoryProvider.tsx"
258
+ export type { ScrollEntry } from "./Main/scrollMemory.ts"
256
259
  export type {
257
260
  MarkdownEditorProps,
258
261
  MarkdownImageUpload,
@@ -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
+ }