@charcuterie/ui 3.29.1 → 3.30.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/Main/Main.d.ts +35 -1
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +38 -3
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/scrollMemory.d.ts +40 -0
- package/dist/Main/scrollMemory.d.ts.map +1 -0
- package/dist/Main/scrollMemory.js +63 -0
- package/dist/Main/scrollMemory.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +29 -0
- package/dist/Main/useScrollMemory.d.ts.map +1 -0
- package/dist/Main/useScrollMemory.js +131 -0
- package/dist/Main/useScrollMemory.js.map +1 -0
- package/package.json +1 -1
- package/src/Main/Main.mdx +53 -0
- package/src/Main/Main.stories.tsx +152 -0
- package/src/Main/Main.test.tsx +195 -4
- package/src/Main/Main.tsx +58 -1
- package/src/Main/scrollMemory.test.ts +59 -0
- package/src/Main/scrollMemory.ts +73 -0
- package/src/Main/useScrollMemory.ts +164 -0
package/dist/Main/Main.d.ts
CHANGED
|
@@ -2,6 +2,16 @@ 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;
|
|
5
15
|
};
|
|
6
16
|
/**
|
|
7
17
|
* The `<main>` landmark and the content column.
|
|
@@ -80,6 +90,30 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
|
80
90
|
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
81
91
|
* rather than in it.
|
|
82
92
|
*
|
|
93
|
+
* ## It remembers where each history entry was scrolled to
|
|
94
|
+
*
|
|
95
|
+
* `Shell` gives this element the page's only vertical scrollport,
|
|
96
|
+
* so the header and the rails stay put while the content moves.
|
|
97
|
+
* The browser's own scroll restoration does not follow it there: a
|
|
98
|
+
* browser remembers **the document scroller's** offset per history
|
|
99
|
+
* entry, and `<main>` is not the document scroller. It also does
|
|
100
|
+
* not restore anything at all across a same-document `pushState`
|
|
101
|
+
* navigation, which is every link press in a single-page app. Back
|
|
102
|
+
* therefore lands at the top of a list the reader had scrolled
|
|
103
|
+
* half-way down, and no browser setting changes that.
|
|
104
|
+
*
|
|
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.
|
|
108
|
+
*
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const location = useLocation()
|
|
111
|
+
*
|
|
112
|
+
* <Main scrollKey={location.key}>
|
|
113
|
+
* <Outlet />
|
|
114
|
+
* </Main>
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
83
117
|
* ## Two more things it does, both one line
|
|
84
118
|
*
|
|
85
119
|
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
@@ -109,5 +143,5 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
|
109
143
|
* wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
|
|
110
144
|
* `Main.mdx` shows.
|
|
111
145
|
*/
|
|
112
|
-
export declare const Main: ({ children, className, contentWidth, id, ...mainProps }: MainProps) => ReactNode;
|
|
146
|
+
export declare const Main: ({ children, className, contentWidth, id, ref, scrollKey, ...mainProps }: MainProps) => ReactNode;
|
|
113
147
|
//# sourceMappingURL=Main.d.ts.map
|
package/dist/Main/Main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Main.d.ts","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":"
|
|
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"}
|
package/dist/Main/Main.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useUniqueId } from "@charcuterie/logic";
|
|
2
|
+
import { mergeRefs, useUniqueId, } from "@charcuterie/logic";
|
|
3
3
|
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 { useScrollMemory } from "./useScrollMemory.js";
|
|
7
8
|
/**
|
|
8
9
|
* The `<main>` landmark and the content column.
|
|
9
10
|
*
|
|
@@ -81,6 +82,30 @@ import { toClassName } from "../toClassName.js";
|
|
|
81
82
|
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
82
83
|
* rather than in it.
|
|
83
84
|
*
|
|
85
|
+
* ## It remembers where each history entry was scrolled to
|
|
86
|
+
*
|
|
87
|
+
* `Shell` gives this element the page's only vertical scrollport,
|
|
88
|
+
* so the header and the rails stay put while the content moves.
|
|
89
|
+
* The browser's own scroll restoration does not follow it there: a
|
|
90
|
+
* browser remembers **the document scroller's** offset per history
|
|
91
|
+
* entry, and `<main>` is not the document scroller. It also does
|
|
92
|
+
* not restore anything at all across a same-document `pushState`
|
|
93
|
+
* navigation, which is every link press in a single-page app. Back
|
|
94
|
+
* therefore lands at the top of a list the reader had scrolled
|
|
95
|
+
* half-way down, and no browser setting changes that.
|
|
96
|
+
*
|
|
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.
|
|
100
|
+
*
|
|
101
|
+
* ```tsx
|
|
102
|
+
* const location = useLocation()
|
|
103
|
+
*
|
|
104
|
+
* <Main scrollKey={location.key}>
|
|
105
|
+
* <Outlet />
|
|
106
|
+
* </Main>
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
84
109
|
* ## Two more things it does, both one line
|
|
85
110
|
*
|
|
86
111
|
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
@@ -110,14 +135,24 @@ import { toClassName } from "../toClassName.js";
|
|
|
110
135
|
* wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
|
|
111
136
|
* `Main.mdx` shows.
|
|
112
137
|
*/
|
|
113
|
-
export const Main = ({ children, className, contentWidth, id, ...mainProps }) => {
|
|
138
|
+
export const Main = ({ children, className, contentWidth, id, ref, scrollKey, ...mainProps }) => {
|
|
114
139
|
const shell = useContext(ShellContext);
|
|
140
|
+
const setScrollport = useScrollMemory(scrollKey);
|
|
115
141
|
// Always called, never conditionally: a `Main` outside a `Shell`
|
|
116
142
|
// still needs an id an app can point its own skip link at.
|
|
117
143
|
const fallbackId = useUniqueId();
|
|
118
144
|
const maxInlineSize = toMaxInlineSize(contentWidth ??
|
|
119
145
|
shell?.contentWidth ??
|
|
120
146
|
DEFAULT_CONTENT_WIDTH);
|
|
121
|
-
return (_jsx("main", { ...mainProps, className: toClassName("charcuterie-scrollbar col-start-1 row-start-3 min-h-0 min-w-0 overflow-y-auto md:col-start-2 md:row-start-2", className), id: id ?? shell?.mainId ?? fallbackId,
|
|
147
|
+
return (_jsx("main", { ...mainProps, className: toClassName("charcuterie-scrollbar col-start-1 row-start-3 min-h-0 min-w-0 overflow-y-auto md:col-start-2 md:row-start-2", className), id: id ?? shell?.mainId ?? fallbackId,
|
|
148
|
+
// A caller's `ref` is a subscription, not a value, so the
|
|
149
|
+
// scroll memory composes with it rather than replacing it.
|
|
150
|
+
//
|
|
151
|
+
// The cast is `mergeRefs`' prop-merging signature, where
|
|
152
|
+
// every value is `unknown`; a caller's `ref` is narrower than
|
|
153
|
+
// that, never wider.
|
|
154
|
+
ref: ref
|
|
155
|
+
? mergeRefs(ref, setScrollport)
|
|
156
|
+
: setScrollport, tabIndex: -1, children: _jsx("div", { className: "@container mx-auto flex w-full min-w-0 flex-col gap-6 wrap-anywhere px-4 py-6 sm:px-6", style: { maxInlineSize }, children: children }) }));
|
|
122
157
|
};
|
|
123
158
|
//# sourceMappingURL=Main.js.map
|
package/dist/Main/Main.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Main.js","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":";AAAA,OAAO,
|
|
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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
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:
|
|
19
|
+
*
|
|
20
|
+
* > *"If I scroll, click on an item, then go back, it resets me to
|
|
21
|
+
* > the top. […] It should remember where the scroll position was
|
|
22
|
+
* > previously. Isn't this handled by Chrome already?"*
|
|
23
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
export declare const rememberScrollOffset: (key: string, offset: number) => void;
|
|
32
|
+
/**
|
|
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.
|
|
36
|
+
*/
|
|
37
|
+
export declare const recallScrollOffset: (key: string) => number;
|
|
38
|
+
/** Test-only. Module state outlives a mount; a test must not. */
|
|
39
|
+
export declare const forgetScrollOffsets: () => void;
|
|
40
|
+
//# sourceMappingURL=scrollMemory.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
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:
|
|
19
|
+
*
|
|
20
|
+
* > *"If I scroll, click on an item, then go back, it resets me to
|
|
21
|
+
* > the top. […] It should remember where the scroll position was
|
|
22
|
+
* > previously. Isn't this handled by Chrome already?"*
|
|
23
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* How many history entries keep an offset. A reader goes back
|
|
33
|
+
* through a handful of screens, never fifty, so this is a ceiling
|
|
34
|
+
* 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.
|
|
37
|
+
*/
|
|
38
|
+
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) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
rememberedOffsets.delete(oldestKey);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export const recallScrollOffset = (key) => rememberedOffsets.get(key) ?? 0;
|
|
59
|
+
/** Test-only. Module state outlives a mount; a test must not. */
|
|
60
|
+
export const forgetScrollOffsets = () => {
|
|
61
|
+
rememberedOffsets.clear();
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=scrollMemory.js.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember where this history entry was scrolled to, and put it
|
|
3
|
+
* back when the reader returns to it.
|
|
4
|
+
*
|
|
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:
|
|
7
|
+
*
|
|
8
|
+
* 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.
|
|
17
|
+
*
|
|
18
|
+
* The save is a passive `scroll` listener, suppressed while a
|
|
19
|
+
* restore is in flight. Without that suppression the feature eats
|
|
20
|
+
* 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
|
+
*
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export declare const useScrollMemory: (scrollKey: string | undefined) => (node: unknown) => void;
|
|
29
|
+
//# sourceMappingURL=useScrollMemory.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { recallScrollOffset, rememberScrollOffset, } from "./scrollMemory.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long a restore keeps trying while the content is still
|
|
5
|
+
* arriving.
|
|
6
|
+
*
|
|
7
|
+
* The offset cannot be applied until the scrollport is tall enough
|
|
8
|
+
* to hold it, and on a client-rendered page it is not: the commit
|
|
9
|
+
* that changes the route draws a skeleton, and the rows land a
|
|
10
|
+
* fetch later. So the restore re-applies as the content grows, and
|
|
11
|
+
* gives up after this. A page that takes longer than three seconds
|
|
12
|
+
* to fill has already been read from the top, and moving it then
|
|
13
|
+
* is a worse answer than leaving it where the reader put it.
|
|
14
|
+
*/
|
|
15
|
+
const RESTORE_WINDOW_MS = 3000;
|
|
16
|
+
/** A scroll the reader asked for. It ends the restore at once. */
|
|
17
|
+
const READER_INTENT_EVENTS = [
|
|
18
|
+
"keydown",
|
|
19
|
+
"pointerdown",
|
|
20
|
+
"touchstart",
|
|
21
|
+
"wheel",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Remember where this history entry was scrolled to, and put it
|
|
25
|
+
* back when the reader returns to it.
|
|
26
|
+
*
|
|
27
|
+
* `scrollMemory.ts` says why the browser will not. This is the
|
|
28
|
+
* part that has to survive the two things a real page does:
|
|
29
|
+
*
|
|
30
|
+
* 1. **The content arrives late.** An offset applied against a
|
|
31
|
+
* 600px skeleton clamps to `0` and looks like it did nothing.
|
|
32
|
+
* A `ResizeObserver` on the scrollport and on its content
|
|
33
|
+
* column re-applies the offset every time either grows, until
|
|
34
|
+
* the offset lands or the window closes.
|
|
35
|
+
* 2. **The reader scrolls during all that.** Any wheel, touch,
|
|
36
|
+
* key or pointer press ends the restore immediately. The
|
|
37
|
+
* alternative is a page that jumps out from under somebody who
|
|
38
|
+
* has already started reading.
|
|
39
|
+
*
|
|
40
|
+
* The save is a passive `scroll` listener, suppressed while a
|
|
41
|
+
* restore is in flight. Without that suppression the feature eats
|
|
42
|
+
* itself: writing `scrollTop` on a still-short page clamps to `0`
|
|
43
|
+
* and fires a `scroll` event reading `0`, which would be saved
|
|
44
|
+
* over the very offset being restored.
|
|
45
|
+
*
|
|
46
|
+
* @param scrollKey Identifies the history entry — react-router's
|
|
47
|
+
* `useLocation().key`. `undefined` turns the memory off, which is
|
|
48
|
+
* what an app with no router gets.
|
|
49
|
+
*/
|
|
50
|
+
export const useScrollMemory = (scrollKey) => {
|
|
51
|
+
const elementRef = useRef(null);
|
|
52
|
+
const isRestoringRef = useRef(false);
|
|
53
|
+
// A ref rather than a query for the `<main>` id: the effect
|
|
54
|
+
// below runs in the same commit that mounts the node, and only a
|
|
55
|
+
// ref can say which node that was.
|
|
56
|
+
//
|
|
57
|
+
// `unknown` in, narrowed here, so the returned function is both a
|
|
58
|
+
// `RefCallback<HTMLElement>` and something `mergeRefs` accepts —
|
|
59
|
+
// that library's merge is typed for the prop-merging path, where
|
|
60
|
+
// every value is `unknown`.
|
|
61
|
+
const setElement = useCallback((node) => {
|
|
62
|
+
elementRef.current =
|
|
63
|
+
node instanceof HTMLElement ? node : null;
|
|
64
|
+
}, []);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
const element = elementRef.current;
|
|
67
|
+
if (!element || scrollKey === undefined) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const offset = recallScrollOffset(scrollKey);
|
|
71
|
+
// The arrow defers the read of `applyOffset`, so the three
|
|
72
|
+
// pieces can be declared in the order they depend on each
|
|
73
|
+
// other rather than in the order they are used.
|
|
74
|
+
const observer = new ResizeObserver(() => {
|
|
75
|
+
applyOffset();
|
|
76
|
+
});
|
|
77
|
+
const deadline = globalThis.setTimeout(() => {
|
|
78
|
+
stopRestoring();
|
|
79
|
+
}, RESTORE_WINDOW_MS);
|
|
80
|
+
const stopRestoring = () => {
|
|
81
|
+
if (!isRestoringRef.current) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
isRestoringRef.current = false;
|
|
85
|
+
observer.disconnect();
|
|
86
|
+
globalThis.clearTimeout(deadline);
|
|
87
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
88
|
+
element.removeEventListener(eventName, stopRestoring);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const applyOffset = () => {
|
|
92
|
+
if (!isRestoringRef.current) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
element.scrollTop = offset;
|
|
96
|
+
// Landed. Anything further would only fight the reader.
|
|
97
|
+
if (element.scrollTop === offset) {
|
|
98
|
+
stopRestoring();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const onScroll = () => {
|
|
102
|
+
if (isRestoringRef.current) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
rememberScrollOffset(scrollKey, element.scrollTop);
|
|
106
|
+
};
|
|
107
|
+
isRestoringRef.current = true;
|
|
108
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
109
|
+
element.addEventListener(eventName, stopRestoring, {
|
|
110
|
+
passive: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// The scrollport's own height changes with the window; its
|
|
114
|
+
// content column's changes when the rows land. Both move the
|
|
115
|
+
// ceiling the offset is clamped against.
|
|
116
|
+
observer.observe(element);
|
|
117
|
+
for (const child of Array.from(element.children)) {
|
|
118
|
+
observer.observe(child);
|
|
119
|
+
}
|
|
120
|
+
element.addEventListener("scroll", onScroll, {
|
|
121
|
+
passive: true,
|
|
122
|
+
});
|
|
123
|
+
applyOffset();
|
|
124
|
+
return () => {
|
|
125
|
+
stopRestoring();
|
|
126
|
+
element.removeEventListener("scroll", onScroll);
|
|
127
|
+
};
|
|
128
|
+
}, [scrollKey]);
|
|
129
|
+
return setElement;
|
|
130
|
+
};
|
|
131
|
+
//# sourceMappingURL=useScrollMemory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../src/Main/useScrollMemory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/Main/Main.mdx
CHANGED
|
@@ -147,3 +147,56 @@ region, so the table is announced as a thing rather than as loose content.
|
|
|
147
147
|
`<main>` accepts focus so `Shell`'s skip link moves **focus** and not merely the scroll
|
|
148
148
|
position. Without it, Safari and Firefox scroll to `<main>` and leave the next Tab back up
|
|
149
149
|
in the header — which looks exactly like a skip link that works.
|
|
150
|
+
|
|
151
|
+
## `scrollKey` — the reader's place, kept
|
|
152
|
+
|
|
153
|
+
<Canvas of={MainStories.ScrollMemory} />
|
|
154
|
+
|
|
155
|
+
`Shell` gives this element the page's only vertical scrollport, so the header and the rails
|
|
156
|
+
stay put while the content moves. That is what `Shell.test.tsx` asserts, and this is what it
|
|
157
|
+
costs.
|
|
158
|
+
|
|
159
|
+
**The browser will not put the offset back.** A browser remembers the **document scroller's**
|
|
160
|
+
position per history entry, and `<main>` is not the document scroller. It also restores
|
|
161
|
+
nothing at all across a same-document `pushState` navigation, which is every link press in a
|
|
162
|
+
single-page app. So Back lands at the top of a list the reader had scrolled half-way down,
|
|
163
|
+
`history.scrollRestoration` has no bearing on it, and there is no browser setting that
|
|
164
|
+
changes it. The owner, on Docket:
|
|
165
|
+
|
|
166
|
+
> "If I scroll, click on an item, then go back, it resets me to the top. […] It should
|
|
167
|
+
> remember where the scroll position was previously. Isn't this handled by Chrome already?"
|
|
168
|
+
|
|
169
|
+
Pass the history entry's key and it is:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
const location = useLocation()
|
|
173
|
+
|
|
174
|
+
<Main scrollKey={location.key}>
|
|
175
|
+
<Outlet />
|
|
176
|
+
</Main>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Omit it and nothing is remembered, which is right for a page with no history to remember.
|
|
180
|
+
|
|
181
|
+
### It has to wait for the content
|
|
182
|
+
|
|
183
|
+
<Canvas of={MainStories.ScrollMemoryWithLateContent} />
|
|
184
|
+
|
|
185
|
+
A one-shot `scrollTop = offset` does **nothing** on a real page. The commit that changes the
|
|
186
|
+
route draws a skeleton, the rows land a fetch later, and an offset written against an empty
|
|
187
|
+
scrollport is clamped to `0` by the browser. Every check a one-shot restore could make still
|
|
188
|
+
passes — the offset was remembered, the write happened — and the reader is looking at the top
|
|
189
|
+
of the list.
|
|
190
|
+
|
|
191
|
+
So the restore re-applies through a `ResizeObserver` on the scrollport and on its content
|
|
192
|
+
column, and stops at the first of three things:
|
|
193
|
+
|
|
194
|
+
1. **The offset lands.** Anything past that would only fight the reader.
|
|
195
|
+
2. **The reader scrolls.** Any `wheel`, `touchstart`, `keydown` or `pointerdown` on the
|
|
196
|
+
scrollport ends it, because a page that jumps out from under somebody who has already
|
|
197
|
+
started reading is a worse bug than the one being fixed.
|
|
198
|
+
3. **Three seconds pass.** A page that slow has been read from the top already.
|
|
199
|
+
|
|
200
|
+
The offsets live in memory, not in `sessionStorage`. A history key survives a reload; the DOM
|
|
201
|
+
it described does not, and a pixel offset from before the reload points at a different row.
|
|
202
|
+
Restoring a stale number is worse than restoring nothing, because it looks deliberate.
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import { useEffect, useState } from "react"
|
|
2
3
|
|
|
3
4
|
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
5
|
+
import { Button } from "../Button/Button.tsx"
|
|
4
6
|
import { Card } from "../Card/Card.tsx"
|
|
5
7
|
import { Header } from "../Header/Header.tsx"
|
|
6
8
|
import { Rail } from "../Rail/Rail.tsx"
|
|
7
9
|
import { toMaxInlineSize } from "../Shell/contentWidth.ts"
|
|
8
10
|
import { Shell } from "../Shell/Shell.tsx"
|
|
11
|
+
import { Spinner } from "../Spinner/Spinner.tsx"
|
|
9
12
|
import {
|
|
10
13
|
HeaderSchemeToggle,
|
|
11
14
|
OverflowingContent,
|
|
@@ -162,3 +165,152 @@ export const Interactive: Story = {
|
|
|
162
165
|
</Shell>
|
|
163
166
|
),
|
|
164
167
|
}
|
|
168
|
+
|
|
169
|
+
const EPISODE_TITLES = Array.from(
|
|
170
|
+
{ length: 40 },
|
|
171
|
+
(_unused, index) => `Episode ${index + 1}`,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
type HistoryEntry = {
|
|
175
|
+
episode: string | null
|
|
176
|
+
key: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A history stack, small enough to read and honest about the one
|
|
181
|
+
* thing that matters: **Back returns to the entry it left, with
|
|
182
|
+
* the key it had.** That key is what the offset is filed under,
|
|
183
|
+
* and reusing it is the whole mechanism.
|
|
184
|
+
*
|
|
185
|
+
* `contentDelayMs` is the second half of the problem. A real list
|
|
186
|
+
* is fetched, so the commit that changes the route draws an empty
|
|
187
|
+
* scrollport and the rows land later — an offset applied against
|
|
188
|
+
* that clamps to `0`. Set it and the restore has to wait for the
|
|
189
|
+
* content, which is the case the `ResizeObserver` in
|
|
190
|
+
* `useScrollMemory` exists for.
|
|
191
|
+
*/
|
|
192
|
+
const ScrollMemoryDemo = ({
|
|
193
|
+
contentDelayMs,
|
|
194
|
+
}: {
|
|
195
|
+
contentDelayMs: number
|
|
196
|
+
}) => {
|
|
197
|
+
const [entries, setEntries] = useState<HistoryEntry[]>([
|
|
198
|
+
{ episode: null, key: "entry-1" },
|
|
199
|
+
])
|
|
200
|
+
const [entryIndex, setEntryIndex] = useState(0)
|
|
201
|
+
const [isContentReady, setIsContentReady] = useState(
|
|
202
|
+
contentDelayMs === 0,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const entry = entries[entryIndex]
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (contentDelayMs === 0) {
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setIsContentReady(false)
|
|
213
|
+
|
|
214
|
+
const timer = globalThis.setTimeout(() => {
|
|
215
|
+
setIsContentReady(true)
|
|
216
|
+
}, contentDelayMs)
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
globalThis.clearTimeout(timer)
|
|
220
|
+
}
|
|
221
|
+
}, [contentDelayMs])
|
|
222
|
+
|
|
223
|
+
const open = (episode: string) => {
|
|
224
|
+
const opened = [
|
|
225
|
+
...entries.slice(0, entryIndex + 1),
|
|
226
|
+
{ episode, key: `entry-${entries.length + 1}` },
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
setEntries(opened)
|
|
230
|
+
setEntryIndex(opened.length - 1)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Shell>
|
|
235
|
+
<Header
|
|
236
|
+
actions={
|
|
237
|
+
<Button
|
|
238
|
+
appearance="outline"
|
|
239
|
+
isDisabled={entryIndex === 0}
|
|
240
|
+
onClick={() => {
|
|
241
|
+
setEntryIndex(entryIndex - 1)
|
|
242
|
+
}}
|
|
243
|
+
size="sm"
|
|
244
|
+
>
|
|
245
|
+
Back
|
|
246
|
+
</Button>
|
|
247
|
+
}
|
|
248
|
+
heading="Library"
|
|
249
|
+
/>
|
|
250
|
+
|
|
251
|
+
<Main scrollKey={entry?.key}>
|
|
252
|
+
{!isContentReady && <Spinner label="Loading" />}
|
|
253
|
+
|
|
254
|
+
{isContentReady && entry?.episode == null && (
|
|
255
|
+
<div className="flex flex-col gap-3">
|
|
256
|
+
{EPISODE_TITLES.map((title) => (
|
|
257
|
+
<Card
|
|
258
|
+
heading={title}
|
|
259
|
+
key={title}
|
|
260
|
+
padding="sm"
|
|
261
|
+
>
|
|
262
|
+
<Button
|
|
263
|
+
appearance="ghost"
|
|
264
|
+
onClick={() => {
|
|
265
|
+
open(title)
|
|
266
|
+
}}
|
|
267
|
+
size="sm"
|
|
268
|
+
>
|
|
269
|
+
Open {title}
|
|
270
|
+
</Button>
|
|
271
|
+
</Card>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{isContentReady && entry?.episode != null && (
|
|
277
|
+
<Card heading={entry.episode}>
|
|
278
|
+
<p className="text-content-secondary text-sm">
|
|
279
|
+
A page short enough that the scrollport
|
|
280
|
+
collapses behind it. Press Back: the list
|
|
281
|
+
returns to where it was, not to the top.
|
|
282
|
+
</p>
|
|
283
|
+
</Card>
|
|
284
|
+
)}
|
|
285
|
+
</Main>
|
|
286
|
+
</Shell>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The reader's place, kept.
|
|
292
|
+
*
|
|
293
|
+
* `Shell` makes `<main>` the page's only vertical scrollport, and
|
|
294
|
+
* a browser restores the **document** scroller and nothing else —
|
|
295
|
+
* so without `scrollKey` this list comes back at the top every
|
|
296
|
+
* time, in every browser, with no setting that changes it.
|
|
297
|
+
*
|
|
298
|
+
* Scroll the list, open an episode, then press Back.
|
|
299
|
+
*/
|
|
300
|
+
export const ScrollMemory: Story = {
|
|
301
|
+
render: () => <ScrollMemoryDemo contentDelayMs={0} />,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The same thing, with the rows arriving 250ms late — which is
|
|
306
|
+
* what a fetched list does, and what makes a naive
|
|
307
|
+
* `scrollTop = offset` do nothing at all.
|
|
308
|
+
*
|
|
309
|
+
* Back lands on an empty scrollport with no room for the offset,
|
|
310
|
+
* so the browser clamps it to `0`. The restore therefore re-applies
|
|
311
|
+
* as the content grows and stops the moment the offset lands, or
|
|
312
|
+
* the moment the reader scrolls.
|
|
313
|
+
*/
|
|
314
|
+
export const ScrollMemoryWithLateContent: Story = {
|
|
315
|
+
render: () => <ScrollMemoryDemo contentDelayMs={250} />,
|
|
316
|
+
}
|
package/src/Main/Main.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { composeStories } from "@storybook/react"
|
|
2
|
-
import { expect, userEvent } from "storybook/test"
|
|
3
|
-
import { afterAll, test } from "vitest"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { afterAll, afterEach, test } from "vitest"
|
|
4
4
|
|
|
5
5
|
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
6
|
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
@@ -11,14 +11,26 @@ import {
|
|
|
11
11
|
setViewport,
|
|
12
12
|
} from "../viewport.testHelpers.ts"
|
|
13
13
|
import * as stories from "./Main.stories.tsx"
|
|
14
|
+
import { forgetScrollOffsets } from "./scrollMemory.ts"
|
|
14
15
|
|
|
15
|
-
const {
|
|
16
|
-
|
|
16
|
+
const {
|
|
17
|
+
Default,
|
|
18
|
+
Interactive,
|
|
19
|
+
Responsive,
|
|
20
|
+
ScrollMemory,
|
|
21
|
+
ScrollMemoryWithLateContent,
|
|
22
|
+
} = composeStories(stories)
|
|
17
23
|
|
|
18
24
|
afterAll(async () => {
|
|
19
25
|
await setViewport(DESKTOP)
|
|
20
26
|
})
|
|
21
27
|
|
|
28
|
+
// The offsets are module state, so they outlive a mount. A test
|
|
29
|
+
// must not.
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
forgetScrollOffsets()
|
|
32
|
+
})
|
|
33
|
+
|
|
22
34
|
test("the content column is capped at the token, not at the window", async () => {
|
|
23
35
|
await setViewport(DESKTOP)
|
|
24
36
|
|
|
@@ -165,3 +177,182 @@ test("main takes focus from a skip link without joining the tab order", async ()
|
|
|
165
177
|
|
|
166
178
|
await expectNoAxeViolations(canvasElement)
|
|
167
179
|
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A `scroll` event is dispatched at the next rendering step, not
|
|
183
|
+
* at the assignment, so a test that scrolls and immediately
|
|
184
|
+
* navigates measures the listener before it has been told
|
|
185
|
+
* anything.
|
|
186
|
+
*/
|
|
187
|
+
const scrollTo = async (
|
|
188
|
+
element: HTMLElement,
|
|
189
|
+
offset: number,
|
|
190
|
+
) => {
|
|
191
|
+
element.scrollTop = offset
|
|
192
|
+
|
|
193
|
+
await waitFor(async () => {
|
|
194
|
+
await expect(element.scrollTop).toBe(offset)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
await new Promise((resolve) => {
|
|
198
|
+
globalThis.requestAnimationFrame(() => {
|
|
199
|
+
globalThis.requestAnimationFrame(resolve)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The whole point, and the thing no browser does for you.
|
|
206
|
+
*
|
|
207
|
+
* `Shell` makes `<main>` the page's only vertical scrollport.
|
|
208
|
+
* `history.scrollRestoration` governs the **document** scroller, so
|
|
209
|
+
* it has nothing to say about this element — and it says nothing at
|
|
210
|
+
* all about a same-document navigation, which is every link press
|
|
211
|
+
* in a single-page app.
|
|
212
|
+
*/
|
|
213
|
+
test("back returns the list to where it was scrolled, not to the top", async () => {
|
|
214
|
+
await setViewport(DESKTOP)
|
|
215
|
+
|
|
216
|
+
const { canvas, canvasElement } =
|
|
217
|
+
await mountStory(ScrollMemory)
|
|
218
|
+
|
|
219
|
+
const main = canvas.getByRole("main")
|
|
220
|
+
|
|
221
|
+
// The fixture genuinely scrolls, or the assertion below would
|
|
222
|
+
// pass against a page that was never long enough to lose a
|
|
223
|
+
// place in.
|
|
224
|
+
await expect(main.scrollHeight).toBeGreaterThan(
|
|
225
|
+
main.clientHeight + 900,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
await scrollTo(main, 900)
|
|
229
|
+
|
|
230
|
+
await userEvent.click(
|
|
231
|
+
expectAgentDrivable(canvas, {
|
|
232
|
+
name: "Open Episode 12",
|
|
233
|
+
role: "button",
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
canvas.getByRole("heading", { name: "Episode 12" }),
|
|
239
|
+
).toBeVisible()
|
|
240
|
+
|
|
241
|
+
await userEvent.click(
|
|
242
|
+
expectAgentDrivable(canvas, {
|
|
243
|
+
name: "Back",
|
|
244
|
+
role: "button",
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await waitFor(async () => {
|
|
249
|
+
await expect(main.scrollTop).toBe(900)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await expectNoAxeViolations(canvasElement)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The failure a naive `scrollTop = offset` produces, and the
|
|
257
|
+
* reason `useScrollMemory` keeps a `ResizeObserver` rather than
|
|
258
|
+
* writing the number once.
|
|
259
|
+
*
|
|
260
|
+
* Back commits before the rows arrive, so the scrollport has no
|
|
261
|
+
* room for the offset and the browser clamps the write to `0`.
|
|
262
|
+
* Every assertion a one-shot restore could make still passes — the
|
|
263
|
+
* offset was remembered, the write happened — and the reader is
|
|
264
|
+
* looking at the top of the list.
|
|
265
|
+
*/
|
|
266
|
+
test("a restore waits for content that arrives after the navigation", async () => {
|
|
267
|
+
await setViewport(DESKTOP)
|
|
268
|
+
|
|
269
|
+
const { canvas } = await mountStory(
|
|
270
|
+
ScrollMemoryWithLateContent,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const main = await waitFor(() => canvas.getByRole("main"))
|
|
274
|
+
|
|
275
|
+
await waitFor(async () => {
|
|
276
|
+
await expect(
|
|
277
|
+
canvas.getByRole("heading", { name: "Episode 1" }),
|
|
278
|
+
).toBeVisible()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await scrollTo(main, 900)
|
|
282
|
+
|
|
283
|
+
await userEvent.click(
|
|
284
|
+
expectAgentDrivable(canvas, {
|
|
285
|
+
name: "Open Episode 12",
|
|
286
|
+
role: "button",
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
await userEvent.click(
|
|
291
|
+
expectAgentDrivable(canvas, {
|
|
292
|
+
name: "Back",
|
|
293
|
+
role: "button",
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
// No assertion on the intermediate `0`: whether the rows have
|
|
298
|
+
// landed by this line depends on how loaded the machine is, and
|
|
299
|
+
// a test that reads the clock is a test that fails on CI. What
|
|
300
|
+
// proves the wait is load-bearing is that deleting the
|
|
301
|
+
// `ResizeObserver` from `useScrollMemory` fails **this** test and
|
|
302
|
+
// no other.
|
|
303
|
+
await waitFor(async () => {
|
|
304
|
+
await expect(main.scrollTop).toBe(900)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A restore that outlives the reader's first scroll is a page that
|
|
310
|
+
* fights them. Anything the reader does to the scrollport ends it.
|
|
311
|
+
*/
|
|
312
|
+
test("a reader scrolling during a restore keeps their own position", async () => {
|
|
313
|
+
await setViewport(DESKTOP)
|
|
314
|
+
|
|
315
|
+
const { canvas } = await mountStory(
|
|
316
|
+
ScrollMemoryWithLateContent,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
await waitFor(async () => {
|
|
320
|
+
await expect(
|
|
321
|
+
canvas.getByRole("heading", { name: "Episode 1" }),
|
|
322
|
+
).toBeVisible()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const main = canvas.getByRole("main")
|
|
326
|
+
|
|
327
|
+
await scrollTo(main, 900)
|
|
328
|
+
|
|
329
|
+
await userEvent.click(
|
|
330
|
+
expectAgentDrivable(canvas, {
|
|
331
|
+
name: "Open Episode 12",
|
|
332
|
+
role: "button",
|
|
333
|
+
}),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
await userEvent.click(
|
|
337
|
+
expectAgentDrivable(canvas, {
|
|
338
|
+
name: "Back",
|
|
339
|
+
role: "button",
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
// Before the rows land, so the restore is still pending.
|
|
344
|
+
main.dispatchEvent(
|
|
345
|
+
new WheelEvent("wheel", { bubbles: true }),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
await waitFor(async () => {
|
|
349
|
+
await expect(
|
|
350
|
+
canvas.getByRole("heading", { name: "Episode 1" }),
|
|
351
|
+
).toBeVisible()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
await scrollTo(main, 200)
|
|
355
|
+
|
|
356
|
+
// Given up, rather than dragging the reader back to 900.
|
|
357
|
+
await expect(main.scrollTop).toBe(200)
|
|
358
|
+
})
|
package/src/Main/Main.tsx
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type MergeableRef,
|
|
3
|
+
mergeRefs,
|
|
4
|
+
useUniqueId,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
2
6
|
import {
|
|
3
7
|
type ComponentPropsWithRef,
|
|
4
8
|
type ReactNode,
|
|
9
|
+
type RefCallback,
|
|
5
10
|
useContext,
|
|
6
11
|
} from "react"
|
|
7
12
|
|
|
@@ -12,9 +17,20 @@ import {
|
|
|
12
17
|
} from "../Shell/contentWidth.ts"
|
|
13
18
|
import { ShellContext } from "../Shell/shellContext.ts"
|
|
14
19
|
import { toClassName } from "../toClassName.ts"
|
|
20
|
+
import { useScrollMemory } from "./useScrollMemory.ts"
|
|
15
21
|
|
|
16
22
|
export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
17
23
|
contentWidth?: ContentWidth
|
|
24
|
+
/**
|
|
25
|
+
* The history entry this page belongs to — react-router's
|
|
26
|
+
* `useLocation().key`, and the same value under a different
|
|
27
|
+
* router. Given it, `<main>` remembers where each entry was
|
|
28
|
+
* scrolled to and puts the offset back on Back and Forward.
|
|
29
|
+
*
|
|
30
|
+
* Omit it and nothing is remembered, which is the right default
|
|
31
|
+
* for a page with no history to remember.
|
|
32
|
+
*/
|
|
33
|
+
scrollKey?: string
|
|
18
34
|
}
|
|
19
35
|
|
|
20
36
|
/**
|
|
@@ -94,6 +110,30 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
|
94
110
|
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
95
111
|
* rather than in it.
|
|
96
112
|
*
|
|
113
|
+
* ## It remembers where each history entry was scrolled to
|
|
114
|
+
*
|
|
115
|
+
* `Shell` gives this element the page's only vertical scrollport,
|
|
116
|
+
* so the header and the rails stay put while the content moves.
|
|
117
|
+
* The browser's own scroll restoration does not follow it there: a
|
|
118
|
+
* browser remembers **the document scroller's** offset per history
|
|
119
|
+
* entry, and `<main>` is not the document scroller. It also does
|
|
120
|
+
* not restore anything at all across a same-document `pushState`
|
|
121
|
+
* navigation, which is every link press in a single-page app. Back
|
|
122
|
+
* therefore lands at the top of a list the reader had scrolled
|
|
123
|
+
* half-way down, and no browser setting changes that.
|
|
124
|
+
*
|
|
125
|
+
* Pass `scrollKey` — react-router's `useLocation().key` — and the
|
|
126
|
+
* offset comes back. `useScrollMemory.ts` holds the mechanism and
|
|
127
|
+
* `scrollMemory.ts` the reasoning.
|
|
128
|
+
*
|
|
129
|
+
* ```tsx
|
|
130
|
+
* const location = useLocation()
|
|
131
|
+
*
|
|
132
|
+
* <Main scrollKey={location.key}>
|
|
133
|
+
* <Outlet />
|
|
134
|
+
* </Main>
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
97
137
|
* ## Two more things it does, both one line
|
|
98
138
|
*
|
|
99
139
|
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
@@ -128,9 +168,12 @@ export const Main = ({
|
|
|
128
168
|
className,
|
|
129
169
|
contentWidth,
|
|
130
170
|
id,
|
|
171
|
+
ref,
|
|
172
|
+
scrollKey,
|
|
131
173
|
...mainProps
|
|
132
174
|
}: MainProps): ReactNode => {
|
|
133
175
|
const shell = useContext(ShellContext)
|
|
176
|
+
const setScrollport = useScrollMemory(scrollKey)
|
|
134
177
|
|
|
135
178
|
// Always called, never conditionally: a `Main` outside a `Shell`
|
|
136
179
|
// still needs an id an app can point its own skip link at.
|
|
@@ -150,6 +193,20 @@ export const Main = ({
|
|
|
150
193
|
className,
|
|
151
194
|
)}
|
|
152
195
|
id={id ?? shell?.mainId ?? fallbackId}
|
|
196
|
+
// A caller's `ref` is a subscription, not a value, so the
|
|
197
|
+
// scroll memory composes with it rather than replacing it.
|
|
198
|
+
//
|
|
199
|
+
// The cast is `mergeRefs`' prop-merging signature, where
|
|
200
|
+
// every value is `unknown`; a caller's `ref` is narrower than
|
|
201
|
+
// that, never wider.
|
|
202
|
+
ref={
|
|
203
|
+
ref
|
|
204
|
+
? (mergeRefs(
|
|
205
|
+
ref as MergeableRef,
|
|
206
|
+
setScrollport,
|
|
207
|
+
) as RefCallback<HTMLElement>)
|
|
208
|
+
: setScrollport
|
|
209
|
+
}
|
|
153
210
|
tabIndex={-1}
|
|
154
211
|
>
|
|
155
212
|
{/*
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forgetScrollOffsets,
|
|
5
|
+
recallScrollOffset,
|
|
6
|
+
rememberScrollOffset,
|
|
7
|
+
} from "./scrollMemory.ts"
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
forgetScrollOffsets()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("an entry nobody has scrolled recalls the top", () => {
|
|
14
|
+
expect(recallScrollOffset("entry-never-seen")).toBe(0)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("an entry recalls the offset it was last left at", () => {
|
|
18
|
+
rememberScrollOffset("entry-1", 900)
|
|
19
|
+
rememberScrollOffset("entry-1", 1240)
|
|
20
|
+
|
|
21
|
+
expect(recallScrollOffset("entry-1")).toBe(1240)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A long session pushes a new history entry per link press, and
|
|
26
|
+
* every one of them would otherwise keep a number forever. The
|
|
27
|
+
* eviction is least-recently-*written*, which is why re-writing an
|
|
28
|
+
* entry has to move it to the end — a plain `Map.set` on an
|
|
29
|
+
* existing key keeps its original position, so the entry the
|
|
30
|
+
* reader is using would age out while it was in use.
|
|
31
|
+
*/
|
|
32
|
+
test("a long session evicts the least recently written entry", () => {
|
|
33
|
+
for (let index = 0; index < 60; index += 1) {
|
|
34
|
+
rememberScrollOffset(`entry-${index}`, index * 10)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Written first, never re-written: gone.
|
|
38
|
+
expect(recallScrollOffset("entry-0")).toBe(0)
|
|
39
|
+
|
|
40
|
+
// Inside the last fifty: kept.
|
|
41
|
+
expect(recallScrollOffset("entry-59")).toBe(590)
|
|
42
|
+
expect(recallScrollOffset("entry-10")).toBe(100)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("re-writing an entry saves it from eviction", () => {
|
|
46
|
+
rememberScrollOffset("entry-old", 500)
|
|
47
|
+
|
|
48
|
+
for (let index = 0; index < 49; index += 1) {
|
|
49
|
+
rememberScrollOffset(`entry-${index}`, index * 10)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
rememberScrollOffset("entry-old", 640)
|
|
53
|
+
|
|
54
|
+
for (let index = 49; index < 60; index += 1) {
|
|
55
|
+
rememberScrollOffset(`entry-${index}`, index * 10)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(recallScrollOffset("entry-old")).toBe(640)
|
|
59
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
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:
|
|
19
|
+
*
|
|
20
|
+
* > *"If I scroll, click on an item, then go back, it resets me to
|
|
21
|
+
* > the top. […] It should remember where the scroll position was
|
|
22
|
+
* > previously. Isn't this handled by Chrome already?"*
|
|
23
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How many history entries keep an offset. A reader goes back
|
|
34
|
+
* through a handful of screens, never fifty, so this is a ceiling
|
|
35
|
+
* 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.
|
|
38
|
+
*/
|
|
39
|
+
const REMEMBERED_ENTRY_LIMIT = 50
|
|
40
|
+
|
|
41
|
+
const rememberedOffsets = new Map<string, number>()
|
|
42
|
+
|
|
43
|
+
export const rememberScrollOffset = (
|
|
44
|
+
key: string,
|
|
45
|
+
offset: number,
|
|
46
|
+
) => {
|
|
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)
|
|
52
|
+
|
|
53
|
+
for (const oldestKey of rememberedOffsets.keys()) {
|
|
54
|
+
if (rememberedOffsets.size <= REMEMBERED_ENTRY_LIMIT) {
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
rememberedOffsets.delete(oldestKey)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export const recallScrollOffset = (key: string) =>
|
|
68
|
+
rememberedOffsets.get(key) ?? 0
|
|
69
|
+
|
|
70
|
+
/** Test-only. Module state outlives a mount; a test must not. */
|
|
71
|
+
export const forgetScrollOffsets = () => {
|
|
72
|
+
rememberedOffsets.clear()
|
|
73
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
recallScrollOffset,
|
|
5
|
+
rememberScrollOffset,
|
|
6
|
+
} from "./scrollMemory.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long a restore keeps trying while the content is still
|
|
10
|
+
* arriving.
|
|
11
|
+
*
|
|
12
|
+
* The offset cannot be applied until the scrollport is tall enough
|
|
13
|
+
* 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.
|
|
19
|
+
*/
|
|
20
|
+
const RESTORE_WINDOW_MS = 3000
|
|
21
|
+
|
|
22
|
+
/** A scroll the reader asked for. It ends the restore at once. */
|
|
23
|
+
const READER_INTENT_EVENTS = [
|
|
24
|
+
"keydown",
|
|
25
|
+
"pointerdown",
|
|
26
|
+
"touchstart",
|
|
27
|
+
"wheel",
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remember where this history entry was scrolled to, and put it
|
|
32
|
+
* back when the reader returns to it.
|
|
33
|
+
*
|
|
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
|
+
*
|
|
37
|
+
* 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.
|
|
46
|
+
*
|
|
47
|
+
* The save is a passive `scroll` listener, suppressed while a
|
|
48
|
+
* restore is in flight. Without that suppression the feature eats
|
|
49
|
+
* 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.
|
|
52
|
+
*
|
|
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
|
+
*/
|
|
57
|
+
export const useScrollMemory = (
|
|
58
|
+
scrollKey: string | undefined,
|
|
59
|
+
) => {
|
|
60
|
+
const elementRef = useRef<HTMLElement | null>(null)
|
|
61
|
+
const isRestoringRef = useRef(false)
|
|
62
|
+
|
|
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
|
+
//
|
|
67
|
+
// `unknown` in, narrowed here, so the returned function is both a
|
|
68
|
+
// `RefCallback<HTMLElement>` and something `mergeRefs` accepts —
|
|
69
|
+
// that library's merge is typed for the prop-merging path, where
|
|
70
|
+
// every value is `unknown`.
|
|
71
|
+
const setElement = useCallback((node: unknown) => {
|
|
72
|
+
elementRef.current =
|
|
73
|
+
node instanceof HTMLElement ? node : null
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
const element = elementRef.current
|
|
78
|
+
|
|
79
|
+
if (!element || scrollKey === undefined) {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
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.
|
|
88
|
+
const observer = new ResizeObserver(() => {
|
|
89
|
+
applyOffset()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const deadline = globalThis.setTimeout(() => {
|
|
93
|
+
stopRestoring()
|
|
94
|
+
}, RESTORE_WINDOW_MS)
|
|
95
|
+
|
|
96
|
+
const stopRestoring = () => {
|
|
97
|
+
if (!isRestoringRef.current) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isRestoringRef.current = false
|
|
102
|
+
observer.disconnect()
|
|
103
|
+
globalThis.clearTimeout(deadline)
|
|
104
|
+
|
|
105
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
106
|
+
element.removeEventListener(
|
|
107
|
+
eventName,
|
|
108
|
+
stopRestoring,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const applyOffset = () => {
|
|
114
|
+
if (!isRestoringRef.current) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
element.scrollTop = offset
|
|
119
|
+
|
|
120
|
+
// Landed. Anything further would only fight the reader.
|
|
121
|
+
if (element.scrollTop === offset) {
|
|
122
|
+
stopRestoring()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onScroll = () => {
|
|
127
|
+
if (isRestoringRef.current) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
rememberScrollOffset(scrollKey, element.scrollTop)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
isRestoringRef.current = true
|
|
135
|
+
|
|
136
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
137
|
+
element.addEventListener(eventName, stopRestoring, {
|
|
138
|
+
passive: true,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The scrollport's own height changes with the window; its
|
|
143
|
+
// content column's changes when the rows land. Both move the
|
|
144
|
+
// ceiling the offset is clamped against.
|
|
145
|
+
observer.observe(element)
|
|
146
|
+
|
|
147
|
+
for (const child of Array.from(element.children)) {
|
|
148
|
+
observer.observe(child)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
element.addEventListener("scroll", onScroll, {
|
|
152
|
+
passive: true,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
applyOffset()
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
stopRestoring()
|
|
159
|
+
element.removeEventListener("scroll", onScroll)
|
|
160
|
+
}
|
|
161
|
+
}, [scrollKey])
|
|
162
|
+
|
|
163
|
+
return setElement
|
|
164
|
+
}
|