@dojo-ng/history 0.1.0 → 0.2.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/README.md +93 -1
- package/dist/handle.d.ts +14 -1
- package/dist/history.d.ts +38 -1
- package/dist/history.js +288 -17
- package/dist/index.d.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,8 @@ nothing else, so Back never adds an entry.
|
|
|
69
69
|
- `canGoBack`, `canGoForward`: properties. The browser does not say whether Back or Forward has
|
|
70
70
|
somewhere to go, so each entry carries a private `{ id, index }` in `history.state`, and the
|
|
71
71
|
highest index is kept in `sessionStorage`. Without `sessionStorage`, `canGoForward` is `false`.
|
|
72
|
-
- `href(path)`: `base` plus `path`, for a link's `href`
|
|
72
|
+
- `href(path)`: `base` plus `path`, for a link's `href` (`#` plus `path` in hash mode)
|
|
73
|
+
- `block(blocker)`: ask before a move; returns a function that removes the blocker. See Guards.
|
|
73
74
|
- `annotate({ state?, trail? })`: replace those fields on the current entry (with `replaceState`)
|
|
74
75
|
and publish the location again. It sets whichever of the two you pass; it does not merge inside `state`.
|
|
75
76
|
- `commit()`: commit the current entry now
|
|
@@ -81,6 +82,9 @@ nothing else, so Back never adds an entry.
|
|
|
81
82
|
|---|---|---|
|
|
82
83
|
| `base` | `""` | A path prefix such as `/acme`. Added to every URL written and stripped from every path read. |
|
|
83
84
|
| `dwellMs` | `3000` | Visible milliseconds before an entry commits. `0` gives plain browser behavior. |
|
|
85
|
+
| `mode` | `"path"` | `"hash"` keeps the route after `#`. See Hash mode. |
|
|
86
|
+
| `scroll` | `false` | `true` or `{ targets?, ready? }`: restore scroll on Back, Forward and reload. See Scroll restoration. |
|
|
87
|
+
| `focus` | `false` | A selector to focus after a `go`. See Focus after a navigation. |
|
|
84
88
|
| `onCommit` | none | Called once for each entry that commits. See Hooks. |
|
|
85
89
|
| `resolve` | none | Rebuilds the state of a landing entry that has none. See Hooks. |
|
|
86
90
|
| `window` | `globalThis.window` | For tests: any object shaped like the parts of `window` this uses. |
|
|
@@ -112,6 +116,94 @@ result becomes the entry's state. If it rejects, the error is logged and the bar
|
|
|
112
116
|
published. A reload in the same tab keeps its state, so `resolve` is not called. If the app
|
|
113
117
|
navigates before `resolve` settles, the late result is dropped.
|
|
114
118
|
|
|
119
|
+
## Hash mode
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
installHistory(bus, { mode: "hash" }); // https://example.com/app.html#/orders/7
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The route lives after the `#`, so a static server with no fallback can serve every deep link: a
|
|
126
|
+
reload on `#/orders/7` just loads `app.html`. Everything else works as in path mode. `go` writes
|
|
127
|
+
`#/orders/7`, `href("/orders/7")` returns `#/orders/7`, and `location.path` is still `/orders/7`.
|
|
128
|
+
|
|
129
|
+
- A user editing the hash in the address bar, or clicking a plain `<a href="#/x">`, is published as
|
|
130
|
+
a `go` and stamped like any other entry. Nothing in your app has to listen for `hashchange`.
|
|
131
|
+
- Hash mode owns the whole hash, so an in-page `#section` link can't also scroll to a section. Use
|
|
132
|
+
`element.scrollIntoView()` for that.
|
|
133
|
+
- `base` is ignored, with one `console.warn`, because the page's own path is fixed.
|
|
134
|
+
- Components build their links with `getHistory().href(path)`, so hash mode needs `installHistory`
|
|
135
|
+
to have run. Without it a component falls back to its plain `href`.
|
|
136
|
+
- Blockers (see Guards) are not asked about an address-bar hash edit: the browser has already
|
|
137
|
+
moved, and it can't be put back safely.
|
|
138
|
+
|
|
139
|
+
## Scroll restoration
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
installHistory(bus, { scroll: true });
|
|
143
|
+
installHistory(bus, {
|
|
144
|
+
scroll: {
|
|
145
|
+
targets: () => [document.getElementById("list")], // scrolling elements; each needs an id
|
|
146
|
+
ready: (location) => view.updateComplete, // resolve when the view has rendered
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Off by default, because turning it on sets `history.scrollRestoration` to `"manual"` (put back by
|
|
152
|
+
`destroy()`), which an app should choose.
|
|
153
|
+
|
|
154
|
+
- Back, Forward and a same-tab reload return the window, and each `targets` element, to where the
|
|
155
|
+
user left that entry. A `go` scrolls to the top, or to the element named by a `#fragment` in the
|
|
156
|
+
path (path mode only).
|
|
157
|
+
- Positions are saved when leaving an entry and on `pagehide`. They are kept in `sessionStorage`
|
|
158
|
+
by entry id, newest 50, never in `history.state`. Without `sessionStorage`, nothing is restored.
|
|
159
|
+
- A skimmed entry that gets replaced (see Dwell) loses its saved position.
|
|
160
|
+
- **`ready` is the contract.** History publishes the location and does not render, so it can't know
|
|
161
|
+
when the view is there. Without `ready`, it waits two animation frames, which is right for a view
|
|
162
|
+
that renders at once and not enough for data that loads later. With a Lit view, `ready` can
|
|
163
|
+
return `view.updateComplete`; with fetched data, a promise for the fetch. A `ready` that rejects
|
|
164
|
+
is logged and the scroll still happens.
|
|
165
|
+
|
|
166
|
+
## Focus after a navigation
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
installHistory(bus, { focus: "h1, [role=main], main" });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
After a `go`, once the view has had its frames (or `scroll.ready`), the first element matching the
|
|
173
|
+
selector gets focus, so keyboard and screen reader users learn the page changed. Without this,
|
|
174
|
+
focus stays on the link that was clicked, which may no longer exist. Most apps will want it on.
|
|
175
|
+
|
|
176
|
+
- An element that can't take focus, such as a heading, gets `tabindex="-1"` until it blurs.
|
|
177
|
+
- Focus does not move on Back, Forward or a reload. The browser's own behavior is right there.
|
|
178
|
+
- Focus already inside the matched element is left alone, so choosing a tree node keeps focus on
|
|
179
|
+
the tree.
|
|
180
|
+
|
|
181
|
+
## Guards
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const unblock = history.block((to, from) => !formIsDirty() || confirm("Leave without saving?"));
|
|
185
|
+
// later, after saving:
|
|
186
|
+
unblock();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`block` runs the blocker before every `go` and every Back or Forward. Return `true` to allow the
|
|
190
|
+
move; the function may be async. With several blockers, all must allow. Anything other than `true`
|
|
191
|
+
(or a throw) refuses.
|
|
192
|
+
|
|
193
|
+
- A refused `go` writes nothing, publishes nothing and leaves the dwell state alone. If a second
|
|
194
|
+
`go` arrives while a check is pending, only the latest is checked.
|
|
195
|
+
- A browser can't cancel a Back, only undo it. So a guarded Back is traversed straight back with
|
|
196
|
+
`history.go`, the blocker is asked, and if it allows the move the traversal is replayed and
|
|
197
|
+
published as usual. No `pushState` or `replaceState` is involved. Entries this package did not
|
|
198
|
+
create are not guarded.
|
|
199
|
+
- While any blocker is active, closing or reloading the tab shows the browser's own prompt. Browsers
|
|
200
|
+
ignore custom text there.
|
|
201
|
+
- `to` is the `nav:go` payload, or `{ path, source: "pop" }` for Back and Forward. `from` is the
|
|
202
|
+
current location.
|
|
203
|
+
- `dj-back-button`'s cancelable `dj-back` event runs first; the two compose.
|
|
204
|
+
- The Navigation API could later give a true cancel in place of the undo and replay, where it is
|
|
205
|
+
available. It isn't used here, so older Safari works.
|
|
206
|
+
|
|
115
207
|
## match
|
|
116
208
|
|
|
117
209
|
```js
|
package/dist/handle.d.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface CommitEntry {
|
|
|
45
45
|
state: unknown;
|
|
46
46
|
trail?: TrailEntry[];
|
|
47
47
|
}
|
|
48
|
+
/** What a blocker is asked about: a `nav:go`, or a Back/Forward (`source: "pop"`) to `path`. */
|
|
49
|
+
export type BlockTarget = NavGo | {
|
|
50
|
+
path: string;
|
|
51
|
+
source: "pop";
|
|
52
|
+
};
|
|
53
|
+
/** Return true to allow the move. May be async. See `HistoryInstance.block`. */
|
|
54
|
+
export type Blocker = (to: BlockTarget, from: NavLocation) => boolean | Promise<boolean>;
|
|
48
55
|
export interface HistoryInstance {
|
|
49
56
|
/** Publish `nav:go`. The only way anything here writes to the browser's history. */
|
|
50
57
|
go(path: string, state?: unknown, options?: GoOptions): void;
|
|
@@ -58,7 +65,13 @@ export interface HistoryInstance {
|
|
|
58
65
|
readonly current: NavLocation | undefined;
|
|
59
66
|
readonly canGoBack: boolean;
|
|
60
67
|
readonly canGoForward: boolean;
|
|
61
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Ask before a move. `blocker` runs for every `nav:go` and every Back/Forward; all active blockers
|
|
70
|
+
* must return true. A refused move writes and publishes nothing. Returns a function that removes the
|
|
71
|
+
* blocker. While any blocker is active, closing the tab shows the browser's own prompt.
|
|
72
|
+
*/
|
|
73
|
+
block(blocker: Blocker): () => void;
|
|
74
|
+
/** The URL for an app path: `base` plus `path`. In `mode: "hash"`, `#` plus `path`. */
|
|
62
75
|
href(path: string): string;
|
|
63
76
|
/** Merge `state` and/or `trail` into the current entry, then publish the location again. */
|
|
64
77
|
annotate(partial: NavAnnotation): void;
|
package/dist/history.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PubSub } from "@dojo-ng/pubsub";
|
|
2
|
-
import { type CommitEntry, type HistoryInstance } from "./handle.js";
|
|
2
|
+
import { type CommitEntry, type HistoryInstance, type NavLocation } from "./handle.js";
|
|
3
3
|
/** The parts of `window` that `installHistory` uses. Tests pass a fake with this shape. */
|
|
4
4
|
export interface HistoryWindow {
|
|
5
5
|
history: {
|
|
@@ -7,6 +7,8 @@ export interface HistoryWindow {
|
|
|
7
7
|
replaceState(state: unknown, title: string, url?: string | null): void;
|
|
8
8
|
back(): void;
|
|
9
9
|
forward(): void;
|
|
10
|
+
go(delta: number): void;
|
|
11
|
+
scrollRestoration?: "auto" | "manual";
|
|
10
12
|
readonly state: unknown;
|
|
11
13
|
};
|
|
12
14
|
location: {
|
|
@@ -15,10 +17,17 @@ export interface HistoryWindow {
|
|
|
15
17
|
readonly hash: string;
|
|
16
18
|
readonly href: string;
|
|
17
19
|
};
|
|
20
|
+
readonly scrollX?: number;
|
|
21
|
+
readonly scrollY?: number;
|
|
22
|
+
scrollTo?(x: number, y: number): void;
|
|
23
|
+
requestAnimationFrame?(callback: () => void): unknown;
|
|
18
24
|
addEventListener(type: string, listener: (event: never) => void): void;
|
|
19
25
|
removeEventListener(type: string, listener: (event: never) => void): void;
|
|
20
26
|
document: {
|
|
21
27
|
readonly visibilityState?: string;
|
|
28
|
+
readonly activeElement?: Element | null;
|
|
29
|
+
getElementById?(id: string): Element | null;
|
|
30
|
+
querySelector?(selector: string): Element | null;
|
|
22
31
|
addEventListener(type: string, listener: (event: never) => void): void;
|
|
23
32
|
removeEventListener(type: string, listener: (event: never) => void): void;
|
|
24
33
|
};
|
|
@@ -27,6 +36,16 @@ export interface HistoryWindow {
|
|
|
27
36
|
setItem(key: string, value: string): void;
|
|
28
37
|
};
|
|
29
38
|
}
|
|
39
|
+
/** What `scroll` takes beyond `true`. */
|
|
40
|
+
export interface ScrollOptions {
|
|
41
|
+
/** Scrollable elements besides the window. Each needs an `id`; one without is skipped, with a warning. */
|
|
42
|
+
targets?: () => Iterable<Element>;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve when the view for `location` has rendered. Without it, restoring waits two animation
|
|
45
|
+
* frames, which is right for a synchronous render and not enough for data that loads later.
|
|
46
|
+
*/
|
|
47
|
+
ready?: (location: NavLocation) => Promise<void> | void;
|
|
48
|
+
}
|
|
30
49
|
export interface InstallOptions {
|
|
31
50
|
/** A path prefix such as `/acme`. Added to every URL written, stripped from every path read. */
|
|
32
51
|
base?: string;
|
|
@@ -41,5 +60,23 @@ export interface InstallOptions {
|
|
|
41
60
|
* bookmark). Its result becomes the entry's state before `nav:location` is published as `load`.
|
|
42
61
|
*/
|
|
43
62
|
resolve?: (path: string) => Promise<unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* `"hash"` keeps the route after `#` (`#/orders/7`), so a server with no SPA fallback can serve any
|
|
65
|
+
* deep link. Default `"path"`. Hash mode owns the whole hash, so in-page `#anchor` links don't work
|
|
66
|
+
* there, and `base` is ignored.
|
|
67
|
+
*/
|
|
68
|
+
mode?: "path" | "hash";
|
|
69
|
+
/**
|
|
70
|
+
* Restore the scroll position on Back, Forward and reload, and scroll to the top (or to a
|
|
71
|
+
* `#fragment`) after a `go`. Off by default. Turning it on sets `history.scrollRestoration` to
|
|
72
|
+
* `"manual"` until `destroy()`.
|
|
73
|
+
*/
|
|
74
|
+
scroll?: boolean | ScrollOptions;
|
|
75
|
+
/**
|
|
76
|
+
* A selector. After a `go` (not a Back or a reload), focus the first match so keyboard and screen
|
|
77
|
+
* reader users learn the view changed. An element that can't take focus gets `tabindex="-1"` until
|
|
78
|
+
* it blurs. Focus already inside the match is left alone. Default off.
|
|
79
|
+
*/
|
|
80
|
+
focus?: string | false;
|
|
44
81
|
}
|
|
45
82
|
export declare function installHistory(bus: PubSub, options?: InstallOptions): HistoryInstance;
|
package/dist/history.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getHistory, registerHistory, unregisterHistory, } from "./handle.js";
|
|
2
2
|
const DEFAULT_DWELL_MS = 3000;
|
|
3
3
|
const MAX_INDEX_KEY = "dojo-ng.history.max";
|
|
4
|
+
const SCROLL_KEY = "dojo-ng.history.scroll";
|
|
5
|
+
const SCROLL_CAP = 50;
|
|
6
|
+
const FOCUSABLE = "a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]";
|
|
4
7
|
const DUPLICATE_INSTALL_WARNED = new WeakSet();
|
|
5
8
|
let idCounter = 0;
|
|
6
9
|
function newId() {
|
|
@@ -43,9 +46,20 @@ export function installHistory(bus, options = {}) {
|
|
|
43
46
|
const win = options.window ?? globalThis.window;
|
|
44
47
|
if (!win)
|
|
45
48
|
throw new Error("[@dojo-ng/history] installHistory needs a window. Pass options.window outside a browser.");
|
|
46
|
-
const
|
|
49
|
+
const hashMode = options.mode === "hash";
|
|
50
|
+
const base = hashMode ? "" : normalizeBase(options.base);
|
|
47
51
|
const dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
|
|
52
|
+
const scrollOn = Boolean(options.scroll);
|
|
53
|
+
const scrollOpts = typeof options.scroll === "object" ? options.scroll : {};
|
|
54
|
+
const focusSelector = options.focus || undefined;
|
|
55
|
+
const blockers = new Set();
|
|
48
56
|
const warned = new Set();
|
|
57
|
+
let guardToken = 0;
|
|
58
|
+
let undoing;
|
|
59
|
+
let redoing = false;
|
|
60
|
+
let pendingAdopt = false;
|
|
61
|
+
let settleToken = 0;
|
|
62
|
+
let savedRestoration;
|
|
49
63
|
const provisionalIds = new Set();
|
|
50
64
|
let cur;
|
|
51
65
|
let last;
|
|
@@ -62,6 +76,10 @@ export function installHistory(bus, options = {}) {
|
|
|
62
76
|
console.warn(`[@dojo-ng/history] ${message} (${signal}). Dojo NG routing stays in charge; this is the only warning of this kind.`);
|
|
63
77
|
}
|
|
64
78
|
function currentPath() {
|
|
79
|
+
if (hashMode) {
|
|
80
|
+
const route = win.location.hash.replace(/^#/, "");
|
|
81
|
+
return route === "" ? "/" : route.startsWith("/") ? route : `/${route}`;
|
|
82
|
+
}
|
|
65
83
|
const full = win.location.pathname + win.location.search + win.location.hash;
|
|
66
84
|
if (base && (full === base || full.startsWith(`${base}/`) || full.startsWith(`${base}?`) || full.startsWith(`${base}#`))) {
|
|
67
85
|
const rest = full.slice(base.length);
|
|
@@ -70,7 +88,10 @@ export function installHistory(bus, options = {}) {
|
|
|
70
88
|
return full;
|
|
71
89
|
}
|
|
72
90
|
function place() {
|
|
73
|
-
return win.location.pathname + win.location.search;
|
|
91
|
+
return win.location.pathname + win.location.search + (hashMode ? win.location.hash : "");
|
|
92
|
+
}
|
|
93
|
+
function hrefOf(path) {
|
|
94
|
+
return hashMode ? `#${path}` : base + path;
|
|
74
95
|
}
|
|
75
96
|
function stateFor(entry) {
|
|
76
97
|
return { __djnav: { id: entry.id, index: entry.index }, state: entry.state, trail: entry.trail };
|
|
@@ -114,15 +135,18 @@ export function installHistory(bus, options = {}) {
|
|
|
114
135
|
// Storage can be blocked. canGoForward is then false, which is harmless.
|
|
115
136
|
}
|
|
116
137
|
}
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
path:
|
|
120
|
-
state:
|
|
121
|
-
index:
|
|
122
|
-
committed:
|
|
123
|
-
source:
|
|
124
|
-
...(
|
|
138
|
+
function toLocation(entry) {
|
|
139
|
+
return {
|
|
140
|
+
path: entry.path,
|
|
141
|
+
state: entry.state,
|
|
142
|
+
index: entry.index,
|
|
143
|
+
committed: entry.committed,
|
|
144
|
+
source: entry.source,
|
|
145
|
+
...(entry.trail ? { trail: entry.trail } : {}),
|
|
125
146
|
};
|
|
147
|
+
}
|
|
148
|
+
function publish() {
|
|
149
|
+
const location = toLocation(cur);
|
|
126
150
|
last = location;
|
|
127
151
|
bus.publish("nav:location", location);
|
|
128
152
|
}
|
|
@@ -181,6 +205,162 @@ export function installHistory(bus, options = {}) {
|
|
|
181
205
|
publish();
|
|
182
206
|
fireCommit(cur);
|
|
183
207
|
}
|
|
208
|
+
// --- scroll and focus ----------------------------------------------------------------------
|
|
209
|
+
// Positions live in sessionStorage by entry id, never in history state: writing them into state
|
|
210
|
+
// would need a replaceState from a scroll or pop handler, and only nav:go may write.
|
|
211
|
+
function readScrollStore() {
|
|
212
|
+
try {
|
|
213
|
+
const raw = win.sessionStorage?.getItem(SCROLL_KEY);
|
|
214
|
+
const parsed = raw ? JSON.parse(raw) : undefined;
|
|
215
|
+
if (parsed && Array.isArray(parsed.order) && isRecord(parsed.pos))
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Blocked storage or a damaged value: start empty.
|
|
220
|
+
}
|
|
221
|
+
return { order: [], pos: {} };
|
|
222
|
+
}
|
|
223
|
+
function writeScrollStore(store) {
|
|
224
|
+
try {
|
|
225
|
+
win.sessionStorage?.setItem(SCROLL_KEY, JSON.stringify(store));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Storage can be blocked. Restoring is then a no-op.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function saveScroll(id) {
|
|
232
|
+
if (!scrollOn)
|
|
233
|
+
return;
|
|
234
|
+
const record = { x: win.scrollX ?? 0, y: win.scrollY ?? 0 };
|
|
235
|
+
if (scrollOpts.targets) {
|
|
236
|
+
for (const el of scrollOpts.targets()) {
|
|
237
|
+
if (!el.id) {
|
|
238
|
+
warnOnce("scroll-target-id", "A scroll target has no id, so its position can't be saved");
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
(record.t ?? (record.t = {}))[el.id] = [el.scrollLeft, el.scrollTop];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const store = readScrollStore();
|
|
245
|
+
if (!(id in store.pos))
|
|
246
|
+
store.order.push(id);
|
|
247
|
+
store.pos[id] = record;
|
|
248
|
+
while (store.order.length > SCROLL_CAP)
|
|
249
|
+
delete store.pos[store.order.shift()];
|
|
250
|
+
writeScrollStore(store);
|
|
251
|
+
}
|
|
252
|
+
function dropScroll(id) {
|
|
253
|
+
if (!scrollOn)
|
|
254
|
+
return;
|
|
255
|
+
const store = readScrollStore();
|
|
256
|
+
if (!(id in store.pos))
|
|
257
|
+
return;
|
|
258
|
+
delete store.pos[id];
|
|
259
|
+
store.order = store.order.filter((other) => other !== id);
|
|
260
|
+
writeScrollStore(store);
|
|
261
|
+
}
|
|
262
|
+
function restoreScroll(id) {
|
|
263
|
+
const record = readScrollStore().pos[id];
|
|
264
|
+
if (!record)
|
|
265
|
+
return;
|
|
266
|
+
win.scrollTo?.(record.x, record.y);
|
|
267
|
+
if (!scrollOpts.targets || !record.t)
|
|
268
|
+
return;
|
|
269
|
+
for (const el of scrollOpts.targets()) {
|
|
270
|
+
const at = el.id ? record.t[el.id] : undefined;
|
|
271
|
+
if (at) {
|
|
272
|
+
el.scrollLeft = at[0];
|
|
273
|
+
el.scrollTop = at[1];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function scrollForGo(path) {
|
|
278
|
+
const hash = path.indexOf("#");
|
|
279
|
+
let target;
|
|
280
|
+
if (!hashMode && hash >= 0) {
|
|
281
|
+
try {
|
|
282
|
+
target = win.document.getElementById?.(decodeURIComponent(path.slice(hash + 1)));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
target = undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (target)
|
|
289
|
+
target.scrollIntoView();
|
|
290
|
+
else
|
|
291
|
+
win.scrollTo?.(0, 0);
|
|
292
|
+
}
|
|
293
|
+
function moveFocus() {
|
|
294
|
+
const target = win.document.querySelector?.(focusSelector);
|
|
295
|
+
if (!target)
|
|
296
|
+
return;
|
|
297
|
+
const active = win.document.activeElement;
|
|
298
|
+
if (active && target.contains(active))
|
|
299
|
+
return;
|
|
300
|
+
if (!target.matches(FOCUSABLE)) {
|
|
301
|
+
target.setAttribute("tabindex", "-1");
|
|
302
|
+
target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
|
|
303
|
+
}
|
|
304
|
+
target.focus({ preventScroll: scrollOn });
|
|
305
|
+
}
|
|
306
|
+
/** The view renders after the publish, and history can't see it (D4), so wait for the app's word or two frames. */
|
|
307
|
+
function whenRendered(location) {
|
|
308
|
+
if (scrollOpts.ready) {
|
|
309
|
+
return new Promise((resolve) => resolve(scrollOpts.ready(location))).catch((err) => {
|
|
310
|
+
console.error("[@dojo-ng/history] scroll.ready rejected; restoring anyway:", err);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const frame = win.requestAnimationFrame?.bind(win);
|
|
314
|
+
if (!frame)
|
|
315
|
+
return Promise.resolve();
|
|
316
|
+
return new Promise((resolve) => frame(() => frame(() => resolve())));
|
|
317
|
+
}
|
|
318
|
+
/** Called after a published location. A newer navigation cancels the wait of an older one. */
|
|
319
|
+
function afterNavigation(entry) {
|
|
320
|
+
if (!scrollOn && !focusSelector)
|
|
321
|
+
return;
|
|
322
|
+
const token = ++settleToken;
|
|
323
|
+
const isGo = entry.source === "go";
|
|
324
|
+
const path = entry.path;
|
|
325
|
+
void whenRendered(toLocation(entry)).then(() => {
|
|
326
|
+
if (destroyed || token !== settleToken)
|
|
327
|
+
return;
|
|
328
|
+
if (scrollOn) {
|
|
329
|
+
if (isGo)
|
|
330
|
+
scrollForGo(path);
|
|
331
|
+
else
|
|
332
|
+
restoreScroll(entry.id);
|
|
333
|
+
}
|
|
334
|
+
if (focusSelector && isGo)
|
|
335
|
+
moveFocus();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function onPageHide() {
|
|
339
|
+
if (!destroyed)
|
|
340
|
+
saveScroll(cur.id);
|
|
341
|
+
}
|
|
342
|
+
// --- guards --------------------------------------------------------------------------------
|
|
343
|
+
async function allows(to) {
|
|
344
|
+
const from = last ?? toLocation(cur);
|
|
345
|
+
try {
|
|
346
|
+
const answers = await Promise.all([...blockers].map(({ fn }) => fn(to, from)));
|
|
347
|
+
return answers.every((answer) => answer === true);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
console.error("[@dojo-ng/history] a blocker threw; the move is refused:", err);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function onBeforeUnload(event) {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
}
|
|
357
|
+
async function runUndone(pending) {
|
|
358
|
+
const ok = await allows(pending.to);
|
|
359
|
+
if (!ok || destroyed || pending.token !== guardToken)
|
|
360
|
+
return;
|
|
361
|
+
redoing = true;
|
|
362
|
+
win.history.go(pending.delta);
|
|
363
|
+
}
|
|
184
364
|
// --- the coexistence checks (D6) -----------------------------------------------------------
|
|
185
365
|
function checkForeignLocation() {
|
|
186
366
|
if (place() !== expectedPlace) {
|
|
@@ -191,9 +371,34 @@ export function installHistory(bus, options = {}) {
|
|
|
191
371
|
function onGo(request) {
|
|
192
372
|
if (destroyed || !request || typeof request.path !== "string")
|
|
193
373
|
return;
|
|
194
|
-
|
|
374
|
+
if (pendingAdopt) {
|
|
375
|
+
// An address-bar hash edit the browser already made. A blocker can't undo it safely, so it isn't asked.
|
|
376
|
+
pendingAdopt = false;
|
|
377
|
+
performGo(request, true);
|
|
378
|
+
}
|
|
379
|
+
else if (blockers.size === 0) {
|
|
380
|
+
performGo(request, false);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Last one wins: a newer request, or any pop, makes this answer stale.
|
|
384
|
+
const token = ++guardToken;
|
|
385
|
+
void allows(request).then((ok) => {
|
|
386
|
+
if (ok && !destroyed && token === guardToken)
|
|
387
|
+
performGo(request, false);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** `adopt`: the browser has already pushed this entry (unstamped), so stamp it in place as the next index. */
|
|
392
|
+
function performGo(request, adopt) {
|
|
393
|
+
guardToken++;
|
|
394
|
+
if (!adopt)
|
|
395
|
+
checkForeignLocation();
|
|
195
396
|
const previous = cur;
|
|
196
|
-
const replace = request.replace === true || !previous.committed;
|
|
397
|
+
const replace = !adopt && (request.replace === true || !previous.committed);
|
|
398
|
+
if (replace)
|
|
399
|
+
dropScroll(previous.id);
|
|
400
|
+
else
|
|
401
|
+
saveScroll(previous.id);
|
|
197
402
|
const entry = {
|
|
198
403
|
id: newId(),
|
|
199
404
|
index: replace ? previous.index : previous.index + 1,
|
|
@@ -205,14 +410,14 @@ export function installHistory(bus, options = {}) {
|
|
|
205
410
|
};
|
|
206
411
|
provisionalIds.delete(previous.id);
|
|
207
412
|
stopTimer();
|
|
208
|
-
writeEntry(replace ? "replace" : "push", entry,
|
|
413
|
+
writeEntry(replace || adopt ? "replace" : "push", entry, hrefOf(request.path));
|
|
209
414
|
if (!replace)
|
|
210
415
|
writeMax(entry.index);
|
|
211
416
|
expectedPlace = place();
|
|
212
417
|
cur = entry;
|
|
213
418
|
// A replace of an entry that had already committed keeps its place on the stack, so the
|
|
214
419
|
// replacement commits too. Otherwise the entry is provisional until it has been read.
|
|
215
|
-
const commitNow = dwellMs <= 0 || (replace && previous.committed);
|
|
420
|
+
const commitNow = dwellMs <= 0 || adopt || (replace && previous.committed);
|
|
216
421
|
if (commitNow) {
|
|
217
422
|
entry.committed = true;
|
|
218
423
|
publish();
|
|
@@ -223,13 +428,43 @@ export function installHistory(bus, options = {}) {
|
|
|
223
428
|
publish();
|
|
224
429
|
startDwell();
|
|
225
430
|
}
|
|
431
|
+
afterNavigation(entry);
|
|
226
432
|
}
|
|
227
433
|
function onPop() {
|
|
228
434
|
if (destroyed)
|
|
229
435
|
return;
|
|
230
|
-
|
|
436
|
+
if (undoing) {
|
|
437
|
+
// The traversal we made to put a guarded Back back. Nothing changed; ask the blockers.
|
|
438
|
+
const pending = undoing;
|
|
439
|
+
undoing = undefined;
|
|
440
|
+
void runUndone(pending);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (redoing) {
|
|
444
|
+
redoing = false;
|
|
445
|
+
settlePop();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (blockers.size > 0) {
|
|
449
|
+
const target = readStamp(win.history.state);
|
|
450
|
+
if (target && target.id !== cur.id && target.index !== cur.index) {
|
|
451
|
+
const delta = target.index - cur.index;
|
|
452
|
+
undoing = { delta, token: ++guardToken, to: { path: currentPath(), source: "pop" } };
|
|
453
|
+
win.history.go(-delta);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
settlePop();
|
|
458
|
+
}
|
|
459
|
+
function settlePop() {
|
|
460
|
+
guardToken++;
|
|
231
461
|
const raw = win.history.state;
|
|
232
462
|
const stamp = readStamp(raw);
|
|
463
|
+
// Hash mode: an unstamped entry here is an address-bar edit. The hashchange that follows adopts it.
|
|
464
|
+
if (hashMode && !stamp && place() !== expectedPlace)
|
|
465
|
+
return;
|
|
466
|
+
stopTimer();
|
|
467
|
+
saveScroll(cur.id);
|
|
233
468
|
let entry;
|
|
234
469
|
let newlyCommitted = false;
|
|
235
470
|
if (!stamp) {
|
|
@@ -262,6 +497,14 @@ export function installHistory(bus, options = {}) {
|
|
|
262
497
|
publish();
|
|
263
498
|
if (newlyCommitted)
|
|
264
499
|
fireCommit(entry);
|
|
500
|
+
afterNavigation(entry);
|
|
501
|
+
}
|
|
502
|
+
function onHashChange() {
|
|
503
|
+
if (destroyed || readStamp(win.history.state))
|
|
504
|
+
return;
|
|
505
|
+
pendingAdopt = true;
|
|
506
|
+
bus.publish("nav:go", { path: currentPath(), replace: true });
|
|
507
|
+
pendingAdopt = false;
|
|
265
508
|
}
|
|
266
509
|
function onNavigate(event) {
|
|
267
510
|
const detail = event.detail;
|
|
@@ -299,9 +542,18 @@ export function installHistory(bus, options = {}) {
|
|
|
299
542
|
const max = readMax();
|
|
300
543
|
return max !== undefined && cur.index < max;
|
|
301
544
|
},
|
|
302
|
-
|
|
303
|
-
|
|
545
|
+
block(blocker) {
|
|
546
|
+
const entry = { fn: blocker };
|
|
547
|
+
blockers.add(entry);
|
|
548
|
+
if (blockers.size === 1)
|
|
549
|
+
win.addEventListener("beforeunload", onBeforeUnload);
|
|
550
|
+
return () => {
|
|
551
|
+
if (!blockers.delete(entry) || blockers.size > 0)
|
|
552
|
+
return;
|
|
553
|
+
win.removeEventListener("beforeunload", onBeforeUnload);
|
|
554
|
+
};
|
|
304
555
|
},
|
|
556
|
+
href: hrefOf,
|
|
305
557
|
annotate(partial) {
|
|
306
558
|
if (destroyed)
|
|
307
559
|
return;
|
|
@@ -319,6 +571,13 @@ export function installHistory(bus, options = {}) {
|
|
|
319
571
|
stopTimer();
|
|
320
572
|
unsubscribeGo();
|
|
321
573
|
win.removeEventListener("popstate", onPop);
|
|
574
|
+
win.removeEventListener("hashchange", onHashChange);
|
|
575
|
+
win.removeEventListener("pagehide", onPageHide);
|
|
576
|
+
if (blockers.size > 0)
|
|
577
|
+
win.removeEventListener("beforeunload", onBeforeUnload);
|
|
578
|
+
blockers.clear();
|
|
579
|
+
if (savedRestoration !== undefined)
|
|
580
|
+
win.history.scrollRestoration = savedRestoration;
|
|
322
581
|
win.document.removeEventListener("visibilitychange", onVisibility);
|
|
323
582
|
win.document.removeEventListener("dj-navigate", onNavigate);
|
|
324
583
|
unregisterHistory(instance);
|
|
@@ -328,6 +587,12 @@ export function installHistory(bus, options = {}) {
|
|
|
328
587
|
if (!isNative(win.history.pushState) || !isNative(win.history.replaceState)) {
|
|
329
588
|
warnOnce("patched-pushstate", "history.pushState or replaceState is not the browser's own, so another router may be patching them");
|
|
330
589
|
}
|
|
590
|
+
if (hashMode && options.base)
|
|
591
|
+
warnOnce("hash-base", "`base` is ignored in hash mode, because the route lives after the #");
|
|
592
|
+
if (scrollOn && win.history.scrollRestoration !== undefined) {
|
|
593
|
+
savedRestoration = win.history.scrollRestoration;
|
|
594
|
+
win.history.scrollRestoration = "manual";
|
|
595
|
+
}
|
|
331
596
|
const raw = win.history.state;
|
|
332
597
|
const stamp = readStamp(raw);
|
|
333
598
|
let fresh = false;
|
|
@@ -344,6 +609,10 @@ export function installHistory(bus, options = {}) {
|
|
|
344
609
|
expectedPlace = place();
|
|
345
610
|
const unsubscribeGo = bus.subscribe("nav:go", onGo, { replay: false });
|
|
346
611
|
win.addEventListener("popstate", onPop);
|
|
612
|
+
if (hashMode)
|
|
613
|
+
win.addEventListener("hashchange", onHashChange);
|
|
614
|
+
if (scrollOn)
|
|
615
|
+
win.addEventListener("pagehide", onPageHide);
|
|
347
616
|
win.document.addEventListener("visibilitychange", onVisibility);
|
|
348
617
|
win.document.addEventListener("dj-navigate", onNavigate);
|
|
349
618
|
registerHistory(instance);
|
|
@@ -366,6 +635,8 @@ export function installHistory(bus, options = {}) {
|
|
|
366
635
|
}
|
|
367
636
|
else {
|
|
368
637
|
publish();
|
|
638
|
+
if (!fresh)
|
|
639
|
+
afterNavigation(cur);
|
|
369
640
|
}
|
|
370
641
|
return instance;
|
|
371
642
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { installHistory } from "./history.js";
|
|
2
|
-
export type { InstallOptions, HistoryWindow } from "./history.js";
|
|
2
|
+
export type { InstallOptions, HistoryWindow, ScrollOptions } from "./history.js";
|
|
3
3
|
export { match } from "./match.js";
|
|
4
4
|
export { getHistory, whenHistory } from "./handle.js";
|
|
5
|
-
export type { CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
|
|
5
|
+
export type { Blocker, BlockTarget, CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
|
package/package.json
CHANGED