@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
@@ -2,16 +2,6 @@ import { type ComponentPropsWithRef, type ReactNode } from "react";
2
2
  import { type ContentWidth } from "../Shell/contentWidth.ts";
3
3
  export type MainProps = ComponentPropsWithRef<"main"> & {
4
4
  contentWidth?: ContentWidth;
5
- /**
6
- * The history entry this page belongs to — react-router's
7
- * `useLocation().key`, and the same value under a different
8
- * router. Given it, `<main>` remembers where each entry was
9
- * scrolled to and puts the offset back on Back and Forward.
10
- *
11
- * Omit it and nothing is remembered, which is the right default
12
- * for a page with no history to remember.
13
- */
14
- scrollKey?: string;
15
5
  };
16
6
  /**
17
7
  * The `<main>` landmark and the content column.
@@ -102,18 +92,27 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
102
92
  * therefore lands at the top of a list the reader had scrolled
103
93
  * half-way down, and no browser setting changes that.
104
94
  *
105
- * Pass `scrollKey` — react-router's `useLocation().key` — and the
106
- * offset comes back. `useScrollMemory.ts` holds the mechanism and
107
- * `scrollMemory.ts` the reasoning.
95
+ * So this element remembers it, and **there is no prop for it**.
96
+ * The history entry arrives on `ScrollMemoryContext`, which
97
+ * `ReactRouterAdapter` puts at the app root:
108
98
  *
109
99
  * ```tsx
110
- * const location = useLocation()
111
- *
112
- * <Main scrollKey={location.key}>
113
- * <Outlet />
114
- * </Main>
100
+ * <BrowserRouter>
101
+ * <ReactRouterAdapter>
102
+ * <Routes>…</Routes>
103
+ * </ReactRouterAdapter>
104
+ * </BrowserRouter>
115
105
  * ```
116
106
  *
107
+ * It was a prop for one release — `scrollKey` — and that release is
108
+ * the argument against a prop. One app wired it; the other three
109
+ * carried on losing the reader's place with the library sitting
110
+ * right there supporting it. Render no provider and nothing is
111
+ * remembered, which is what an app with no router should get.
112
+ *
113
+ * `useScrollMemory.ts` holds the mechanism and `scrollMemory.ts`
114
+ * the reasoning.
115
+ *
117
116
  * ## Two more things it does, both one line
118
117
  *
119
118
  * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
@@ -143,5 +142,5 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
143
142
  * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
144
143
  * `Main.mdx` shows.
145
144
  */
146
- export declare const Main: ({ children, className, contentWidth, id, ref, scrollKey, ...mainProps }: MainProps) => ReactNode;
145
+ export declare const Main: ({ children, className, contentWidth, id, ref, ...mainProps }: MainProps) => ReactNode;
147
146
  //# sourceMappingURL=Main.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Main.d.ts","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAGf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAKjC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiIG;AACH,eAAO,MAAM,IAAI,GAAI,yEAQlB,SAAS,KAAG,SAyDd,CAAA"}
1
+ {"version":3,"file":"Main.d.ts","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAGf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAMjC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0IG;AACH,eAAO,MAAM,IAAI,GAAI,8DAOlB,SAAS,KAAG,SA0Dd,CAAA"}
package/dist/Main/Main.js CHANGED
@@ -4,6 +4,7 @@ import { useContext, } from "react";
4
4
  import { DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.js";
5
5
  import { ShellContext } from "../Shell/shellContext.js";
6
6
  import { toClassName } from "../toClassName.js";
7
+ import { ScrollMemoryContext } from "./scrollMemoryContext.js";
7
8
  import { useScrollMemory } from "./useScrollMemory.js";
8
9
  /**
9
10
  * The `<main>` landmark and the content column.
@@ -94,18 +95,27 @@ import { useScrollMemory } from "./useScrollMemory.js";
94
95
  * therefore lands at the top of a list the reader had scrolled
95
96
  * half-way down, and no browser setting changes that.
96
97
  *
97
- * Pass `scrollKey` — react-router's `useLocation().key` — and the
98
- * offset comes back. `useScrollMemory.ts` holds the mechanism and
99
- * `scrollMemory.ts` the reasoning.
98
+ * So this element remembers it, and **there is no prop for it**.
99
+ * The history entry arrives on `ScrollMemoryContext`, which
100
+ * `ReactRouterAdapter` puts at the app root:
100
101
  *
101
102
  * ```tsx
102
- * const location = useLocation()
103
- *
104
- * <Main scrollKey={location.key}>
105
- * <Outlet />
106
- * </Main>
103
+ * <BrowserRouter>
104
+ * <ReactRouterAdapter>
105
+ * <Routes>…</Routes>
106
+ * </ReactRouterAdapter>
107
+ * </BrowserRouter>
107
108
  * ```
108
109
  *
110
+ * It was a prop for one release — `scrollKey` — and that release is
111
+ * the argument against a prop. One app wired it; the other three
112
+ * carried on losing the reader's place with the library sitting
113
+ * right there supporting it. Render no provider and nothing is
114
+ * remembered, which is what an app with no router should get.
115
+ *
116
+ * `useScrollMemory.ts` holds the mechanism and `scrollMemory.ts`
117
+ * the reasoning.
118
+ *
109
119
  * ## Two more things it does, both one line
110
120
  *
111
121
  * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
@@ -135,9 +145,10 @@ import { useScrollMemory } from "./useScrollMemory.js";
135
145
  * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
136
146
  * `Main.mdx` shows.
137
147
  */
138
- export const Main = ({ children, className, contentWidth, id, ref, scrollKey, ...mainProps }) => {
148
+ export const Main = ({ children, className, contentWidth, id, ref, ...mainProps }) => {
139
149
  const shell = useContext(ShellContext);
140
- const setScrollport = useScrollMemory(scrollKey);
150
+ const scrollEntry = useContext(ScrollMemoryContext);
151
+ const setScrollport = useScrollMemory(scrollEntry);
141
152
  // Always called, never conditionally: a `Main` outside a `Shell`
142
153
  // still needs an id an app can point its own skip link at.
143
154
  const fallbackId = useUniqueId();
@@ -1 +1 @@
1
- {"version":3,"file":"Main.js","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":";AAAA,OAAO,EAEL,SAAS,EACT,WAAW,GACZ,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAIL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAgBtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiIG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,EAAE,EACF,GAAG,EACH,SAAS,EACT,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IACtC,MAAM,aAAa,GAAG,eAAe,CAAC,SAAS,CAAC,CAAA;IAEhD,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAA;IAEhC,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,kBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,6GAA6G,EAC7G,SAAS,CACV,EACD,EAAE,EAAE,EAAE,IAAI,KAAK,EAAE,MAAM,IAAI,UAAU;QACrC,0DAA0D;QAC1D,2DAA2D;QAC3D,EAAE;QACF,yDAAyD;QACzD,8DAA8D;QAC9D,qBAAqB;QACrB,GAAG,EACD,GAAG;YACD,CAAC,CAAE,SAAS,CACR,GAAmB,EACnB,aAAa,CACe;YAChC,CAAC,CAAC,aAAa,EAEnB,QAAQ,EAAE,CAAC,CAAC,YAaZ,cACE,SAAS,EAAC,uFAAuF,EACjG,KAAK,EAAE,EAAE,aAAa,EAAE,YAEvB,QAAQ,GACL,GACD,CACR,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"Main.js","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":";AAAA,OAAO,EAEL,SAAS,EACT,WAAW,GACZ,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAIL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAMtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0IG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,EAAE,EACF,GAAG,EACH,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IACtC,MAAM,WAAW,GAAG,UAAU,CAAC,mBAAmB,CAAC,CAAA;IACnD,MAAM,aAAa,GAAG,eAAe,CAAC,WAAW,CAAC,CAAA;IAElD,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAA;IAEhC,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,kBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,6GAA6G,EAC7G,SAAS,CACV,EACD,EAAE,EAAE,EAAE,IAAI,KAAK,EAAE,MAAM,IAAI,UAAU;QACrC,0DAA0D;QAC1D,2DAA2D;QAC3D,EAAE;QACF,yDAAyD;QACzD,8DAA8D;QAC9D,qBAAqB;QACrB,GAAG,EACD,GAAG;YACD,CAAC,CAAE,SAAS,CACR,GAAmB,EACnB,aAAa,CACe;YAChC,CAAC,CAAC,aAAa,EAEnB,QAAQ,EAAE,CAAC,CAAC,YAaZ,cACE,SAAS,EAAC,uFAAuF,EACjG,KAAK,EAAE,EAAE,aAAa,EAAE,YAEvB,QAAQ,GACL,GACD,CACR,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ScrollEntry } from "./scrollMemory.ts";
3
+ export type ScrollMemoryProviderProps = {
4
+ children?: ReactNode;
5
+ /**
6
+ * The history entry the page is showing, and the path it belongs
7
+ * to. Under react-router both come straight off `useLocation()`.
8
+ */
9
+ entry: ScrollEntry;
10
+ };
11
+ /**
12
+ * Tell every `Main` below this which history entry it is showing,
13
+ * so Back and Forward return the reader to where they were.
14
+ *
15
+ * An app that uses react-router should not render this directly —
16
+ * `ReactRouterAdapter` in the `./react-router` subpath wires it,
17
+ * along with the link seam, in one component at the root. This is
18
+ * the router-free half, for an app on a different router:
19
+ *
20
+ * ```tsx
21
+ * const [location] = useLocation() // wouter, say
22
+ *
23
+ * <ScrollMemoryProvider
24
+ * entry={{ key: historyKey, path: location }}
25
+ * >
26
+ * <App />
27
+ * </ScrollMemoryProvider>
28
+ * ```
29
+ *
30
+ * **`key` must identify the history entry, not the URL.** Two visits
31
+ * to the same list are two entries and the reader may leave them at
32
+ * different offsets; a key derived from the path would give the
33
+ * second visit the first one's position and could not tell Back from
34
+ * Forward. A router that exposes no such key can pass a counter it
35
+ * increments on every push and restores on every pop.
36
+ */
37
+ export declare const ScrollMemoryProvider: ({ children, entry, }: ScrollMemoryProviderProps) => ReactNode;
38
+ //# sourceMappingURL=ScrollMemoryProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScrollMemoryProvider.d.ts","sourceRoot":"","sources":["../../src/Main/ScrollMemoryProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAGpD,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB;;;OAGG;IACH,KAAK,EAAE,WAAW,CAAA;CACnB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,oBAAoB,GAAI,sBAGlC,yBAAyB,KAAG,SAI9B,CAAA"}
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ScrollMemoryContext } from "./scrollMemoryContext.js";
3
+ /**
4
+ * Tell every `Main` below this which history entry it is showing,
5
+ * so Back and Forward return the reader to where they were.
6
+ *
7
+ * An app that uses react-router should not render this directly —
8
+ * `ReactRouterAdapter` in the `./react-router` subpath wires it,
9
+ * along with the link seam, in one component at the root. This is
10
+ * the router-free half, for an app on a different router:
11
+ *
12
+ * ```tsx
13
+ * const [location] = useLocation() // wouter, say
14
+ *
15
+ * <ScrollMemoryProvider
16
+ * entry={{ key: historyKey, path: location }}
17
+ * >
18
+ * <App />
19
+ * </ScrollMemoryProvider>
20
+ * ```
21
+ *
22
+ * **`key` must identify the history entry, not the URL.** Two visits
23
+ * to the same list are two entries and the reader may leave them at
24
+ * different offsets; a key derived from the path would give the
25
+ * second visit the first one's position and could not tell Back from
26
+ * Forward. A router that exposes no such key can pass a counter it
27
+ * increments on every push and restores on every pop.
28
+ */
29
+ export const ScrollMemoryProvider = ({ children, entry, }) => (_jsx(ScrollMemoryContext.Provider, { value: entry, children: children }));
30
+ //# sourceMappingURL=ScrollMemoryProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScrollMemoryProvider.js","sourceRoot":"","sources":["../../src/Main/ScrollMemoryProvider.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAW9D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,EACnC,QAAQ,EACR,KAAK,GACqB,EAAa,EAAE,CAAC,CAC1C,KAAC,mBAAmB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YACvC,QAAQ,GACoB,CAChC,CAAA"}
@@ -1,40 +1,66 @@
1
1
  /**
2
- * What the browser will not remember for you.
3
- *
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:
2
+ * What the browser will not remember for you, and the one thing it
3
+ * would never have got wrong.
4
+ *
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
- export declare const rememberScrollOffset: (key: string, offset: number) => void;
49
+ export type ScrollEntry = {
50
+ /** The history entry — react-router's `useLocation().key`. */
51
+ key: string;
52
+ /** The page it belongs to — `useLocation().pathname`. */
53
+ path: string;
54
+ };
55
+ export declare const rememberScrollOffset: (entry: ScrollEntry, offset: number) => void;
32
56
  /**
33
- * `0` for an entry nobody has scrolled, which is also the right
34
- * answer for an entry nobody has *seen* — a link press lands at
35
- * the top of the new page.
57
+ * Where the scrollport should go for this entry, or `null` for
58
+ * **leave it alone** — which is a third answer rather than a missing
59
+ * one, and the difference between a search param and a page.
36
60
  */
37
- export declare const recallScrollOffset: (key: string) => number;
61
+ export declare const resolveScrollOffset: (entry: ScrollEntry) => number | null;
62
+ /** Called by the hook once it owns the scrollport for this entry. */
63
+ export declare const enterScrollEntry: (entry: ScrollEntry) => void;
38
64
  /** Test-only. Module state outlives a mount; a test must not. */
39
65
  export declare const forgetScrollOffsets: () => void;
40
66
  //# sourceMappingURL=scrollMemory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scrollMemory.d.ts","sourceRoot":"","sources":["../../src/Main/scrollMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAaH,eAAO,MAAM,oBAAoB,GAC/B,KAAK,MAAM,EACX,QAAQ,MAAM,SAef,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,MAAM,WACb,CAAA;AAEjC,iEAAiE;AACjE,eAAO,MAAM,mBAAmB,YAE/B,CAAA"}
1
+ {"version":3,"file":"scrollMemory.d.ts","sourceRoot":"","sources":["../../src/Main/scrollMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAUH,MAAM,MAAM,WAAW,GAAG;IACxB,8DAA8D;IAC9D,GAAG,EAAE,MAAM,CAAA;IACX,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAkBD,eAAO,MAAM,oBAAoB,GAC/B,OAAO,WAAW,EAClB,QAAQ,MAAM,SAkBf,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,WAAW,kBAYrD,CAAA;AAED,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,GAAI,OAAO,WAAW,SAElD,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,mBAAmB,YAG/B,CAAA"}
@@ -1,63 +1,104 @@
1
1
  /**
2
- * What the browser will not remember for you.
3
- *
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:
2
+ * What the browser will not remember for you, and the one thing it
3
+ * would never have got wrong.
4
+ *
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
  * How many history entries keep an offset. A reader goes back
33
51
  * through a handful of screens, never fifty, so this is a ceiling
34
52
  * against a long session rather than a working set — the map holds
35
- * one small number per entry and evicts the least recently
36
- * written.
53
+ * one small record per entry and evicts the least recently written.
37
54
  */
38
55
  const REMEMBERED_ENTRY_LIMIT = 50;
39
- const rememberedOffsets = new Map();
40
- export const rememberScrollOffset = (key, offset) => {
41
- // Delete before set, so insertion order is least-recent-first
42
- // and the eviction below takes the right one. A plain `set` on
43
- // an existing key keeps its original position.
44
- rememberedOffsets.delete(key);
45
- rememberedOffsets.set(key, offset);
46
- for (const oldestKey of rememberedOffsets.keys()) {
47
- if (rememberedOffsets.size <= REMEMBERED_ENTRY_LIMIT) {
56
+ const rememberedEntries = new Map();
57
+ /**
58
+ * The entry the scrollport is showing. Module state rather than a
59
+ * ref, because the question it answers — *did the reader change page
60
+ * or only a search param?* — is asked by the incoming entry's effect
61
+ * about the OUTGOING one, and by then the outgoing effect has
62
+ * already been cleaned up.
63
+ */
64
+ let currentEntry = null;
65
+ export const rememberScrollOffset = (entry, offset) => {
66
+ // Delete before set, so insertion order is least-recent-first and
67
+ // the eviction below takes the right one. A plain `set` on an
68
+ // existing key keeps its original position.
69
+ rememberedEntries.delete(entry.key);
70
+ rememberedEntries.set(entry.key, {
71
+ offset,
72
+ path: entry.path,
73
+ });
74
+ for (const oldestKey of rememberedEntries.keys()) {
75
+ if (rememberedEntries.size <= REMEMBERED_ENTRY_LIMIT) {
48
76
  break;
49
77
  }
50
- rememberedOffsets.delete(oldestKey);
78
+ rememberedEntries.delete(oldestKey);
51
79
  }
52
80
  };
53
81
  /**
54
- * `0` for an entry nobody has scrolled, which is also the right
55
- * answer for an entry nobody has *seen* — a link press lands at
56
- * the top of the new page.
82
+ * Where the scrollport should go for this entry, or `null` for
83
+ * **leave it alone** — which is a third answer rather than a missing
84
+ * one, and the difference between a search param and a page.
57
85
  */
58
- export const recallScrollOffset = (key) => rememberedOffsets.get(key) ?? 0;
86
+ export const resolveScrollOffset = (entry) => {
87
+ const remembered = rememberedEntries.get(entry.key);
88
+ if (remembered) {
89
+ return remembered.offset;
90
+ }
91
+ const isSamePage = currentEntry !== null &&
92
+ currentEntry.path === entry.path;
93
+ return isSamePage ? null : 0;
94
+ };
95
+ /** Called by the hook once it owns the scrollport for this entry. */
96
+ export const enterScrollEntry = (entry) => {
97
+ currentEntry = entry;
98
+ };
59
99
  /** Test-only. Module state outlives a mount; a test must not. */
60
100
  export const forgetScrollOffsets = () => {
61
- rememberedOffsets.clear();
101
+ rememberedEntries.clear();
102
+ currentEntry = null;
62
103
  };
63
104
  //# sourceMappingURL=scrollMemory.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"scrollMemory.js","sourceRoot":"","sources":["../../src/Main/scrollMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH;;;;;;GAMG;AACH,MAAM,sBAAsB,GAAG,EAAE,CAAA;AAEjC,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAAkB,CAAA;AAEnD,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,GAAW,EACX,MAAc,EACd,EAAE;IACF,8DAA8D;IAC9D,+DAA+D;IAC/D,+CAA+C;IAC/C,iBAAiB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IAC7B,iBAAiB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAA;IAElC,KAAK,MAAM,SAAS,IAAI,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC;QACjD,IAAI,iBAAiB,CAAC,IAAI,IAAI,sBAAsB,EAAE,CAAC;YACrD,MAAK;QACP,CAAC;QAED,iBAAiB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrC,CAAC;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,GAAW,EAAE,EAAE,CAChD,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AAEjC,iEAAiE;AACjE,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,EAAE;IACtC,iBAAiB,CAAC,KAAK,EAAE,CAAA;AAC3B,CAAC,CAAA"}
1
+ {"version":3,"file":"scrollMemory.js","sourceRoot":"","sources":["../../src/Main/scrollMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH;;;;;GAKG;AACH,MAAM,sBAAsB,GAAG,EAAE,CAAA;AAcjC,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAA2B,CAAA;AAE5D;;;;;;GAMG;AACH,IAAI,YAAY,GAAuB,IAAI,CAAA;AAE3C,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,KAAkB,EAClB,MAAc,EACd,EAAE;IACF,kEAAkE;IAClE,8DAA8D;IAC9D,4CAA4C;IAC5C,iBAAiB,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACnC,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE;QAC/B,MAAM;QACN,IAAI,EAAE,KAAK,CAAC,IAAI;KACjB,CAAC,CAAA;IAEF,KAAK,MAAM,SAAS,IAAI,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC;QACjD,IAAI,iBAAiB,CAAC,IAAI,IAAI,sBAAsB,EAAE,CAAC;YACrD,MAAK;QACP,CAAC;QAED,iBAAiB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrC,CAAC;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,KAAkB,EAAE,EAAE;IACxD,MAAM,UAAU,GAAG,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAEnD,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,UAAU,CAAC,MAAM,CAAA;IAC1B,CAAC;IAED,MAAM,UAAU,GACd,YAAY,KAAK,IAAI;QACrB,YAAY,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,CAAA;IAElC,OAAO,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAA;AAC9B,CAAC,CAAA;AAED,qEAAqE;AACrE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,KAAkB,EAAE,EAAE;IACrD,YAAY,GAAG,KAAK,CAAA;AACtB,CAAC,CAAA;AAED,iEAAiE;AACjE,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,EAAE;IACtC,iBAAiB,CAAC,KAAK,EAAE,CAAA;IACzB,YAAY,GAAG,IAAI,CAAA;AACrB,CAAC,CAAA"}
@@ -0,0 +1,18 @@
1
+ import type { ScrollEntry } from "./scrollMemory.ts";
2
+ /**
3
+ * How the history entry reaches `Main` without `Main` knowing a
4
+ * router exists.
5
+ *
6
+ * `@charcuterie/ui`'s main entry is router-free and stays that way —
7
+ * `sourceRules.test.ts` asserts it, and the fleet runs five
8
+ * different routers. So the entry arrives as a plain
9
+ * `{ key, path }`, and the one component that knows how to read it
10
+ * from react-router lives in the `./react-router` subpath.
11
+ *
12
+ * A context rather than a prop, because the alternative is a prop
13
+ * every app has to remember on every `Main` it renders. It was a
14
+ * prop first, exactly once, and three of the four apps that needed
15
+ * it did not have it.
16
+ */
17
+ export declare const ScrollMemoryContext: import("react").Context<ScrollEntry | null>;
18
+ //# sourceMappingURL=scrollMemoryContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scrollMemoryContext.d.ts","sourceRoot":"","sources":["../../src/Main/scrollMemoryContext.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,6CACS,CAAA"}
@@ -0,0 +1,18 @@
1
+ import { createContext } from "react";
2
+ /**
3
+ * How the history entry reaches `Main` without `Main` knowing a
4
+ * router exists.
5
+ *
6
+ * `@charcuterie/ui`'s main entry is router-free and stays that way —
7
+ * `sourceRules.test.ts` asserts it, and the fleet runs five
8
+ * different routers. So the entry arrives as a plain
9
+ * `{ key, path }`, and the one component that knows how to read it
10
+ * from react-router lives in the `./react-router` subpath.
11
+ *
12
+ * A context rather than a prop, because the alternative is a prop
13
+ * every app has to remember on every `Main` it renders. It was a
14
+ * prop first, exactly once, and three of the four apps that needed
15
+ * it did not have it.
16
+ */
17
+ export const ScrollMemoryContext = createContext(null);
18
+ //# sourceMappingURL=scrollMemoryContext.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scrollMemoryContext.js","sourceRoot":"","sources":["../../src/Main/scrollMemoryContext.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAIrC;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAC9B,aAAa,CAAqB,IAAI,CAAC,CAAA"}
@@ -1,29 +1,31 @@
1
+ import { type ScrollEntry } from "./scrollMemory.ts";
1
2
  /**
2
- * Remember where this history entry was scrolled to, and put it
3
- * back when the reader returns to it.
3
+ * Remember where this history entry was scrolled to, and put it back
4
+ * when the reader returns to it.
4
5
  *
5
- * `scrollMemory.ts` says why the browser will not. This is the
6
- * part that has to survive the two things a real page does:
6
+ * `scrollMemory.ts` says why the browser will not, and what an entry
7
+ * that has never been seen should do. This is the part that has to
8
+ * survive the two things a real page does:
7
9
  *
8
10
  * 1. **The content arrives late.** An offset applied against a
9
- * 600px skeleton clamps to `0` and looks like it did nothing.
10
- * A `ResizeObserver` on the scrollport and on its content
11
- * column re-applies the offset every time either grows, until
12
- * the offset lands or the window closes.
13
- * 2. **The reader scrolls during all that.** Any wheel, touch,
14
- * key or pointer press ends the restore immediately. The
15
- * alternative is a page that jumps out from under somebody who
16
- * has already started reading.
11
+ * 600px skeleton clamps to `0` and looks like it did nothing. A
12
+ * `ResizeObserver` on the scrollport and on its content column
13
+ * re-applies the offset every time either grows, until the
14
+ * offset lands or the window closes.
15
+ * 2. **The reader scrolls during all that.** Any wheel, touch, key
16
+ * or pointer press ends the restore immediately. The alternative
17
+ * is a page that jumps out from under somebody who has already
18
+ * started reading.
17
19
  *
18
20
  * The save is a passive `scroll` listener, suppressed while a
19
21
  * restore is in flight. Without that suppression the feature eats
20
22
  * itself: writing `scrollTop` on a still-short page clamps to `0`
21
- * and fires a `scroll` event reading `0`, which would be saved
22
- * over the very offset being restored.
23
+ * and fires a `scroll` event reading `0`, which would be saved over
24
+ * the very offset being restored.
23
25
  *
24
- * @param scrollKey Identifies the history entry — react-router's
25
- * `useLocation().key`. `undefined` turns the memory off, which is
26
- * what an app with no router gets.
26
+ * @param entry The history entry and the page it belongs to.
27
+ * `null` turns the memory off, which is what an app that renders
28
+ * no `ScrollMemoryProvider` gets.
27
29
  */
28
- export declare const useScrollMemory: (scrollKey: string | undefined) => (node: unknown) => void;
30
+ export declare const useScrollMemory: (entry: ScrollEntry | null | undefined) => (node: unknown) => void;
29
31
  //# sourceMappingURL=useScrollMemory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../src/Main/useScrollMemory.ts"],"names":[],"mappings":"AA6BA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,MAAM,GAAG,SAAS,YAaS,OAAO,SA6F9C,CAAA"}
1
+ {"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../src/Main/useScrollMemory.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,mBAAmB,CAAA;AAwB1B;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO,WAAW,GAAG,IAAI,GAAG,SAAS,YAaC,OAAO,SAkI9C,CAAA"}