@enigmax/primitives 0.11.1 → 0.13.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.
@@ -0,0 +1,165 @@
1
+ /*
2
+ * Ember - the default toast theme.
3
+ *
4
+ * Every colour, every distance and every curve is in this file. `<Toaster />` renders the
5
+ * structure and the state; nothing below is something the package decided for you, so
6
+ * editing this file is the supported way to make it yours.
7
+ *
8
+ * The custom properties at the top are the whole API. Override them on `:root` and you have
9
+ * a different theme without touching a selector.
10
+ */
11
+
12
+ :root {
13
+ --enigma-toast-width: 22rem;
14
+ --enigma-toast-gap: 0.625rem;
15
+ --enigma-toast-edge: 1.25rem;
16
+ --enigma-toast-radius: 0.75rem;
17
+ --enigma-toast-exit: 200ms;
18
+
19
+ --enigma-toast-bg: #101010;
20
+ --enigma-toast-border: #2a2a2a;
21
+ --enigma-toast-text: #f5f5f5;
22
+ --enigma-toast-muted: #a3a3a3;
23
+ --enigma-toast-shadow: 0 8px 30px rgb(0 0 0 / 45%);
24
+
25
+ --enigma-toast-info: #60a5fa;
26
+ --enigma-toast-success: #4ade80;
27
+ --enigma-toast-warning: #fbbf24;
28
+ --enigma-toast-error: #f87171;
29
+ --enigma-toast-loading: var(--enigma-toast-muted);
30
+ }
31
+
32
+ @media (prefers-color-scheme: light) {
33
+ :root {
34
+ --enigma-toast-bg: #ffffff;
35
+ --enigma-toast-border: #e5e5e5;
36
+ --enigma-toast-text: #171717;
37
+ --enigma-toast-muted: #737373;
38
+ --enigma-toast-shadow: 0 8px 30px rgb(0 0 0 / 12%);
39
+ }
40
+ }
41
+
42
+ [data-enigma-toaster] {
43
+ position: fixed;
44
+ z-index: 9999;
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: var(--enigma-toast-gap);
48
+ width: min(var(--enigma-toast-width), calc(100vw - var(--enigma-toast-edge) * 2));
49
+ /* The region is a layout box, not a wall: clicks pass through the gaps between toasts. */
50
+ pointer-events: none;
51
+ margin: 0;
52
+ padding: 0;
53
+ }
54
+
55
+ [data-enigma-toaster][data-position^="top"] { top: var(--enigma-toast-edge); flex-direction: column-reverse; }
56
+ [data-enigma-toaster][data-position^="bottom"] { bottom: var(--enigma-toast-edge); }
57
+ [data-enigma-toaster][data-position$="-left"] { left: var(--enigma-toast-edge); }
58
+ [data-enigma-toaster][data-position$="-right"] { right: var(--enigma-toast-edge); }
59
+ [data-enigma-toaster][data-position$="-center"] { left: 50%; transform: translateX(-50%); }
60
+
61
+ [data-enigma-toast] {
62
+ pointer-events: auto;
63
+ position: relative;
64
+ display: grid;
65
+ grid-template-columns: 1fr auto;
66
+ align-items: center;
67
+ gap: 0.25rem 0.75rem;
68
+ padding: 0.875rem 1rem;
69
+ color: var(--enigma-toast-text);
70
+ background: var(--enigma-toast-bg);
71
+ border: 1px solid var(--enigma-toast-border);
72
+ border-radius: var(--enigma-toast-radius);
73
+ box-shadow: var(--enigma-toast-shadow);
74
+ /* The swipe offset the component publishes. Zero unless a finger is on it. */
75
+ translate: var(--enigma-toast-swipe, 0) 0;
76
+ transition: translate 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease-out;
77
+ touch-action: pan-y;
78
+ outline: none;
79
+ }
80
+
81
+ [data-enigma-toaster][data-position$="-center"] [data-enigma-toast] { translate: 0 var(--enigma-toast-swipe, 0); }
82
+
83
+ /* While a finger is down the toast tracks it exactly: a transition here would lag behind
84
+ the pointer, which reads as a broken gesture rather than a smooth one. */
85
+ [data-enigma-toast][data-swiping] { transition: none; cursor: grabbing; user-select: none; }
86
+
87
+ [data-enigma-toast]:focus-visible { box-shadow: var(--enigma-toast-shadow), 0 0 0 2px var(--enigma-toast-info); }
88
+
89
+ /* A left border is the tone. It reads at a glance and survives any background. */
90
+ [data-enigma-toast]::before {
91
+ content: "";
92
+ position: absolute;
93
+ inset: 0 auto 0 0;
94
+ width: 3px;
95
+ border-radius: var(--enigma-toast-radius) 0 0 var(--enigma-toast-radius);
96
+ background: var(--enigma-toast-tone, transparent);
97
+ }
98
+
99
+ [data-enigma-toast][data-tone="info"] { --enigma-toast-tone: var(--enigma-toast-info); }
100
+ [data-enigma-toast][data-tone="success"] { --enigma-toast-tone: var(--enigma-toast-success); }
101
+ [data-enigma-toast][data-tone="warning"] { --enigma-toast-tone: var(--enigma-toast-warning); }
102
+ [data-enigma-toast][data-tone="error"] { --enigma-toast-tone: var(--enigma-toast-error); }
103
+ [data-enigma-toast][data-tone="loading"] { --enigma-toast-tone: var(--enigma-toast-loading); }
104
+
105
+ [data-enigma-toast-title] { grid-column: 1; margin: 0; font-size: 0.875rem; font-weight: 600; line-height: 1.35; }
106
+ [data-enigma-toast-body] { grid-column: 1; margin: 0; font-size: 0.8125rem; color: var(--enigma-toast-muted); line-height: 1.45; }
107
+
108
+ [data-enigma-toast-action] {
109
+ grid-column: 2;
110
+ grid-row: 1 / -1;
111
+ padding: 0.3125rem 0.625rem;
112
+ font: inherit;
113
+ font-size: 0.75rem;
114
+ font-weight: 600;
115
+ color: var(--enigma-toast-bg);
116
+ background: var(--enigma-toast-text);
117
+ border: 0;
118
+ border-radius: 0.375rem;
119
+ cursor: pointer;
120
+ }
121
+
122
+ [data-enigma-toast-close] {
123
+ position: absolute;
124
+ top: 0.375rem;
125
+ right: 0.5rem;
126
+ padding: 0 0.25rem;
127
+ font-size: 0.875rem;
128
+ line-height: 1;
129
+ color: var(--enigma-toast-muted);
130
+ background: none;
131
+ border: 0;
132
+ cursor: pointer;
133
+ /* Shown on hover or focus, so it never covers the message while it is being read - but
134
+ always reachable by keyboard, which `display: none` would take away. */
135
+ opacity: 0;
136
+ transition: opacity 120ms ease-out;
137
+ }
138
+
139
+ [data-enigma-toast]:hover [data-enigma-toast-close],
140
+ [data-enigma-toast-close]:focus-visible { opacity: 1; }
141
+
142
+ /* Enter from the edge it is pinned to, leave the same way. */
143
+ @keyframes enigma-toast-in-right { from { opacity: 0; translate: 100% 0; } to { opacity: 1; translate: 0 0; } }
144
+ @keyframes enigma-toast-in-left { from { opacity: 0; translate: -100% 0; } to { opacity: 1; translate: 0 0; } }
145
+ @keyframes enigma-toast-in-top { from { opacity: 0; translate: 0 -100%; } to { opacity: 1; translate: 0 0; } }
146
+ @keyframes enigma-toast-in-bottom { from { opacity: 0; translate: 0 100%; } to { opacity: 1; translate: 0 0; } }
147
+ @keyframes enigma-toast-out { to { opacity: 0; scale: 0.94; } }
148
+
149
+ [data-position$="-right"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-right 260ms cubic-bezier(0.22, 1, 0.36, 1); }
150
+ [data-position$="-left"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-left 260ms cubic-bezier(0.22, 1, 0.36, 1); }
151
+ [data-position="top-center"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-top 260ms cubic-bezier(0.22, 1, 0.36, 1); }
152
+ [data-position="bottom-center"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-bottom 260ms cubic-bezier(0.22, 1, 0.36, 1); }
153
+
154
+ /* Matches the component's exitDuration: the node is held for exactly this long, so a
155
+ longer animation would be cut off mid-way. */
156
+ [data-enigma-toast][data-state="leaving"] {
157
+ animation: enigma-toast-out var(--enigma-toast-exit) ease-in forwards;
158
+ pointer-events: none;
159
+ }
160
+
161
+ @media (prefers-reduced-motion: reduce) {
162
+ /* The movement goes, the state change stays: a toast still appears and still leaves. */
163
+ [data-enigma-toast] { animation: none !important; transition: opacity 120ms ease-out; }
164
+ [data-enigma-toast][data-state="leaving"] { opacity: 0; }
165
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { Toaster, type ToastPosition } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * Ember, in Tailwind. Yours to edit.
7
+ *
8
+ * The stack, the gestures and the exit hold come from `<Toaster />`; everything below is a
9
+ * class. The one thing Tailwind cannot express is the swipe offset - it is a live number
10
+ * from the pointer, published as `--enigma-toast-swipe` - so the arbitrary translate below
11
+ * reads that variable rather than trying to enumerate positions.
12
+ */
13
+
14
+ const TONE = {
15
+ info: "before:bg-blue-400",
16
+ success: "before:bg-green-400",
17
+ warning: "before:bg-amber-400",
18
+ error: "before:bg-red-400",
19
+ loading: "before:bg-neutral-500"
20
+ } as const;
21
+
22
+ export function AppToaster({ position = "bottom-right" }: { position?: ToastPosition; }) {
23
+ return (
24
+ <Toaster
25
+ position={position}
26
+ className="fixed z-[9999] flex w-[min(22rem,calc(100vw-2.5rem))] flex-col gap-2.5 data-[position^=top]:top-5 data-[position^=top]:flex-col-reverse data-[position^=bottom]:bottom-5 data-[position$=-left]:left-5 data-[position$=-right]:right-5 data-[position$=-center]:left-1/2 data-[position$=-center]:-translate-x-1/2 pointer-events-none"
27
+ >
28
+ {(notification, controls) => (
29
+ <div
30
+ className={[
31
+ "pointer-events-auto relative grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl border p-4",
32
+ "border-neutral-800 bg-neutral-950 text-neutral-100 shadow-[0_8px_30px_rgb(0_0_0/45%)]",
33
+ "before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:rounded-l-xl",
34
+ // Tracks the finger exactly while a swipe is in progress.
35
+ "translate-x-[var(--enigma-toast-swipe,0)] transition-transform duration-200 ease-out data-[swiping]:transition-none",
36
+ TONE[notification.tone]
37
+ ].join(" ")}
38
+ >
39
+ <p className="col-start-1 m-0 text-sm font-semibold leading-snug">{notification.title}</p>
40
+ {notification.body && <p className="col-start-1 m-0 text-[13px] leading-relaxed text-neutral-400">{notification.body}</p>}
41
+
42
+ {notification.action && (
43
+ <button
44
+ type="button"
45
+ className="col-start-2 row-span-full rounded-md bg-neutral-100 px-2.5 py-1.5 text-xs font-semibold text-neutral-950"
46
+ onClick={() => {
47
+ notification.action?.onSelect();
48
+ if (notification.action?.dismiss !== false) controls.dismiss();
49
+ }}
50
+ >{notification.action.label}</button>
51
+ )}
52
+
53
+ {/* Opacity rather than hidden: it must stay reachable by keyboard. */}
54
+ <button
55
+ type="button"
56
+ aria-label="Dismiss"
57
+ className="absolute right-2 top-1.5 px-1 text-sm leading-none text-neutral-400 opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
58
+ onClick={controls.dismiss}
59
+ >&times;</button>
60
+ </div>
61
+ )}
62
+ </Toaster>
63
+ );
64
+ }
package/registry.json CHANGED
@@ -354,6 +354,273 @@
354
354
  "[aria-busy=true]"
355
355
  ],
356
356
  "docs": "docs/notes/primitives.md#button"
357
+ },
358
+ {
359
+ "name": "notifications",
360
+ "title": "Notification queue",
361
+ "description": "Ordering, dedupe by key, sticky errors and dismiss timers that hold instead of running while the tab is hidden. No rendering.",
362
+ "targets": [
363
+ "vanilla",
364
+ "astro",
365
+ "react"
366
+ ],
367
+ "entry": {
368
+ "vanilla": "@enigmax/primitives",
369
+ "astro": "@enigmax/primitives",
370
+ "react": "@enigmax/primitives/react"
371
+ },
372
+ "exports": {
373
+ "vanilla": [
374
+ "createNotifications"
375
+ ],
376
+ "astro": [
377
+ "createNotifications"
378
+ ],
379
+ "react": [
380
+ "useNotifications"
381
+ ]
382
+ },
383
+ "files": [
384
+ {
385
+ "path": "src/core/notifications.ts",
386
+ "dest": "notifications/notifications.ts",
387
+ "targets": [
388
+ "vanilla",
389
+ "astro",
390
+ "react"
391
+ ]
392
+ },
393
+ {
394
+ "path": "src/react/use-notifications.ts",
395
+ "dest": "notifications/use-notifications.ts",
396
+ "targets": [
397
+ "react"
398
+ ],
399
+ "rewrite": {
400
+ "@/core/notifications": "./notifications"
401
+ },
402
+ "main": true
403
+ },
404
+ {
405
+ "path": "recipes/notifications/tailwind.tsx",
406
+ "dest": "notifications/Notifications.tsx",
407
+ "targets": [
408
+ "react"
409
+ ],
410
+ "style": "tailwind"
411
+ }
412
+ ],
413
+ "styles": false,
414
+ "docs": "docs/notes/primitives.md#notifications",
415
+ "recipes": [
416
+ "tailwind"
417
+ ]
418
+ },
419
+ {
420
+ "name": "toast",
421
+ "title": "Toast",
422
+ "description": "The notification queue with a renderer on top: stacking, swipe to dismiss, a pointer that stops the clock, and a toast that is still on screen while it animates out. Ships the Ember theme, which is a stylesheet you own.",
423
+ "targets": [
424
+ "react"
425
+ ],
426
+ "entry": {
427
+ "react": "@enigmax/primitives/react"
428
+ },
429
+ "exports": {
430
+ "react": [
431
+ "Toaster",
432
+ "useNotifications"
433
+ ]
434
+ },
435
+ "files": [
436
+ {
437
+ "path": "src/core/notifications.ts",
438
+ "dest": "toast/notifications.ts",
439
+ "targets": [
440
+ "react"
441
+ ]
442
+ },
443
+ {
444
+ "path": "src/react/use-notifications.ts",
445
+ "dest": "toast/use-notifications.ts",
446
+ "targets": [
447
+ "react"
448
+ ],
449
+ "rewrite": {
450
+ "@/core/notifications": "./notifications"
451
+ }
452
+ },
453
+ {
454
+ "path": "src/react/toaster.tsx",
455
+ "dest": "toast/index.tsx",
456
+ "main": true,
457
+ "targets": [
458
+ "react"
459
+ ],
460
+ "rewrite": {
461
+ "@/core/notifications": "./notifications",
462
+ "@/react/use-notifications": "./use-notifications"
463
+ }
464
+ },
465
+ {
466
+ "path": "recipes/toast/styles.css",
467
+ "dest": "toast/styles.css",
468
+ "targets": [
469
+ "react"
470
+ ],
471
+ "style": "css"
472
+ },
473
+ {
474
+ "path": "recipes/toast/tailwind.tsx",
475
+ "dest": "toast/AppToaster.tsx",
476
+ "targets": [
477
+ "react"
478
+ ],
479
+ "style": "tailwind",
480
+ "rewrite": {
481
+ "@enigmax/utils/react": "./index"
482
+ }
483
+ }
484
+ ],
485
+ "styles": false,
486
+ "themeHooks": [
487
+ "[data-enigma-toaster]",
488
+ "[data-enigma-toaster][data-position]",
489
+ "[data-enigma-toast]",
490
+ "[data-enigma-toast][data-tone=error]",
491
+ "[data-enigma-toast][data-state=leaving]",
492
+ "[data-enigma-toast][data-swiping]",
493
+ "[data-enigma-toast][data-front]",
494
+ "[data-enigma-toast-title]",
495
+ "[data-enigma-toast-body]",
496
+ "[data-enigma-toast-action]",
497
+ "[data-enigma-toast-close]"
498
+ ],
499
+ "docs": "docs/notes/primitives.md#toast",
500
+ "recipes": [
501
+ "tailwind",
502
+ "css"
503
+ ]
504
+ },
505
+ {
506
+ "name": "network",
507
+ "title": "Connection state",
508
+ "description": "Online/offline plus what the Network Information API reports, and the recovery transition every hand-rolled version re-derives: `recovered` is false on a first load, so a page that opens online never announces a comeback that did not happen.",
509
+ "targets": [
510
+ "vanilla",
511
+ "astro",
512
+ "react"
513
+ ],
514
+ "entry": {
515
+ "vanilla": "@enigmax/primitives",
516
+ "astro": "@enigmax/primitives",
517
+ "react": "@enigmax/primitives/react"
518
+ },
519
+ "exports": {
520
+ "vanilla": [
521
+ "createNetworkMonitor"
522
+ ],
523
+ "astro": [
524
+ "createNetworkMonitor"
525
+ ],
526
+ "react": [
527
+ "useNetworkState"
528
+ ]
529
+ },
530
+ "files": [
531
+ {
532
+ "path": "src/core/network.ts",
533
+ "dest": "network/network.ts",
534
+ "targets": [
535
+ "vanilla",
536
+ "astro",
537
+ "react"
538
+ ]
539
+ },
540
+ {
541
+ "path": "src/react/use-network-state.ts",
542
+ "dest": "network/use-network-state.ts",
543
+ "targets": [
544
+ "react"
545
+ ],
546
+ "rewrite": {
547
+ "@/core/network": "./network"
548
+ },
549
+ "main": true
550
+ }
551
+ ],
552
+ "styles": false,
553
+ "docs": "docs/notes/primitives.md#network"
554
+ },
555
+ {
556
+ "name": "relative-time",
557
+ "title": "Relative timestamps",
558
+ "description": "\"3 hours ago\", kept current, localised by the page's own language. A timestamp with no zone is read as UTC instead of as the server's local time, and the absolute date is rendered as the element's child so a server render and a reader without JavaScript still see a real date.",
559
+ "targets": [
560
+ "vanilla",
561
+ "astro",
562
+ "react"
563
+ ],
564
+ "entry": {
565
+ "vanilla": "@enigmax/primitives",
566
+ "astro": "@enigmax/primitives",
567
+ "react": "@enigmax/primitives/react"
568
+ },
569
+ "exports": {
570
+ "vanilla": [
571
+ "relativeTimeView",
572
+ "relativeTimeAttributes",
573
+ "normalizeDate"
574
+ ],
575
+ "astro": [
576
+ "relativeTimeView",
577
+ "relativeTimeAttributes",
578
+ "normalizeDate"
579
+ ],
580
+ "react": [
581
+ "RelativeTime"
582
+ ]
583
+ },
584
+ "files": [
585
+ {
586
+ "path": "src/core/relative-time.ts",
587
+ "dest": "relative-time/relative-time.ts",
588
+ "targets": [
589
+ "vanilla",
590
+ "astro",
591
+ "react"
592
+ ]
593
+ },
594
+ {
595
+ "path": "src/react/relative-time.ts",
596
+ "dest": "relative-time/index.ts",
597
+ "targets": [
598
+ "react"
599
+ ],
600
+ "rewrite": {
601
+ "@/core/relative-time": "./relative-time"
602
+ }
603
+ },
604
+ {
605
+ "path": "recipes/relative-time/styles.css",
606
+ "dest": "relative-time/styles.css",
607
+ "targets": [
608
+ "vanilla",
609
+ "astro",
610
+ "react"
611
+ ]
612
+ }
613
+ ],
614
+ "styles": false,
615
+ "themeHooks": [
616
+ "relative-time",
617
+ "[data-relative-time-capitalize=true]",
618
+ "[data-relative-time-capitalize-first-letter=true]"
619
+ ],
620
+ "docs": "docs/notes/primitives.md#relative-time",
621
+ "dependencies": {
622
+ "@github/relative-time-element": "^4.4.0"
623
+ }
357
624
  }
358
625
  ]
359
626
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Connection state: online/offline plus whatever the Network Information API will say.
3
+ *
4
+ * The part worth not rewriting is the TRANSITION. "You are back online" needs to know the
5
+ * connection had dropped, and every implementation of that ends up as a stray `wasOffline`
6
+ * boolean next to the effect, re-derived per screen and wrong the first time the page
7
+ * loads offline. It is tracked here instead.
8
+ */
9
+
10
+ export interface NetworkState {
11
+ online: boolean;
12
+ /** Estimated downlink in Mbps. Null where the browser does not report it. */
13
+ downlink: number | null;
14
+ downlinkMax: number | null;
15
+ /** "slow-2g" | "2g" | "3g" | "4g", or null. */
16
+ effectiveType: string | null;
17
+ /** Estimated round-trip time in ms. */
18
+ rtt: number | null;
19
+ /** The visitor asked for reduced data use. */
20
+ saveData: boolean | null;
21
+ /** "wifi" | "cellular" | "ethernet" | ..., or null. */
22
+ type: string | null;
23
+ /**
24
+ * The connection dropped earlier and is back. False on a first load, so a page that
25
+ * opens online never announces a recovery that did not happen.
26
+ */
27
+ recovered: boolean;
28
+ /** On a connection worth degrading for: 2g or slower, or data saver on. */
29
+ slow: boolean;
30
+ }
31
+
32
+ export interface NetworkMonitor {
33
+ readonly state: NetworkState;
34
+ subscribe(listener: (state: NetworkState) => void): () => void;
35
+ destroy(): void;
36
+ }
37
+
38
+ /** What a server render sees. Assuming offline would flash a warning on every page. */
39
+ export const SERVER_NETWORK_STATE: NetworkState = {
40
+ online: true,
41
+ downlink: null,
42
+ downlinkMax: null,
43
+ effectiveType: null,
44
+ rtt: null,
45
+ saveData: null,
46
+ type: null,
47
+ recovered: false,
48
+ slow: false
49
+ };
50
+
51
+ interface ConnectionLike extends EventTarget {
52
+ downlink?: number;
53
+ downlinkMax?: number;
54
+ effectiveType?: string;
55
+ rtt?: number;
56
+ saveData?: boolean;
57
+ type?: string;
58
+ }
59
+
60
+ /** Still prefixed in some browsers, and absent entirely in Safari. */
61
+ function connection(): ConnectionLike | null {
62
+ if (typeof navigator === "undefined") return null;
63
+ const nav = navigator as Navigator & {
64
+ connection?: ConnectionLike;
65
+ mozConnection?: ConnectionLike;
66
+ webkitConnection?: ConnectionLike;
67
+ };
68
+ return nav.connection ?? nav.mozConnection ?? nav.webkitConnection ?? null;
69
+ }
70
+
71
+ function shallowEqual(left: NetworkState, right: NetworkState): boolean {
72
+ for (const key of Object.keys(left) as (keyof NetworkState)[]) {
73
+ if (left[key] !== right[key]) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ export function createNetworkMonitor(): NetworkMonitor {
79
+ // Nothing to observe during a server render; the snapshot stands in.
80
+ if (typeof window === "undefined") {
81
+ return {
82
+ get state() { return SERVER_NETWORK_STATE; },
83
+ subscribe() { return () => { /* nothing changes */ }; },
84
+ destroy() { /* nothing to release */ }
85
+ };
86
+ }
87
+
88
+ const listeners = new Set<(state: NetworkState) => void>();
89
+ let hasBeenOffline = false;
90
+ let state = read();
91
+
92
+ function read(): NetworkState {
93
+ const link = connection();
94
+ const online = navigator.onLine;
95
+ const effectiveType = link?.effectiveType ?? null;
96
+ const saveData = link?.saveData ?? null;
97
+ return {
98
+ online,
99
+ downlink: link?.downlink ?? null,
100
+ downlinkMax: link?.downlinkMax ?? null,
101
+ effectiveType,
102
+ rtt: link?.rtt ?? null,
103
+ saveData,
104
+ type: link?.type ?? null,
105
+ recovered: online && hasBeenOffline,
106
+ slow: saveData === true || effectiveType === "2g" || effectiveType === "slow-2g"
107
+ };
108
+ }
109
+
110
+ function update(): void {
111
+ if (!navigator.onLine) hasBeenOffline = true;
112
+ const next = read();
113
+ // The connection object fires `change` for readings that did not change; an
114
+ // identical object would re-render every subscriber for nothing.
115
+ if (shallowEqual(state, next)) return;
116
+ state = next;
117
+ for (const listener of listeners) listener(state);
118
+ }
119
+
120
+ const link = connection();
121
+ window.addEventListener("online", update, { passive: true });
122
+ window.addEventListener("offline", update, { passive: true });
123
+ link?.addEventListener("change", update, { passive: true });
124
+
125
+ return {
126
+ get state() { return state; },
127
+ subscribe(listener) {
128
+ listeners.add(listener);
129
+ return () => { listeners.delete(listener); };
130
+ },
131
+ destroy() {
132
+ listeners.clear();
133
+ window.removeEventListener("online", update);
134
+ window.removeEventListener("offline", update);
135
+ link?.removeEventListener("change", update);
136
+ }
137
+ };
138
+ }