@thepassle/app-tools 0.0.10 → 0.7.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 +3 -1
- package/api/index.js +31 -9
- package/api/plugins/abort.js +1 -0
- package/api/plugins/cache.js +4 -3
- package/api/plugins/debounce.js +37 -0
- package/api/plugins/delay.js +4 -1
- package/api/plugins/jsonPrefix.js +1 -0
- package/api/plugins/logger.js +1 -0
- package/api/plugins/mock.js +2 -14
- package/api/plugins/xsrf.js +29 -0
- package/dialog/dialog.test.js +66 -0
- package/dialog/events.js +14 -0
- package/dialog/index.js +184 -0
- package/dialog/utils.js +40 -0
- package/dialog.js +1 -0
- package/package.json +19 -16
- package/pwa/capabilities.js +7 -0
- package/pwa/events.js +33 -0
- package/pwa/index.js +174 -0
- package/pwa.js +2 -0
- package/router/index.js +221 -0
- package/router/plugins/appName.js +12 -0
- package/router/plugins/checkServiceWorkerUpdate.js +15 -0
- package/router/plugins/data.js +13 -0
- package/router/plugins/lazy.js +13 -0
- package/router/plugins/offline.js +16 -0
- package/router/plugins/redirect.js +13 -0
- package/router/plugins/resetFocus.js +31 -0
- package/router/plugins/scrollToTop.js +9 -0
- package/router.js +1 -0
- package/state/index.js +6 -1
- package/utils/CONSTANTS.js +1 -0
- package/utils/Service.js +83 -0
- package/utils/async.js +81 -0
- package/utils/log.js +44 -0
- package/utils/media.js +47 -0
- package/utils.js +3 -0
package/pwa/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InstallableEvent,
|
|
3
|
+
InstalledEvent,
|
|
4
|
+
UpdateAvailableEvent
|
|
5
|
+
} from './events.js';
|
|
6
|
+
import { capabilities } from './capabilities.js';
|
|
7
|
+
import { createLogger } from '../utils/log.js';
|
|
8
|
+
import { media } from '../utils/media.js';
|
|
9
|
+
const log = createLogger('pwa');
|
|
10
|
+
|
|
11
|
+
let installable, installPrompt;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Event & {
|
|
15
|
+
* prompt(): Promise<void>,
|
|
16
|
+
* userChoice: Promise<{
|
|
17
|
+
* outcome: 'accepted' | 'dismissed',
|
|
18
|
+
* platform: string
|
|
19
|
+
* }>
|
|
20
|
+
* }} BeforeInstallPromptEvent
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
class Pwa extends EventTarget {
|
|
24
|
+
/** @type {boolean} */
|
|
25
|
+
updateAvailable = false;
|
|
26
|
+
/** @type {boolean} */
|
|
27
|
+
installable = false;
|
|
28
|
+
/** @type {BeforeInstallPromptEvent | undefined} */
|
|
29
|
+
installPrompt;
|
|
30
|
+
/** @type {ServiceWorker | undefined} */
|
|
31
|
+
__waitingServiceWorker;
|
|
32
|
+
/** @type {boolean} */
|
|
33
|
+
isInstalled = media.STANDALONE();
|
|
34
|
+
|
|
35
|
+
/** Triggers the install prompt, when it's available. You can call this method when the `'installable'` event has fired. */
|
|
36
|
+
triggerPrompt = async () => {
|
|
37
|
+
log('Triggering prompt')
|
|
38
|
+
if(this.installPrompt) {
|
|
39
|
+
this.installPrompt.prompt();
|
|
40
|
+
const { outcome } = await this.installPrompt?.userChoice;
|
|
41
|
+
|
|
42
|
+
if (outcome === 'accepted') {
|
|
43
|
+
log('Prompt accepted')
|
|
44
|
+
this.dispatchEvent(new InstalledEvent(true));
|
|
45
|
+
this.installPrompt = undefined;
|
|
46
|
+
} else {
|
|
47
|
+
log('Prompt denied')
|
|
48
|
+
this.dispatchEvent(new InstalledEvent(false));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Update */
|
|
54
|
+
update = () => {
|
|
55
|
+
log('Skip waiting')
|
|
56
|
+
this.__waitingServiceWorker?.postMessage({ type: 'SKIP_WAITING' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} swPath
|
|
61
|
+
* @param {RegistrationOptions} opts
|
|
62
|
+
* @returns {Promise<ServiceWorkerRegistration> | Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
register(swPath, opts) {
|
|
65
|
+
if(capabilities.SERVICEWORKER) {
|
|
66
|
+
if(opts) {
|
|
67
|
+
return navigator.serviceWorker.register(swPath, opts);
|
|
68
|
+
} else {
|
|
69
|
+
return navigator.serviceWorker.register(swPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async kill() {
|
|
76
|
+
if (capabilities.SERVICEWORKER) {
|
|
77
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
78
|
+
for (let registration of registrations) {
|
|
79
|
+
registration.unregister();
|
|
80
|
+
}
|
|
81
|
+
log('Killed service worker');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cachesList = await caches.keys();
|
|
85
|
+
await Promise.all(cachesList.map(key => caches.delete(key)));
|
|
86
|
+
log('Cleared cache');
|
|
87
|
+
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
window.location.reload();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pwa = new Pwa();
|
|
95
|
+
|
|
96
|
+
window.addEventListener('beforeinstallprompt', e => {
|
|
97
|
+
log('Before install prompt fired')
|
|
98
|
+
installable = true;
|
|
99
|
+
installPrompt = /** @type {BeforeInstallPromptEvent} */ (e);
|
|
100
|
+
pwa.installable = installable;
|
|
101
|
+
pwa.installPrompt = installPrompt;
|
|
102
|
+
pwa.dispatchEvent(new InstallableEvent());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if(capabilities.SERVICEWORKER) {
|
|
106
|
+
/** @type {ServiceWorker | null} */
|
|
107
|
+
let newWorker;
|
|
108
|
+
|
|
109
|
+
navigator.serviceWorker.getRegistration().then(reg => {
|
|
110
|
+
if (reg) {
|
|
111
|
+
/**
|
|
112
|
+
* If there is already a waiting service worker in line, AND an active, controlling
|
|
113
|
+
* service worker, it means there is an update ready
|
|
114
|
+
*/
|
|
115
|
+
if (reg.waiting && navigator.serviceWorker.controller) {
|
|
116
|
+
log('New service worker available')
|
|
117
|
+
pwa.updateAvailable = true;
|
|
118
|
+
pwa.__waitingServiceWorker = reg.waiting;
|
|
119
|
+
pwa.dispatchEvent(new UpdateAvailableEvent());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* If there is no waiting service worker yet, it might still be parsing or installing
|
|
124
|
+
*/
|
|
125
|
+
reg.addEventListener('updatefound', () => {
|
|
126
|
+
newWorker = reg.installing;
|
|
127
|
+
if(newWorker) {
|
|
128
|
+
newWorker.addEventListener('statechange', () => {
|
|
129
|
+
if (newWorker?.state === 'installed' && navigator.serviceWorker.controller) {
|
|
130
|
+
log('New service worker available')
|
|
131
|
+
pwa.updateAvailable = true;
|
|
132
|
+
pwa.__waitingServiceWorker = newWorker;
|
|
133
|
+
pwa.dispatchEvent(new UpdateAvailableEvent());
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Handle the reload whenever the service worker has updated. This can happen either via:
|
|
143
|
+
* - The service worker calling skipWaiting itself (skipWaiting pattern)
|
|
144
|
+
* - Or calling `pwa.update()` after the `'update-available'` event has fired, to let the user choose when they would like to activate the update
|
|
145
|
+
*
|
|
146
|
+
* This logic prevents an unnecessary page reload the first time the service worker has installed and activated
|
|
147
|
+
*/
|
|
148
|
+
let refreshing;
|
|
149
|
+
async function handleUpdate() {
|
|
150
|
+
// check to see if there is a current active service worker
|
|
151
|
+
const oldSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
|
|
152
|
+
|
|
153
|
+
navigator.serviceWorker.addEventListener('controllerchange', async () => {
|
|
154
|
+
log('Controller change');
|
|
155
|
+
if (refreshing) return;
|
|
156
|
+
|
|
157
|
+
// when the controllerchange event has fired, we get the new service worker
|
|
158
|
+
const newSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
|
|
159
|
+
|
|
160
|
+
// if there was already an old activated service worker, and a new activating service worker, do the reload
|
|
161
|
+
if(oldSw === 'activated' && newSw === 'activating') {
|
|
162
|
+
log('Reloading');
|
|
163
|
+
refreshing = true;
|
|
164
|
+
window.location.reload();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if(capabilities.SERVICEWORKER) {
|
|
170
|
+
handleUpdate();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { pwa };
|
package/pwa.js
ADDED
package/router/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createLogger } from '../utils/log.js';
|
|
2
|
+
const log = createLogger('router');
|
|
3
|
+
|
|
4
|
+
class RouteEvent extends Event {
|
|
5
|
+
/**
|
|
6
|
+
* @param {Context} context
|
|
7
|
+
*/
|
|
8
|
+
constructor(context) {
|
|
9
|
+
super('route-changed');
|
|
10
|
+
this.context = context;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* name: string,
|
|
17
|
+
* shouldNavigate?: (context: Context) => {
|
|
18
|
+
* condition: () => boolean | (() => Promise<boolean>),
|
|
19
|
+
* redirect: string
|
|
20
|
+
* },
|
|
21
|
+
* beforeNavigation?: (context: Context) => void,
|
|
22
|
+
* afterNavigation?: (context: Context) => void,
|
|
23
|
+
* }} Plugin
|
|
24
|
+
* @typedef {{
|
|
25
|
+
* title?: string,
|
|
26
|
+
* query: Object,
|
|
27
|
+
* params: Object,
|
|
28
|
+
* url: URL,
|
|
29
|
+
* [key: string]: any
|
|
30
|
+
* }} Context
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* path: string,
|
|
33
|
+
* title: string | ((context: Context) => string),
|
|
34
|
+
* render?: <RenderResult>(context: Context) => RenderResult
|
|
35
|
+
* plugins?: Plugin[]
|
|
36
|
+
* }} RouteDefinition
|
|
37
|
+
* @typedef {RouteDefinition & {
|
|
38
|
+
* urlPattern?: any,
|
|
39
|
+
* }} Route
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export class Router extends EventTarget {
|
|
43
|
+
context = {
|
|
44
|
+
params: {},
|
|
45
|
+
query: {},
|
|
46
|
+
title: '',
|
|
47
|
+
url: new URL(window.location.href)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* fallback?: string,
|
|
53
|
+
* plugins?: Plugin[],
|
|
54
|
+
* routes: RouteDefinition[]
|
|
55
|
+
* }} config
|
|
56
|
+
*/
|
|
57
|
+
constructor(config) {
|
|
58
|
+
super();
|
|
59
|
+
this.config = config;
|
|
60
|
+
|
|
61
|
+
/** @type {Route[]} */
|
|
62
|
+
this.routes = config.routes.map((route) => {
|
|
63
|
+
const r = /** @type {unknown} */ ({
|
|
64
|
+
...route,
|
|
65
|
+
urlPattern: new URLPattern({
|
|
66
|
+
pathname: route.path,
|
|
67
|
+
baseURL: window.location.href,
|
|
68
|
+
search: '*',
|
|
69
|
+
hash: '*',
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
return /** @type {Route} */ (r);
|
|
73
|
+
});
|
|
74
|
+
log('Initialized routes', this.routes);
|
|
75
|
+
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
this.navigate(new URL(window.location.href));
|
|
78
|
+
});
|
|
79
|
+
window.addEventListener('popstate', this._onPopState);
|
|
80
|
+
window.addEventListener('click', this._onAnchorClick);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get url() {
|
|
84
|
+
return new URL(window.location.href);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get fallback() {
|
|
88
|
+
return new URL(
|
|
89
|
+
this.config?.fallback || this.baseUrl.href.substring(window.location.origin.length),
|
|
90
|
+
this.baseUrl
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get baseUrl() {
|
|
95
|
+
return new URL('./', document.baseURI);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
render() {
|
|
99
|
+
log(`Rendering route ${this.context.url.pathname}${this.context.url.search}`, { context: this.context, route: this.route });
|
|
100
|
+
return this.route?.render?.(this.context);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {URL} url
|
|
105
|
+
* @returns {Route | null}
|
|
106
|
+
*/
|
|
107
|
+
_matchRoute(url) {
|
|
108
|
+
for (const route of this.routes) {
|
|
109
|
+
const match = route.urlPattern.exec(url);
|
|
110
|
+
if (match) {
|
|
111
|
+
const { title } = route;
|
|
112
|
+
const query = Object.fromEntries(new URLSearchParams(url.search));
|
|
113
|
+
const params = match?.pathname?.groups ?? {};
|
|
114
|
+
this.context = {
|
|
115
|
+
url,
|
|
116
|
+
title: typeof title === 'function' ? title({params, query, url}) : title,
|
|
117
|
+
params,
|
|
118
|
+
query,
|
|
119
|
+
}
|
|
120
|
+
return route;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
log(`No route matched for ${url.pathname}${url.search}`, url);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_notifyUrlChanged() {
|
|
128
|
+
this.dispatchEvent(new RouteEvent(this.context));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_onPopState = () => {
|
|
132
|
+
this.route = this._matchRoute(new URL(window.location.href));
|
|
133
|
+
this._notifyUrlChanged();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_onAnchorClick = (e) => {
|
|
137
|
+
if (
|
|
138
|
+
e.defaultPrevented ||
|
|
139
|
+
e.button !== 0 ||
|
|
140
|
+
e.metaKey ||
|
|
141
|
+
e.ctrlKey ||
|
|
142
|
+
e.shiftKey
|
|
143
|
+
) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const a = e.composedPath().find((el) => el.tagName === 'A');
|
|
148
|
+
if (!a || !a.href) return;
|
|
149
|
+
|
|
150
|
+
const url = new URL(a.href);
|
|
151
|
+
|
|
152
|
+
if (this.url.href === url.href) return;
|
|
153
|
+
if (a.hasAttribute('download') || a.href.includes('mailto:')) return;
|
|
154
|
+
|
|
155
|
+
const target = a.getAttribute('target');
|
|
156
|
+
if (target && target !== '' && target !== '_self') return;
|
|
157
|
+
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
this.navigate(url);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {string | URL} url
|
|
164
|
+
*/
|
|
165
|
+
async navigate(url) {
|
|
166
|
+
if (typeof url === 'string') {
|
|
167
|
+
url = new URL(url, this.baseUrl);
|
|
168
|
+
}
|
|
169
|
+
this.route = this._matchRoute(url) || this._matchRoute(this.fallback);
|
|
170
|
+
log(`Navigating to ${url.pathname}${url.search}`, { context: this.context, route: this.route });
|
|
171
|
+
|
|
172
|
+
/** @type {Plugin[]} */
|
|
173
|
+
const plugins = [
|
|
174
|
+
...(this.config?.plugins ?? []),
|
|
175
|
+
...(this.route?.plugins ?? []),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
for (const plugin of plugins) {
|
|
179
|
+
try {
|
|
180
|
+
const result = await plugin?.shouldNavigate?.(this.context);
|
|
181
|
+
if (result) {
|
|
182
|
+
const condition = await result.condition();
|
|
183
|
+
if (!condition) {
|
|
184
|
+
url = new URL(result.redirect, this.baseUrl);
|
|
185
|
+
this.route = this._matchRoute(url) || this._matchRoute(this.fallback);
|
|
186
|
+
log('Redirecting', { context: this.context, route: this.route });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch(e) {
|
|
190
|
+
log(`Plugin "${plugin.name}" error on shouldNavigate hook`, e);
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!this.route) {
|
|
196
|
+
throw new Error(`[ROUTER] No route or fallback matched for url ${url}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const plugin of plugins) {
|
|
200
|
+
try {
|
|
201
|
+
await plugin?.beforeNavigation?.(this.context);
|
|
202
|
+
} catch(e) {
|
|
203
|
+
log(`Plugin "${plugin.name}" error on beforeNavigation hook`, e);
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
window.history.pushState(null, '', `${url.pathname}${url.search}`);
|
|
209
|
+
document.title = this.context.title;
|
|
210
|
+
this._notifyUrlChanged();
|
|
211
|
+
|
|
212
|
+
for (const plugin of plugins) {
|
|
213
|
+
try {
|
|
214
|
+
await plugin?.afterNavigation?.(this.context);
|
|
215
|
+
} catch(e) {
|
|
216
|
+
log(`Plugin "${plugin.name}" error on afterNavigation hook`, e);
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @type {import('../index.js').Plugin}
|
|
3
|
+
*/
|
|
4
|
+
export const checkServiceWorkerUpdate = {
|
|
5
|
+
name: 'checkServiceWorkerUpdate',
|
|
6
|
+
beforeNavigation: () => {
|
|
7
|
+
if ('serviceWorker' in navigator) {
|
|
8
|
+
navigator.serviceWorker.getRegistration().then(registration => {
|
|
9
|
+
if (registration) {
|
|
10
|
+
registration.update();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {<Data>(context: import('../index.js').Context) => Promise<Data>} promise
|
|
3
|
+
* @returns {import('../index.js').Plugin}
|
|
4
|
+
*/
|
|
5
|
+
export function data(promise){
|
|
6
|
+
return {
|
|
7
|
+
name: 'data',
|
|
8
|
+
beforeNavigation: async (context) => {
|
|
9
|
+
const data = await promise(context);
|
|
10
|
+
context.data = data;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @example offlinePlugin('/my-offline-page')
|
|
3
|
+
* @param {string} offlineRoute
|
|
4
|
+
* @returns {import('../index.js').Plugin}
|
|
5
|
+
*/
|
|
6
|
+
export function offlinePlugin(offlineRoute = '/offline') {
|
|
7
|
+
return {
|
|
8
|
+
name: 'offline',
|
|
9
|
+
shouldNavigate: () => ({
|
|
10
|
+
condition: () => !navigator.onLine,
|
|
11
|
+
redirect: offlineRoute,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const offline = offlinePlugin();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { APP_TOOLS } from '../../utils/CONSTANTS.js';
|
|
2
|
+
|
|
3
|
+
const FOCUS_ELEMENT_ID = 'router-focus';
|
|
4
|
+
const SR_ONLY_STYLE = `position:absolute;top:0;width:1px;height:1px;overflow:hidden;clip:rect(1px,1px,1px,1px);clip-path:inset(50%);margin:-1px;`;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @type {import('../index.js').Plugin}
|
|
8
|
+
*/
|
|
9
|
+
export const resetFocus = {
|
|
10
|
+
name: 'resetFocus',
|
|
11
|
+
afterNavigation: ({title}) => {
|
|
12
|
+
let el = /** @type {HTMLElement} */ (document.querySelector(`div[${APP_TOOLS}]#${FOCUS_ELEMENT_ID}`));
|
|
13
|
+
if (!el) {
|
|
14
|
+
el = document.createElement('div');
|
|
15
|
+
el.setAttribute(APP_TOOLS, '');
|
|
16
|
+
el.id = FOCUS_ELEMENT_ID;
|
|
17
|
+
el.setAttribute('tabindex', '-1');
|
|
18
|
+
el.setAttribute('aria-live', 'polite');
|
|
19
|
+
el.setAttribute('style', SR_ONLY_STYLE);
|
|
20
|
+
el.addEventListener('blur', () => {
|
|
21
|
+
el?.style.setProperty('display', 'none');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
document.body.insertBefore(el, document.body.firstChild);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
el.textContent = /** @type {string} */ (title);
|
|
28
|
+
el.style.removeProperty('display');
|
|
29
|
+
el.focus();
|
|
30
|
+
}
|
|
31
|
+
}
|
package/router.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Router } from './router/index.js';
|
package/state/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createLogger } from '../utils/log.js';
|
|
2
|
+
const log = createLogger('state');
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* `'state-changed'` event
|
|
3
6
|
* @example this.dispatchEvent(new StateEvent(data));
|
|
4
7
|
*/
|
|
5
|
-
|
|
8
|
+
export class StateEvent extends Event {
|
|
6
9
|
constructor(state = {}) {
|
|
7
10
|
super('state-changed');
|
|
8
11
|
this.state = state;
|
|
@@ -18,7 +21,9 @@ export class State extends EventTarget {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
setState(state) {
|
|
24
|
+
log('Before: ', this.#state);
|
|
21
25
|
this.#state = typeof state === 'function' ? state(this.#state) : state;
|
|
26
|
+
log('After: ', this.#state);
|
|
22
27
|
this.dispatchEvent(new StateEvent(this.#state));
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const APP_TOOLS = 'app-tools';
|
package/utils/Service.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { when } from './index.js';
|
|
2
|
+
|
|
3
|
+
export function createService(defaults) {
|
|
4
|
+
return class Service {
|
|
5
|
+
constructor(host, promise) {
|
|
6
|
+
(this.host = host).addController(this);
|
|
7
|
+
|
|
8
|
+
this.promise = promise;
|
|
9
|
+
this.state = 'initialized';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setPromise(promise) {
|
|
13
|
+
this.promise = promise;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setError(msg) {
|
|
17
|
+
this.errorMessage = msg;
|
|
18
|
+
this.state = 'error';
|
|
19
|
+
this.host.requestUpdate();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
request(params) {
|
|
23
|
+
this.state = 'pending';
|
|
24
|
+
this.host.requestUpdate();
|
|
25
|
+
|
|
26
|
+
return this.promise(params)
|
|
27
|
+
.then(data => {
|
|
28
|
+
this.state = 'success';
|
|
29
|
+
this.data = data;
|
|
30
|
+
this.host.requestUpdate();
|
|
31
|
+
return data;
|
|
32
|
+
})
|
|
33
|
+
.catch(e => {
|
|
34
|
+
this.errorMessage = e?.message;
|
|
35
|
+
this.state = 'error';
|
|
36
|
+
this.host.requestUpdate();
|
|
37
|
+
throw e;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Use states individually, useful if you may need to render stuff in different locations
|
|
43
|
+
*/
|
|
44
|
+
initialized(templateFn) {
|
|
45
|
+
return when(this.state === 'initialized', templateFn || defaults.initialized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pending(templateFn) {
|
|
49
|
+
return when(this.state === 'pending', templateFn || defaults.pending);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
success(templateFn) {
|
|
53
|
+
const template = templateFn || defaults.success;
|
|
54
|
+
return when(this.state === 'success', () => template(this.data));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
error(templateFn) {
|
|
58
|
+
const template = templateFn || defaults.error;
|
|
59
|
+
return when(this.state === 'error', () => template(this.errorMessage));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Combined render method, if you want to just render everything in place
|
|
64
|
+
*/
|
|
65
|
+
render(templates) {
|
|
66
|
+
const states = {
|
|
67
|
+
...defaults,
|
|
68
|
+
...templates,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch(this.state) {
|
|
72
|
+
case 'initialized':
|
|
73
|
+
return states.initialized();
|
|
74
|
+
case 'pending':
|
|
75
|
+
return states.pending();
|
|
76
|
+
case 'success':
|
|
77
|
+
return states.success(this.data);
|
|
78
|
+
case 'error':
|
|
79
|
+
return states.error(this.errorMessage);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|