aberdeen 0.2.4 → 0.4.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 +47 -44
- package/dist/aberdeen.d.ts +297 -269
- package/dist/aberdeen.js +708 -452
- package/dist/aberdeen.js.map +1 -1
- package/dist/prediction.js +1 -1
- package/dist/prediction.js.map +1 -1
- package/dist/route.d.ts +18 -4
- package/dist/route.js +110 -57
- package/dist/route.js.map +1 -1
- package/dist/transitions.d.ts +2 -2
- package/dist/transitions.js +40 -33
- package/dist/transitions.js.map +1 -1
- package/dist-min/aberdeen.d.ts +297 -269
- package/dist-min/aberdeen.js +1 -1
- package/dist-min/aberdeen.js.map +1 -1
- package/dist-min/prediction.js +1 -1
- package/dist-min/prediction.js.map +1 -1
- package/dist-min/route.d.ts +18 -4
- package/dist-min/route.js +1 -1
- package/dist-min/route.js.map +1 -1
- package/dist-min/transitions.d.ts +2 -2
- package/dist-min/transitions.js +1 -1
- package/dist-min/transitions.js.map +1 -1
- package/package.json +1 -1
- package/src/aberdeen.ts +724 -568
- package/src/prediction.ts +1 -1
- package/src/route.ts +116 -53
- package/src/transitions.ts +38 -42
package/src/prediction.ts
CHANGED
package/src/route.ts
CHANGED
|
@@ -1,48 +1,76 @@
|
|
|
1
|
-
import {Store, observe, immediateObserve,
|
|
1
|
+
import {Store, observe, immediateObserve, runQueue, getParentElement, clean} from './aberdeen.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A `Store` object that holds the following keys:
|
|
5
5
|
* - `path`: The current path of the URL split into components. For instance `/` or `/users/123/feed`. Updates will be reflected in the URL and will *push* a new entry to the browser history.
|
|
6
6
|
* - `p`: Array containing the path segments. For instance `[]` or `['users', 123, 'feed']`. Updates will be reflected in the URL and will *push* a new entry to the browser history. Also, the values of `p` and `path` will be synced.
|
|
7
|
-
* - `search`: An observable object containing search parameters (a split up query string). For instance `{order: "date", title: "something"}` or just `{}`.
|
|
7
|
+
* - `search`: An observable object containing search parameters (a split up query string). For instance `{order: "date", title: "something"}` or just `{}`. By default, updates will be reflected in the URL, replacing the current history state.
|
|
8
8
|
* - `hash`: The document hash part of the URL. For instance `"#section-title"`. It can also be an empty string. Updates will be reflected in the URL, modifying the current history state.
|
|
9
|
-
* - `
|
|
9
|
+
* - `id`: A part of the browser history *state* that is considered part of the page *identify*, meaning changes will (by default) cause a history push, and when going *back*, it must match.
|
|
10
|
+
* - `aux`: The auxiliary part of the browser history *state*, not considered part of the page *identity*. Changes will be reflected in the browser history using a replace.
|
|
11
|
+
* - `depth`: The navigation depth of the current session. Starts at 1. Writing to this property has no effect.
|
|
12
|
+
* - `nav`: The navigation action that got us to this page. Writing to this property has no effect.
|
|
13
|
+
* - `"load"`: An initial page load.
|
|
14
|
+
* - `"back"` or `"forward"`: When we navigated backwards or forwards in the stack.
|
|
15
|
+
* - `"push"`: When we added a new page on top of the stack.
|
|
10
16
|
*
|
|
11
17
|
* The following key may also be written to `route` but will be immediately and silently removed:
|
|
12
18
|
* - `mode`: As described above, this library takes a best guess about whether pushing an item to the browser history makes sense or not. When `mode` is...
|
|
13
19
|
* - `"push"`: Force creation of a new browser history entry.
|
|
14
20
|
* - `"replace"`: Update the current history entry, even when updates to other keys would normally cause a *push*.
|
|
15
|
-
* - `"back"`: Unwind the history (like repeatedly pressing the *back* button) until we find a page that matches the given `path
|
|
21
|
+
* - `"back"`: Unwind the history (like repeatedly pressing the *back* button) until we find a page that matches the given `path` and `id`, and then *replace* that state by the full given state.
|
|
16
22
|
*/
|
|
17
|
-
export const route = new Store()
|
|
18
23
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
let stack: Array<{url: string, state: object}> = []
|
|
24
|
+
// Idea: split `state` into `pstate` (primary) and `astate` (auxilary). The former is used to determine if new pages should be pushed and if, when moving back, a page matches.
|
|
25
|
+
// Moving back is done by just doing history.back() while depth > 1, and repeating in handleLocationUpdate until matching page is found (or overriding base page).
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
const initialHistoryLength = history.length;
|
|
27
|
+
export const route = new Store()
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
let stateRoute = {
|
|
30
|
+
nonce: -1,
|
|
31
|
+
depth: 0,
|
|
32
|
+
}
|
|
29
33
|
|
|
30
34
|
// Reflect changes to the browser URL (back/forward navigation) in the `route` and `stack`.
|
|
31
35
|
function handleLocationUpdate(event?: PopStateEvent) {
|
|
36
|
+
let state = event?.state || {}
|
|
37
|
+
let nav = 'load'
|
|
38
|
+
if (state.route?.nonce == null) {
|
|
39
|
+
state.route = {
|
|
40
|
+
nonce: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
|
|
41
|
+
depth: 1,
|
|
42
|
+
}
|
|
43
|
+
history.replaceState(state, '')
|
|
44
|
+
} else if (stateRoute.nonce === state.route.nonce) {
|
|
45
|
+
nav = state.route.depth > stateRoute.depth ? 'forward' : 'back'
|
|
46
|
+
}
|
|
47
|
+
stateRoute = state.route
|
|
48
|
+
|
|
49
|
+
if (route('mode').peek() === 'back') {
|
|
50
|
+
route('depth').set(stateRoute.depth)
|
|
51
|
+
// We are still in the process of searching for a page in our navigation history..
|
|
52
|
+
updateHistory()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
const search: any= {}
|
|
33
57
|
for(let [k, v] of new URLSearchParams(location.search)) {
|
|
34
58
|
search[k] = v
|
|
35
59
|
}
|
|
36
|
-
|
|
37
|
-
stack.length = Math.max(1, history.length - initialHistoryLength + 1)
|
|
38
|
-
stack[stack.length-1] = {url: location.pathname + location.search, state: prevHistoryState}
|
|
60
|
+
|
|
39
61
|
route.set({
|
|
40
62
|
path: location.pathname,
|
|
41
63
|
p: location.pathname.slice(1).split('/'),
|
|
42
64
|
search: search,
|
|
43
65
|
hash: location.hash,
|
|
44
|
-
|
|
66
|
+
id: state.id,
|
|
67
|
+
aux: state.aux,
|
|
68
|
+
depth: stateRoute.depth,
|
|
69
|
+
nav,
|
|
45
70
|
})
|
|
71
|
+
|
|
72
|
+
// Forward or back event. Redraw synchronously, because we can!
|
|
73
|
+
if (event) runQueue();
|
|
46
74
|
}
|
|
47
75
|
handleLocationUpdate()
|
|
48
76
|
window.addEventListener("popstate", handleLocationUpdate);
|
|
@@ -52,80 +80,115 @@ window.addEventListener("popstate", handleLocationUpdate);
|
|
|
52
80
|
// initiated `set` will see the canonical form (instead of doing a rerender shortly after,
|
|
53
81
|
// or crashing due to non-canonical data).
|
|
54
82
|
function updatePath(): void {
|
|
55
|
-
let path = route
|
|
56
|
-
if (path == null && route
|
|
83
|
+
let path = route('path').get()
|
|
84
|
+
if (path == null && route('p').peek()) {
|
|
57
85
|
return updateP();
|
|
58
86
|
}
|
|
59
87
|
path = ''+path
|
|
60
88
|
if (!path.startsWith('/')) path = '/'+path
|
|
61
|
-
route
|
|
62
|
-
route
|
|
89
|
+
route('path').set(path)
|
|
90
|
+
route('p').set(path.slice(1).split('/'))
|
|
63
91
|
}
|
|
64
92
|
immediateObserve(updatePath)
|
|
65
93
|
|
|
66
94
|
function updateP(): void {
|
|
67
|
-
const p = route
|
|
68
|
-
|
|
69
|
-
if (p == null && route.peek('path')) {
|
|
95
|
+
const p = route('p').get()
|
|
96
|
+
if (p == null && route('path').peek()) {
|
|
70
97
|
return updatePath()
|
|
71
98
|
}
|
|
72
99
|
if (!(p instanceof Array)) {
|
|
73
100
|
console.error(`aberdeen route: 'p' must be a non-empty array, not ${JSON.stringify(p)}`)
|
|
74
|
-
route
|
|
101
|
+
route('p').set(['']) // This will cause a recursive call this observer.
|
|
75
102
|
} else if (p.length == 0) {
|
|
76
|
-
route
|
|
103
|
+
route('p').set(['']) // This will cause a recursive call this observer.
|
|
77
104
|
} else {
|
|
78
|
-
route
|
|
105
|
+
route('path').set('/' + p.join('/'))
|
|
79
106
|
}
|
|
80
107
|
}
|
|
81
108
|
immediateObserve(updateP)
|
|
82
109
|
|
|
83
110
|
immediateObserve(() => {
|
|
84
|
-
if (route
|
|
111
|
+
if (route('search').getType() !== 'object') route('search').set({})
|
|
85
112
|
})
|
|
86
113
|
|
|
87
114
|
immediateObserve(() => {
|
|
88
|
-
if (route
|
|
115
|
+
if (route('state').getType() !== 'object') route('state').set({})
|
|
89
116
|
})
|
|
90
117
|
|
|
91
118
|
immediateObserve(() => {
|
|
92
|
-
let hash = ''+(route
|
|
119
|
+
let hash = ''+(route('hash').get() || '')
|
|
93
120
|
if (hash && !hash.startsWith('#')) hash = '#'+hash
|
|
94
|
-
route
|
|
121
|
+
route('hash').set(hash)
|
|
95
122
|
})
|
|
96
123
|
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
function isSamePage(path: string, state: any): boolean {
|
|
125
|
+
return location.pathname === path && JSON.stringify(history.state.id) === JSON.stringify(state.id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function updateHistory() {
|
|
99
129
|
// Get and delete mode without triggering anything.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
let mode = route('mode').get()
|
|
131
|
+
const state = {
|
|
132
|
+
id: route('id').get(),
|
|
133
|
+
aux: route('aux').get(),
|
|
134
|
+
route: stateRoute,
|
|
135
|
+
}
|
|
103
136
|
|
|
104
137
|
// Construct the URL.
|
|
105
|
-
const path = route
|
|
106
|
-
const search = new URLSearchParams(route.get('search')).toString()
|
|
107
|
-
const url = (search ? path+'?'+search : path) + route.get('hash')
|
|
138
|
+
const path = route('path').get()
|
|
108
139
|
|
|
109
140
|
// Change browser state, according to `mode`.
|
|
110
141
|
if (mode === 'back') {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
goDelta--
|
|
116
|
-
stack.pop()
|
|
142
|
+
route('nav').set('back')
|
|
143
|
+
if (!isSamePage(path, state) && (history.state.route?.depth||0) > 1) {
|
|
144
|
+
history.back()
|
|
145
|
+
return
|
|
117
146
|
}
|
|
118
|
-
|
|
147
|
+
mode = 'replace'
|
|
119
148
|
// We'll replace the state async, to give the history.go the time to take affect first.
|
|
120
|
-
setTimeout(() => history.replaceState(state, '', url), 0)
|
|
121
|
-
|
|
122
|
-
|
|
149
|
+
//setTimeout(() => history.replaceState(state, '', url), 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (mode) route('mode').delete()
|
|
153
|
+
const search = new URLSearchParams(route('search').get()).toString()
|
|
154
|
+
const url = (search ? path+'?'+search : path) + route('hash').get()
|
|
155
|
+
|
|
156
|
+
if (mode === 'push' || (!mode && !isSamePage(path, state))) {
|
|
157
|
+
stateRoute.depth++ // stateRoute === state.route
|
|
123
158
|
history.pushState(state, '', url)
|
|
124
|
-
|
|
159
|
+
route.merge({
|
|
160
|
+
nav: 'push',
|
|
161
|
+
depth: stateRoute.depth
|
|
162
|
+
})
|
|
125
163
|
} else {
|
|
164
|
+
// Default to `push` when the URL changed or top-level state keys changed.
|
|
126
165
|
history.replaceState(state, '', url)
|
|
127
|
-
stack[stack.length-1] = {url,state}
|
|
128
166
|
}
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// This deferred-mode observer will update the URL and history based on `route` changes.
|
|
170
|
+
observe(updateHistory)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Restore and store the vertical and horizontal scroll position for
|
|
175
|
+
* the parent element to the page state.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} name - A unique (within this page) name for this
|
|
178
|
+
* scrollable element. Defaults to 'main'.
|
|
179
|
+
*/
|
|
180
|
+
export function persistScroll(name: string = 'main') {
|
|
181
|
+
const el = getParentElement()
|
|
182
|
+
el.addEventListener('scroll', onScroll)
|
|
183
|
+
clean(() => el.removeEventListener('scroll', onScroll))
|
|
131
184
|
|
|
185
|
+
let restore = route('state', 'scroll', name).peek()
|
|
186
|
+
if (restore) {
|
|
187
|
+
Object.assign(el, restore)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onScroll() {
|
|
191
|
+
route('mode').set('replace')
|
|
192
|
+
route('state', 'scroll', name).set({scrollTop: el.scrollTop, scrollLeft: el.scrollLeft})
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/transitions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {DOM_READ_PHASE, DOM_WRITE_PHASE} from './aberdeen.js'
|
|
2
2
|
|
|
3
3
|
const FADE_TIME = 400
|
|
4
4
|
const GROW_SHRINK_TRANSITION = `margin ${FADE_TIME}ms ease-out, transform ${FADE_TIME}ms ease-out`
|
|
@@ -20,34 +20,28 @@ function getGrowShrinkProps(el: HTMLElement) {
|
|
|
20
20
|
* The transition doesn't look great for table elements, and may have problems
|
|
21
21
|
* for other specific cases as well.
|
|
22
22
|
*/
|
|
23
|
-
export function grow(el: HTMLElement)
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
el.style.transition = ""
|
|
46
|
-
}, FADE_TIME)
|
|
47
|
-
})
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
})
|
|
23
|
+
export async function grow(el: HTMLElement) {
|
|
24
|
+
// Wait until all DOM updates have been done. Then get info from the computed layout.
|
|
25
|
+
await DOM_READ_PHASE
|
|
26
|
+
let props = getGrowShrinkProps(el)
|
|
27
|
+
|
|
28
|
+
// In the write phase, make the element size 0 using transforms and negative margins.
|
|
29
|
+
await DOM_WRITE_PHASE
|
|
30
|
+
Object.assign(el.style, props)
|
|
31
|
+
|
|
32
|
+
// Await read phase, to combine multiple transitions into a single browser layout
|
|
33
|
+
await DOM_READ_PHASE
|
|
34
|
+
// Make sure the layouting has been performed, to cause transitions to trigger
|
|
35
|
+
el.offsetHeight
|
|
36
|
+
|
|
37
|
+
// In the next write phase, do the transitions
|
|
38
|
+
await DOM_WRITE_PHASE
|
|
39
|
+
el.style.transition = GROW_SHRINK_TRANSITION
|
|
40
|
+
for(let prop in props) el.style[prop as any] = ''
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
// Disable transitions.
|
|
43
|
+
el.style.transition = ''
|
|
44
|
+
}, FADE_TIME)
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
/** Do a shrink transition for the given element, and remove it from the DOM
|
|
@@ -58,16 +52,18 @@ export function grow(el: HTMLElement): void {
|
|
|
58
52
|
* The transition doesn't look great for table elements, and may have problems
|
|
59
53
|
* for other specific cases as well.
|
|
60
54
|
*/
|
|
61
|
-
export function shrink(el: HTMLElement)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
55
|
+
export async function shrink(el: HTMLElement) {
|
|
56
|
+
// Wait until all DOM updates have been done. Then get info from the computed layout.
|
|
57
|
+
await DOM_READ_PHASE
|
|
58
|
+
const props = getGrowShrinkProps(el)
|
|
59
|
+
|
|
60
|
+
// Batch starting transitions in the write phase.
|
|
61
|
+
await DOM_WRITE_PHASE
|
|
62
|
+
el.style.transition = GROW_SHRINK_TRANSITION
|
|
63
|
+
Object.assign(el.style, props)
|
|
64
|
+
|
|
65
|
+
// Remove the element after the transition is done.
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
el.remove()
|
|
68
|
+
}, FADE_TIME)
|
|
69
|
+
}
|