@escape-game-over/atlas 0.1.30 → 0.1.32
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/docs/client-scripts.md +11 -0
- package/package.json +1 -1
- package/src/astro/Document.astro +10 -1
- package/src/astro/ScrollRestore.astro +57 -0
- package/src/astro/carousel.ts +39 -5
package/docs/client-scripts.md
CHANGED
|
@@ -49,6 +49,17 @@ analytics need no permission, and remembers, expires and applies the answer. The
|
|
|
49
49
|
markup marks its buttons `data-consent="grant"` and `data-consent="deny"`,
|
|
50
50
|
and the control that brings it back `data-consent-reopen hidden`.
|
|
51
51
|
|
|
52
|
+
`Document` ships one more: scroll restoration, on unless `restoreScroll={false}`.
|
|
53
|
+
WebKit restores a page's position only at `load`, after every image, so a page
|
|
54
|
+
it reloads on back paints its top and then snaps down — and Firefox on iOS
|
|
55
|
+
reloads on nearly every back. An inline script at the end of `<body>` saves the
|
|
56
|
+
position on the history entry once scrolling settles, and puts it back before
|
|
57
|
+
the first paint. On the entry, not in storage keyed by URL, so a link to the same
|
|
58
|
+
page still opens at the top. Never on `pagehide`: WebKit fires it after moving to
|
|
59
|
+
the previous entry, and the write wipes the position about to be restored. A
|
|
60
|
+
site on Astro's `ClientRouter` turns it off; the router keeps its own position
|
|
61
|
+
in the same history state.
|
|
62
|
+
|
|
52
63
|
**Browser code has one import path: `@escape-game-over/atlas/client`**, which
|
|
53
64
|
re-exports every module above. None of it touches the DOM at import, so an
|
|
54
65
|
element's contract can be imported in frontmatter from the same path.
|
package/package.json
CHANGED
package/src/astro/Document.astro
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* `head-start` comes before every tag Atlas writes — a consent manager that must
|
|
6
6
|
* load ahead of the analytics goes there; `head` comes after.
|
|
7
7
|
*
|
|
8
|
+
* It also restores the scroll position on back and reload; see
|
|
9
|
+
* `docs/client-scripts.md`. A site on Astro's `ClientRouter` passes
|
|
10
|
+
* `restoreScroll={false}`: the router keeps its own position in the same
|
|
11
|
+
* history state.
|
|
12
|
+
*
|
|
8
13
|
* ```astro
|
|
9
14
|
* <Document meta={meta} html={{ class: "scroll-smooth" }} body={{ class: "…" }}>
|
|
10
15
|
* <Fragment slot="head"><link rel="preload" … /></Fragment>
|
|
@@ -15,15 +20,18 @@
|
|
|
15
20
|
import type { HTMLAttributes } from "astro/types";
|
|
16
21
|
import type { PageMeta } from "../site/index.ts";
|
|
17
22
|
import MetaTags from "./MetaTags.astro";
|
|
23
|
+
import ScrollRestore from "./ScrollRestore.astro";
|
|
18
24
|
|
|
19
25
|
interface Props {
|
|
20
26
|
readonly meta: PageMeta;
|
|
21
27
|
/** `lang` and `dir` are the page's locale's, from `meta`. */
|
|
22
28
|
readonly html?: Omit<HTMLAttributes<"html">, "lang" | "dir">;
|
|
23
29
|
readonly body?: HTMLAttributes<"body">;
|
|
30
|
+
/** Defaults to `true`; `false` for a site on `ClientRouter`. */
|
|
31
|
+
readonly restoreScroll?: boolean;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
const { meta, html, body } = Astro.props;
|
|
34
|
+
const { meta, html, body, restoreScroll = true } = Astro.props;
|
|
27
35
|
---
|
|
28
36
|
|
|
29
37
|
<!doctype html>
|
|
@@ -40,5 +48,6 @@ const { meta, html, body } = Astro.props;
|
|
|
40
48
|
<body {...body}>
|
|
41
49
|
<MetaTags tags={meta.bodyTags} />
|
|
42
50
|
<slot />
|
|
51
|
+
{restoreScroll && <ScrollRestore />}
|
|
43
52
|
</body>
|
|
44
53
|
</html>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Puts a page back where the reader left it, before the first paint.
|
|
4
|
+
*
|
|
5
|
+
* WebKit restores scroll only at `load`, after every image, so a page it
|
|
6
|
+
* reloads on back shows its top and then snaps down — and Firefox on iOS
|
|
7
|
+
* reloads on nearly every back. The position is kept on the history entry, as
|
|
8
|
+
* the browser keeps its own, so a link to the same page still opens at the top.
|
|
9
|
+
*
|
|
10
|
+
* Rendered by `Document` after the page, where the layout already exists.
|
|
11
|
+
* Inline, because a bundled module runs too late: after the first paint.
|
|
12
|
+
*/
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<script is:inline>
|
|
16
|
+
{
|
|
17
|
+
history.scrollRestoration = "manual";
|
|
18
|
+
|
|
19
|
+
const save = () => {
|
|
20
|
+
try {
|
|
21
|
+
history.replaceState({ ...history.state, scrollY }, "");
|
|
22
|
+
} catch {
|
|
23
|
+
// Safari throttles replaceState; the last saved position stands.
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Once scrolling settles, not per frame: Safari caps replaceState calls.
|
|
28
|
+
let settle = 0;
|
|
29
|
+
addEventListener(
|
|
30
|
+
"scroll",
|
|
31
|
+
() => {
|
|
32
|
+
clearTimeout(settle);
|
|
33
|
+
settle = setTimeout(save, 300);
|
|
34
|
+
},
|
|
35
|
+
{ passive: true }
|
|
36
|
+
);
|
|
37
|
+
// No save on `pagehide`. WebKit fires it after moving to the previous entry,
|
|
38
|
+
// and Firefox on iOS restores a page at the top and reloads it at once, so
|
|
39
|
+
// either way the save would wipe the position about to be restored.
|
|
40
|
+
|
|
41
|
+
// `instant`, or a site with `scroll-behavior: smooth` glides down from the
|
|
42
|
+
// top — the jump this exists to remove, in slow motion.
|
|
43
|
+
const restore = (state) => {
|
|
44
|
+
if (typeof state?.scrollY === "number") {
|
|
45
|
+
scrollTo({ top: state.scrollY, behavior: "instant" });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
restore(history.state);
|
|
50
|
+
|
|
51
|
+
// Filters push entries on the same page; `manual` turned off restoring
|
|
52
|
+
// those too. A frame later, so the filtered list has re-rendered first.
|
|
53
|
+
addEventListener("popstate", (event) => {
|
|
54
|
+
requestAnimationFrame(() => restore(event.state));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
</script>
|
package/src/astro/carousel.ts
CHANGED
|
@@ -62,6 +62,17 @@ export interface CarouselOptions {
|
|
|
62
62
|
* renders, and calling would make a project write the first position twice.
|
|
63
63
|
*/
|
|
64
64
|
readonly onChange: (index: number) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Called with `autoplayMs` whenever a countdown to the next automatic
|
|
67
|
+
* advance begins, and with `null` when autoplay stops.
|
|
68
|
+
*
|
|
69
|
+
* For a progress bar. A countdown begins on attach, after every tick and
|
|
70
|
+
* after every manual move, and each one starts from zero — so the bar
|
|
71
|
+
* restarts on every number and stops on `null`, and never has to know why.
|
|
72
|
+
* A hold (hover, focus, a hidden tab, `pause`) and a detach report `null`
|
|
73
|
+
* once, not once per reason.
|
|
74
|
+
*/
|
|
75
|
+
readonly onCountdown?: (ms: number | null) => void;
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
export interface Carousel {
|
|
@@ -107,6 +118,7 @@ export function carousel(options: CarouselOptions): Carousel {
|
|
|
107
118
|
swipeThreshold = 10,
|
|
108
119
|
lockMs = 500,
|
|
109
120
|
onChange,
|
|
121
|
+
onCountdown,
|
|
110
122
|
} = options;
|
|
111
123
|
|
|
112
124
|
/**
|
|
@@ -143,9 +155,19 @@ export function carousel(options: CarouselOptions): Carousel {
|
|
|
143
155
|
*/
|
|
144
156
|
const holds = new Set<string>();
|
|
145
157
|
|
|
158
|
+
/** Whether a countdown is running, so `null` is reported once per stop. */
|
|
159
|
+
let counting = false;
|
|
160
|
+
|
|
161
|
+
const stopAutoplay = (): void => {
|
|
162
|
+
clearInterval(autoplayTimer);
|
|
163
|
+
if (!counting) return;
|
|
164
|
+
counting = false;
|
|
165
|
+
onCountdown?.(null);
|
|
166
|
+
};
|
|
167
|
+
|
|
146
168
|
const hold = (reason: string): void => {
|
|
147
169
|
holds.add(reason);
|
|
148
|
-
|
|
170
|
+
stopAutoplay();
|
|
149
171
|
};
|
|
150
172
|
|
|
151
173
|
const release = (reason: string): void => {
|
|
@@ -171,16 +193,28 @@ export function carousel(options: CarouselOptions): Carousel {
|
|
|
171
193
|
};
|
|
172
194
|
|
|
173
195
|
const restartAutoplay = (): void => {
|
|
174
|
-
clearInterval(autoplayTimer);
|
|
175
196
|
// Nothing to rotate through, nobody watching, or no autoplay asked
|
|
176
197
|
// for — in each case, nothing to schedule.
|
|
177
|
-
if (
|
|
178
|
-
|
|
198
|
+
if (
|
|
199
|
+
autoplayMs === undefined ||
|
|
200
|
+
!canMove ||
|
|
201
|
+
!attached ||
|
|
202
|
+
holds.size > 0
|
|
203
|
+
) {
|
|
204
|
+
stopAutoplay();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
clearInterval(autoplayTimer);
|
|
179
208
|
autoplayTimer = setInterval(() => {
|
|
180
209
|
// No `restartAutoplay` here: the interval already paces itself, and
|
|
181
210
|
// resetting it from inside its own tick would only churn timers.
|
|
182
211
|
if (claim()) move(index + 1);
|
|
212
|
+
// Reported even when the lock refused the move: the next tick is
|
|
213
|
+
// still `autoplayMs` away either way.
|
|
214
|
+
onCountdown?.(autoplayMs);
|
|
183
215
|
}, autoplayMs);
|
|
216
|
+
counting = true;
|
|
217
|
+
onCountdown?.(autoplayMs);
|
|
184
218
|
};
|
|
185
219
|
|
|
186
220
|
/** The only place `index` changes. `at` may be out of range or negative. */
|
|
@@ -326,7 +360,7 @@ export function carousel(options: CarouselOptions): Carousel {
|
|
|
326
360
|
return () => {
|
|
327
361
|
listeners.abort();
|
|
328
362
|
attached = false;
|
|
329
|
-
|
|
363
|
+
stopAutoplay();
|
|
330
364
|
|
|
331
365
|
// The automatic holds belong to this attachment: an element
|
|
332
366
|
// detached while hovered would otherwise keep "hover" forever,
|