@thepassle/app-tools 0.9.2 → 0.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thepassle/app-tools",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -35,7 +35,7 @@
35
35
  "default": "./router.js"
36
36
  },
37
37
  "./router/plugins/*": {
38
- "types": "./types/router/index/*",
38
+ "types": "./types/router/plugins/*",
39
39
  "default": "./router/plugins/*"
40
40
  },
41
41
  "./api/plugins/*": {
package/pwa/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Pwa
2
+
3
+ ## Install
4
+
5
+ ```
6
+ npm i -S @thepassle/app-tools
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ Sets up global listeners for `'beforeinstallprompt'`, `'controllerchange'` as a side effect and correctly handles reloading when `'controllerchange'` has fired; it only reloads when a new service worker has activated, and there was a previous worker.
12
+
13
+ ```js
14
+ import { PROD } from '@thepassle/app-tools/env.js';
15
+ import { pwa } from '@thepassle/app-tools/pwa.js';
16
+
17
+ pwa.updateAvailable; // false
18
+ pwa.installable; // false
19
+ pwa.installPrompt; // undefined
20
+ /** Whether or not the PWA is running in standalone mode, and thus is installed as PWA */
21
+ pwa.isInstalled; // false
22
+
23
+ if (PROD) {
24
+ pwa.register('./sw.js', { scope: './' })
25
+ .catch(() => {
26
+ console.log('Failed to register SW.')
27
+ });
28
+ }
29
+
30
+ /** Fires an event when the PWA is considered installable by the browser */
31
+ pwa.addEventListener('installable', () => {
32
+ pwa.installable; // true
33
+ pwa.installPrompt; // stores the beforeinstallprompt event
34
+ pwa.triggerPrompt(); // trigger the prompt
35
+ });
36
+
37
+ pwa.addEventListener('installed', ({installed}) => {
38
+ if (installed) {
39
+ /** The user accepted the install prompt, the PWA is successfully installed */
40
+ } else {
41
+ /** The user denied the prompt */
42
+ }
43
+ });
44
+
45
+ /**
46
+ * Fires when a service worker update is available
47
+ * Can be used to display a notification dot/icon to signal the user that an update is available,
48
+ * or conditionally render some kind of "update" button
49
+ */
50
+ pwa.addEventListener('update-available', () => {
51
+ pwa.updateAvailable; // true
52
+ pwa.update(); // Sends a `{type: 'SKIP_WAITING'}` to the waiting service worker so it can take control of the page
53
+ });
54
+
55
+ /**
56
+ * PWA kill switch. Unregisters service worker, deletes caches, reloads the browser
57
+ */
58
+ pwa.kill();
59
+ ```
60
+
61
+ ## Capabilities
62
+
63
+ ```js
64
+ import { capabilities } from '@thepassle/app-tools/pwa.js';
65
+ import { when } from '@thepassle/app-tools/utils.js';
66
+
67
+ when(capabilities.WAKELOCK, () => html`<button>Request wakelock</button>`);
68
+
69
+ // capabilities.BADGING
70
+ // capabilities.NOTIFICATION
71
+ // capabilities.SHARE
72
+ // capabilities.SERVICEWORKER
73
+ // capabilities.WAKELOCK
74
+ ```
75
+
76
+ ## Catching the `update` in your service worker file
77
+
78
+ The `pwa.update()` method will `postMessage({type: 'SKIP_WAITING'})` to the currently `'waiting'` service worker. This is aligned with Workbox's defaults. However, if you are not using Workbox, make sure to add the following code to your service worker:
79
+
80
+ ```js
81
+ self.addEventListener('message', (event) => {
82
+ if (event?.data?.type === 'SKIP_WAITING') {
83
+ self.skipWaiting();
84
+ }
85
+ });
86
+ ```
87
+
88
+ ## Reloading when `'controllerchange'` fires
89
+
90
+ The following code snippet usually does the rounds on the internet:
91
+
92
+ ```js
93
+ let refreshing;
94
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
95
+ if (refreshing) return;
96
+ window.location.reload();
97
+ refreshing = true;
98
+ });
99
+ ```
100
+
101
+ However, this will also reload the page _the first time a user visits the page_ and leads to an unnecessary initial reload.
102
+
103
+ `pwa` handles updates by making sure there was an old service worker to replace, to avoid the unnecessary initial reload.
@@ -0,0 +1,491 @@
1
+ # Router
2
+
3
+ A simple, modular Single Page Application router.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm i -S @thepassle/app-tools
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ import { Router } from '@thepassle/app-tools/router.js';
15
+ import { lazy } from '@thepassle/app-tools/router/plugins/lazy.js';
16
+ import { offline } from '@thepassle/app-tools/router/plugins/offline.js';
17
+ import { resetFocus } from '@thepassle/app-tools/router/plugins/resetFocus.js';
18
+ import { scrollToTop } from '@thepassle/app-tools/router/plugins/scrollToTop.js';
19
+ import { checkServiceWorkerUpdate } from '@thepassle/app-tools/router/plugins/checkServiceWorkerUpdate.js';
20
+
21
+ export const router = new Router({
22
+ /** Plugins to be run for every route */
23
+ plugins: [
24
+ /** Redirects to an offline page */
25
+ offline,
26
+ /** Checks for service worker updates on route navigations */
27
+ checkServiceWorkerUpdate,
28
+ scrollToTop,
29
+ resetFocus
30
+ ],
31
+ /** Fallback route when the user navigates to a route that doesnt exist */
32
+ fallback: '/404',
33
+ routes: [
34
+ {
35
+ path: '/',
36
+ title: 'home',
37
+ render: () => html`<product-list></product-list>`
38
+ },
39
+ {
40
+ path: '/cart',
41
+ title: 'cart',
42
+ plugins: [
43
+ lazy(() => import('./shopping-card.js'))
44
+ ],
45
+ render: () => html`<shopping-cart></shopping-cart>`
46
+ },
47
+ {
48
+ path: '/product/:name',
49
+ title: ({params}) => `Product ${params.name}`,
50
+ plugins: [
51
+ lazy(() => import('./product-page.js'))
52
+ ],
53
+ render: ({params}) => html`<product-page id="${params.name}"></product-page>`
54
+ },
55
+ {
56
+ path: '/admin',
57
+ title: 'Admin',
58
+ plugins: [
59
+ {
60
+ shouldNavigate: () => ({
61
+ condition: () => state.user.isAdmin,
62
+ redirect: '/'
63
+ })
64
+ }
65
+ ],
66
+ render: () => html`<admin-page></admin-page>`
67
+ },
68
+ {
69
+ path: '/offline',
70
+ title: 'Offline',
71
+ render: () => html`<offline-page></offline-page>`
72
+ },
73
+ {
74
+ path: '/404',
75
+ title: 'Not found',
76
+ render: () => html`<404-page></404-page>`
77
+ }
78
+ ]
79
+ });
80
+
81
+ router.addEventListener('route-changed', ({context}) => {
82
+ document.querySelector('#outlet').innerHTML = router.render();
83
+ });
84
+
85
+ router.navigate('/cart');
86
+
87
+ router.context.url;
88
+ router.context.params;
89
+ router.context.query;
90
+ router.context.title;
91
+
92
+ // Cleans up global event listeners
93
+ router.uninstall();
94
+ ```
95
+
96
+ ## Polyfill
97
+
98
+ The router makes use of the `URLPattern` api, which you may need to polyfill in your application. You can use [`urlpattern-polyfill`](https://www.npmjs.com/package/urlpattern-polyfill) for this.
99
+
100
+ If you use [`@web/rollup-plugin-polyfills-loader`](https://www.npmjs.com/package/@web/rollup-plugin-polyfills-loader) in your rollup build you can use the `URLPattern` config option:
101
+
102
+ ```js
103
+ polyfillsLoader({
104
+ polyfills: {
105
+ URLPattern: true,
106
+ },
107
+ })
108
+ ```
109
+
110
+ ## Base
111
+
112
+ When using a Single Page Application (SPA) router, make sure to set the `<base href="/">` element in your HTML. Also make sure that your dev server is configured for SPA Navigations. When using `@web/dev-server`, you can use the `--app-index` configuration options.
113
+
114
+ E.g.: `web-dev-server --app-index index.html`, or `web-dev-server --app-index foo/index.html`
115
+
116
+ When using a different base, for example if your app is running on `my-app.com/foo/`, make sure to adjust your routing configuration:
117
+
118
+ ```html
119
+ <html>
120
+ <head>
121
+ <base href="/foo/">
122
+ </head>
123
+ <body>
124
+ <a href="/foo/">home</a>
125
+ <a href="foo">foo</a>
126
+ <a href="bar/123">bar</a>
127
+ <main></main>
128
+ </body>
129
+ <script type="module">
130
+ import { Router } from 'https://unpkg.com/@thepassle/app-tools/router.js';
131
+
132
+ const router = new Router({
133
+ routes: [
134
+ {
135
+ path: '/foo/',
136
+ title: 'Hello',
137
+ render: () => 'home'
138
+ },
139
+ {
140
+ path: 'foo',
141
+ title: 'Foo',
142
+ render: () => 'foo'
143
+ },
144
+ {
145
+ path: 'bar/:id',
146
+ title: ({params}) => `Bar ${params.id}`,
147
+ render: ({params}) => `bar ${params.id}`
148
+ },
149
+ ]
150
+ });
151
+
152
+ router.addEventListener('route-changed', ({context}) => {
153
+ const route = router.render();
154
+ document.querySelector('main').innerHTML = route;
155
+ });
156
+ </script>
157
+ </html>
158
+ ```
159
+
160
+ ## Rendering
161
+
162
+ The router is framework agnostic. Rendering the route is left to the consumer of the router. The application is then in charge of rendering whatever is returned from the `render` function. Here's a basic example:
163
+
164
+ ### Using vanilla js
165
+
166
+ Route:
167
+ ```js
168
+ {
169
+ path: '/',
170
+ title: 'Home',
171
+ render: (context) => 'Home route'
172
+ }
173
+ ```
174
+ App:
175
+ ```js
176
+ router.addEventListener('route-changed', () => {
177
+ const route = router.render();
178
+ document.querySelector('#outlet').innerHTML = route;
179
+ });
180
+ ```
181
+
182
+ ### Using lit-html
183
+
184
+ Route:
185
+ ```js
186
+ {
187
+ path: '/',
188
+ title: 'Home',
189
+ render: (context) => html`<my-el></my-el>`
190
+ }
191
+ ```
192
+ App:
193
+ ```js
194
+ import { html, render } from 'lit';
195
+
196
+ router.addEventListener('route-changed', () => {
197
+ render(router.render(), document.querySelector('#outlet'))
198
+ });
199
+ ```
200
+
201
+ ### Using LitElement
202
+
203
+ Route:
204
+ ```js
205
+ {
206
+ path: '/',
207
+ title: 'Home',
208
+ render: (context) => html`<my-el></my-el>`
209
+ }
210
+ ```
211
+ App:
212
+ ```js
213
+ import { LitElement } from 'lit';
214
+
215
+ class MyEl extends LitElement {
216
+ firstUpdated() {
217
+ router.addEventListener('route-changed', () => {
218
+ this.requestUpdate();
219
+ });
220
+ }
221
+
222
+ render() {
223
+ return router.render();
224
+ }
225
+ }
226
+ ```
227
+
228
+ ## Composable
229
+
230
+ Use plugins to customize your navigations to fit your needs. You can add plugins for all navigations, or for specific routes.
231
+
232
+ ```js
233
+ const router = new Router({
234
+ /** These plugins will run for any navigation */
235
+ plugins: [],
236
+ routes: [
237
+ {
238
+ path: '/foo',
239
+ title: 'Foo',
240
+ /** These plugins will run for this route only */
241
+ plugins: [],
242
+ render: () => html`<my-el></my-el>`
243
+ }
244
+ ]
245
+ })
246
+ ```
247
+
248
+ ### `lazy`
249
+
250
+ Lazily import resources or components on route navigations
251
+
252
+ ```js
253
+ import { lazy } from '@thepassle/app-tools/router/plugins/lazy.js';
254
+
255
+ const router = new Router({
256
+ routes: [
257
+ {
258
+ path: '/foo',
259
+ title: 'Foo',
260
+ plugins: [
261
+ lazy(() => import('./my-el.js')),
262
+ ],
263
+ render: () => html`<my-el></my-el>`
264
+ },
265
+ ]
266
+ });
267
+ ```
268
+
269
+ ### `data`
270
+
271
+ ```js
272
+ import { api } from '@thepassle/app-tools/api.js';
273
+ import { data } from '@thepassle/app-tools/router/plugins/data.js';
274
+
275
+ const router = new Router({
276
+ routes: [
277
+ {
278
+ path: '/pokemon/:id',
279
+ title: (context) => `Pokemon ${context.params.id}`,
280
+ plugins: [
281
+ data((context) => api.get(`https://pokeapi.co/api/v2/pokemon/${context.params.id}`)),
282
+ ],
283
+ // context.data is a promise
284
+ render: (context) => html`<pokemon-card .data=${context.data}></pokemon-card>`
285
+ },
286
+ ]
287
+ });
288
+ ```
289
+
290
+ ### `redirect`
291
+
292
+ ```js
293
+ import { redirect } from '@thepassle/app-tools/router/plugins/redirect.js';
294
+
295
+ const router = new Router({
296
+ routes: [
297
+ {
298
+ path: '/foo',
299
+ title: 'Foo',
300
+ plugins: [
301
+ redirect('/404'),
302
+ ],
303
+ },
304
+ ]
305
+ });
306
+ ```
307
+
308
+ ### `checkServiceWorkerUpdate`
309
+
310
+ Checks for service worker updates on route navigations
311
+
312
+ ```js
313
+ import { checkServiceWorkerUpdate } from '@thepassle/app-tools/router/plugins/checkServiceWorkerUpdate.js';
314
+
315
+ const router = new Router({
316
+ plugins: [
317
+ checkServiceWorkerUpdate
318
+ ],
319
+ routes: [
320
+ {
321
+ path: '/foo',
322
+ title: 'Foo',
323
+ render: () => html`<my-el></my-el>`
324
+ },
325
+ ]
326
+ });
327
+ ```
328
+
329
+ ### `offline`
330
+
331
+ Redirects to an offline page when the user is offline
332
+
333
+ ```js
334
+ import { offline, offlinePlugin } from '@thepassle/app-tools/router/plugins/offline.js';
335
+
336
+ const router = new Router({
337
+ plugins: [
338
+ /** Redirects to `/offline` by default */
339
+ offline
340
+ /** Or */
341
+ offlinePlugin('/my-offline-page')
342
+ ],
343
+ routes: [
344
+ {
345
+ path: '/offline',
346
+ title: 'Offline',
347
+ render: () => html`<offline-page></offline-page>`
348
+ },
349
+ ]
350
+ });
351
+ ```
352
+
353
+ ### `appName`
354
+
355
+ Prepends the name of your app to the title
356
+
357
+ ```js
358
+ import { appName } from '@thepassle/app-tools/router/plugins/appName.js';
359
+
360
+ const router = new Router({
361
+ plugins: [
362
+ appName('My App -')
363
+ ],
364
+ routes: [
365
+ {
366
+ path: '/foo',
367
+ title: 'Foo',
368
+ render: () => html`<my-el></my-el>`
369
+ },
370
+ ]
371
+ });
372
+ ```
373
+
374
+ Will result in the title for route `/foo` being:
375
+ `My App - Foo`
376
+
377
+ ### `scrollToTop`
378
+
379
+ Scrolls the page to the top after a navigation
380
+
381
+ ```js
382
+ import { scrollToTop } from '@thepassle/app-tools/router/plugins/scrollToTop.js';
383
+
384
+ const router = new Router({
385
+ plugins: [
386
+ scrollToTop
387
+ ],
388
+ routes: [
389
+ {
390
+ path: '/foo',
391
+ title: 'Foo',
392
+ render: () => html`<my-el></my-el>`
393
+ },
394
+ ]
395
+ });
396
+ ```
397
+
398
+ ### `resetFocus`
399
+
400
+ Resets focus to the start of the document
401
+
402
+ ```js
403
+ import { resetFocus } from '@thepassle/app-tools/router/plugins/resetFocus.js';
404
+
405
+ const router = new Router({
406
+ plugins: [
407
+ resetFocus
408
+ ],
409
+ routes: [
410
+ {
411
+ path: '/foo',
412
+ title: 'Foo',
413
+ render: () => html`<my-el></my-el>`
414
+ },
415
+ ]
416
+ });
417
+ ```
418
+
419
+ ## Plugin API
420
+
421
+ ```js
422
+ const router = new Router({
423
+ plugins: [
424
+ {
425
+ shouldNavigate: (context) => ({
426
+ condition: () => false,
427
+ redirect: '/'
428
+ }),
429
+ beforeNavigation: (context) => {
430
+
431
+ },
432
+ afterNavigation: (context) => {
433
+
434
+ }
435
+ }
436
+ ]
437
+ });
438
+ ```
439
+
440
+ All plugins have access to the `context` object for the current route. Given the following route, the context includes:
441
+
442
+ ```js
443
+ {
444
+ path: '/users/:id',
445
+ title: ({params, query}) => `Hello world ${params.id} ${query.foo}`,
446
+ }
447
+ ```
448
+
449
+ `www.my-website.com/users/123?foo=bar`
450
+
451
+ ```js
452
+ context.params; // { id: 123 }
453
+ context.query; // { foo: 'bar' }
454
+ context.title; // "Hello world 123 bar"
455
+ context.url; // URL instance of "www.my-website.com/users/123?foo=bar"
456
+ ```
457
+
458
+ ### `shouldNavigate`
459
+
460
+ Can be used to protect routes based on a condition function. Should return an object containing a `condition` function, and a `redirect`. When the `condition` returns `false`, it will redirect to the path provided by `redirect`.
461
+
462
+ ```js
463
+ const myPlugin = {
464
+ shouldNavigate: (context) => ({
465
+ /** A condition function to determine whether or not the navigation should take place */
466
+ condition: () => state.user.isAdmin,
467
+ /** Where to send the user in case the condition is false */
468
+ redirect: '/'
469
+ }),
470
+ }
471
+ ```
472
+
473
+ ### `beforeNavigation`
474
+
475
+ Runs before the navigation takes place
476
+
477
+ ```js
478
+ const myPlugin = {
479
+ beforeNavigation: (context) => {}
480
+ }
481
+ ```
482
+
483
+ ### `afterNavigation`
484
+
485
+ Runs after the navigation takes place
486
+
487
+ ```js
488
+ const myPlugin = {
489
+ afterNavigation: (context) => {}
490
+ }
491
+ ```
@@ -0,0 +1,25 @@
1
+ # State
2
+
3
+ ## Install
4
+
5
+ ```
6
+ npm i -S @thepassle/app-tools
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```js
12
+ import { State } from '@thepassle/app-tools/state.js';
13
+
14
+ const state = new State();
15
+
16
+ /** Or pass a default state */
17
+ const state = new State({foo: 'foo'});
18
+
19
+ state.setState({foo: 'bar'}); // state === {foo: 'bar'}
20
+ state.setState((old) => ({...old, bar: 'bar'})); // state === {foo: 'bar', bar: 'bar'}
21
+
22
+ state.addEventListener('state-changed', ({state}) => {});
23
+
24
+ state.getState(); // {foo: 'bar', bar: 'bar'};
25
+ ```