@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/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
@@ -0,0 +1,2 @@
1
+ export { pwa } from './pwa/index.js';
2
+ export { capabilities } from './pwa/capabilities.js';
@@ -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,12 @@
1
+ /**
2
+ * @param {string} name
3
+ * @returns {import('../index.js').Plugin}
4
+ */
5
+ export function appName(name){
6
+ return {
7
+ name: 'appName',
8
+ beforeNavigation: (context) => {
9
+ context.title = `${name} ${context.title}`
10
+ }
11
+ }
12
+ }
@@ -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,13 @@
1
+ /**
2
+ * @example lazy(() => import('foo'))
3
+ * @param {any} fn
4
+ * @returns {import('../index.js').Plugin}
5
+ */
6
+ export function lazy(fn) {
7
+ return {
8
+ name: 'lazy',
9
+ beforeNavigation: () => {
10
+ fn();
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,13 @@
1
+ /**
2
+ * @param {string} path
3
+ * @returns {import('../index.js').Plugin}
4
+ */
5
+ export function redirect(path) {
6
+ return {
7
+ name: 'redirect',
8
+ shouldNavigate: () => ({
9
+ condition: () => false,
10
+ redirect: path
11
+ })
12
+ }
13
+ }
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @type {import('../index.js').Plugin}
3
+ */
4
+ export const scrollToTop = {
5
+ name: 'scrollToTop',
6
+ beforeNavigation: () => {
7
+ window.scrollTo(0, 0);
8
+ }
9
+ }
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
- export class StateEvent extends Event {
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';
@@ -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
+ }