@excom/network-status 0.1.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.
Files changed (34) hide show
  1. package/.rush/temp/chunked-rush-logs/network-status.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/network-status.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/network-status.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +5 -0
  15. package/index.ts +17 -0
  16. package/network-status.ts +215 -0
  17. package/package.json +42 -0
  18. package/rush-logs/network-status.apply-exports.cache.log +1 -0
  19. package/rush-logs/network-status.apply-exports.log +1 -0
  20. package/rush-logs/network-status.build_docs.cache.log +1 -0
  21. package/rush-logs/network-status.build_docs.log +1 -0
  22. package/rush-logs/network-status.build_package-metas.cache.log +1 -0
  23. package/rush-logs/network-status.build_package-metas.log +1 -0
  24. package/support/custom-elements.json +156 -0
  25. package/support/demos/offline-banner.html +6 -0
  26. package/support/demos/simple.html +5 -0
  27. package/support/dist-docs/network-status.md +125 -0
  28. package/support/docs/INTERNAL.md +12 -0
  29. package/support/docs/README.md +65 -0
  30. package/support/package-meta.json +103 -0
  31. package/support/tests/network-status.test.ts +401 -0
  32. package/support/tests/offline-banner.view.test.ts +20 -0
  33. package/support/tests/simple.view.test.ts +22 -0
  34. package/tsconfig.json +5 -0
@@ -0,0 +1,65 @@
1
+ # network-status
2
+
3
+ Live connectivity readout β€” `navigator.onLine` plus connection type and
4
+ speed where the browser exposes them.
5
+
6
+ <include-content data-demo="simple"></include-content>
7
+
8
+ ## Features
9
+
10
+ - **CSS-driven UI** Style from `[is-online]` / `[connection-type]`
11
+ - **Live events** `online` / `offline` / `change` fire as connectivity
12
+ shifts β€” on transitions, never at mount
13
+ - **Bindable state** `.provision` is the full snapshot; read it from Quark
14
+ with `prop("provision")`
15
+ - **Network Information API** Connection type + effective speed where
16
+ supported
17
+ - **Graceful degradation** Unsupported fields stay `null`; core
18
+ online/offline still works everywhere
19
+
20
+ ## Installation
21
+
22
+ <include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
23
+
24
+ ## Usage
25
+
26
+ Drop it anywhere and hook `[is-online]` / `[is-mounted]` with CSS, or
27
+ listen for its events from a parent.
28
+
29
+ ```html
30
+ <network-status></network-status>
31
+ ```
32
+
33
+ ```css
34
+ network-status:not([is-mounted])::before {
35
+ content: "Loading…";
36
+ }
37
+ network-status[is-online]::before {
38
+ content: "πŸ“Ά Online";
39
+ }
40
+ network-status[is-mounted]:not([is-online])::before {
41
+ content: "πŸ“΅ Offline";
42
+ }
43
+ ```
44
+
45
+ Nothing fires at mount: the first read sets `is-online` and `.provision`
46
+ only. React to the attribute (or `prop("provision")`) for the initial
47
+ state; `network-status-online` / `-offline` report transitions.
48
+
49
+ The Network Information API (`connection-type`, `effective-type`,
50
+ `downlink`, `rtt`) is Chromium-only β€” Safari and Firefox leave those
51
+ fields `null`. Build critical UX on `is-online` alone; treat the rest as
52
+ a progressive enhancement. Safari/iOS also have known `navigator.onLine`
53
+ reliability quirks β€” see `INTERNAL.md`.
54
+
55
+ ### API Reference
56
+
57
+ <include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
58
+
59
+ ### Examples
60
+
61
+ #### Offline banner
62
+
63
+ A banner shown purely by CSS attribute selector β€” no listeners needed.
64
+
65
+ <include-content data-demo="offline-banner"></include-content>
@@ -0,0 +1,103 @@
1
+ {
2
+ "shortName": "network-status",
3
+ "package": {
4
+ "name": "@excom/network-status",
5
+ "version": "0.1.0",
6
+ "description": "<network-status> custom element",
7
+ "peerDependencies": {},
8
+ "excom": {
9
+ "packageType": "kit-element"
10
+ }
11
+ },
12
+ "demos": {
13
+ "offline-banner": "<div>\n <network-status></network-status>\n <p role=\"alert\">You're offline β€” some features may be unavailable.</p>\n <small role=\"note\">Banner is CSS-only, driven by\n <code>[is-online]</code>. Go offline to see it appear.</small>\n</div>\n",
14
+ "simple": "<div>\n <network-status></network-status>\n <small role=\"note\">Live β€” reflects real connectivity. Toggle your\n devtools network throttling (or your device's wifi/data) to see it change.</small>\n</div>\n"
15
+ },
16
+ "readme": "<h1 id=\"md-network-status\">network-status</h1>\n<p>Live connectivity readout β€” <code>navigator.onLine</code> plus connection type and\nspeed where the browser exposes them.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>CSS-driven UI</strong> Style from <code>[is-online]</code> / <code>[connection-type]</code></li>\n<li><strong>Live events</strong> <code>online</code> / <code>offline</code> / <code>change</code> fire as connectivity\nshifts β€” on transitions, never at mount</li>\n<li><strong>Bindable state</strong> <code>.provision</code> is the full snapshot; read it from Quark\nwith <code>prop(&quot;provision&quot;)</code></li>\n<li><strong>Network Information API</strong> Connection type + effective speed where\nsupported</li>\n<li><strong>Graceful degradation</strong> Unsupported fields stay <code>null</code>; core\nonline/offline still works everywhere</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Drop it anywhere and hook <code>[is-online]</code> / <code>[is-mounted]</code> with CSS, or\nlisten for its events from a parent.</p>\n<include-content data-language=\"html\"><template>&lt;network-status&gt;&lt;/network-status&gt;</template></include-content>\n<include-content data-language=\"css\"><template>network-status:not([is-mounted])::before {\n content: \"Loading…\";\n}\nnetwork-status[is-online]::before {\n content: \"πŸ“Ά Online\";\n}\nnetwork-status[is-mounted]:not([is-online])::before {\n content: \"πŸ“΅ Offline\";\n}</template></include-content>\n<p>Nothing fires at mount: the first read sets <code>is-online</code> and <code>.provision</code>\nonly. React to the attribute (or <code>prop(&quot;provision&quot;)</code>) for the initial\nstate; <code>network-status-online</code> / <code>-offline</code> report transitions.</p>\n<p>The Network Information API (<code>connection-type</code>, <code>effective-type</code>,\n<code>downlink</code>, <code>rtt</code>) is Chromium-only β€” Safari and Firefox leave those\nfields <code>null</code>. Build critical UX on <code>is-online</code> alone; treat the rest as\na progressive enhancement. Safari/iOS also have known <code>navigator.onLine</code>\nreliability quirks β€” see <code>INTERNAL.md</code>.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-offline-banner\">Offline banner</h4>\n<p>A banner shown purely by CSS attribute selector β€” no listeners needed.</p>\n<p><include-content data-demo=\"offline-banner\"></include-content></p>\n",
17
+ "docs": {
18
+ "readme": "<h1 id=\"md-network-status\">network-status</h1>\n<p>Live connectivity readout β€” <code>navigator.onLine</code> plus connection type and\nspeed where the browser exposes them.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>CSS-driven UI</strong> Style from <code>[is-online]</code> / <code>[connection-type]</code></li>\n<li><strong>Live events</strong> <code>online</code> / <code>offline</code> / <code>change</code> fire as connectivity\nshifts β€” on transitions, never at mount</li>\n<li><strong>Bindable state</strong> <code>.provision</code> is the full snapshot; read it from Quark\nwith <code>prop(&quot;provision&quot;)</code></li>\n<li><strong>Network Information API</strong> Connection type + effective speed where\nsupported</li>\n<li><strong>Graceful degradation</strong> Unsupported fields stay <code>null</code>; core\nonline/offline still works everywhere</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Drop it anywhere and hook <code>[is-online]</code> / <code>[is-mounted]</code> with CSS, or\nlisten for its events from a parent.</p>\n<include-content data-language=\"html\"><template>&lt;network-status&gt;&lt;/network-status&gt;</template></include-content>\n<include-content data-language=\"css\"><template>network-status:not([is-mounted])::before {\n content: \"Loading…\";\n}\nnetwork-status[is-online]::before {\n content: \"πŸ“Ά Online\";\n}\nnetwork-status[is-mounted]:not([is-online])::before {\n content: \"πŸ“΅ Offline\";\n}</template></include-content>\n<p>Nothing fires at mount: the first read sets <code>is-online</code> and <code>.provision</code>\nonly. React to the attribute (or <code>prop(&quot;provision&quot;)</code>) for the initial\nstate; <code>network-status-online</code> / <code>-offline</code> report transitions.</p>\n<p>The Network Information API (<code>connection-type</code>, <code>effective-type</code>,\n<code>downlink</code>, <code>rtt</code>) is Chromium-only β€” Safari and Firefox leave those\nfields <code>null</code>. Build critical UX on <code>is-online</code> alone; treat the rest as\na progressive enhancement. Safari/iOS also have known <code>navigator.onLine</code>\nreliability quirks β€” see <code>INTERNAL.md</code>.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-offline-banner\">Offline banner</h4>\n<p>A banner shown purely by CSS attribute selector β€” no listeners needed.</p>\n<p><include-content data-demo=\"offline-banner\"></include-content></p>\n"
19
+ },
20
+ "installation": {
21
+ "name": "@excom/network-status",
22
+ "shortName": "network-status",
23
+ "version": "0.1.0",
24
+ "description": "<network-status> custom element",
25
+ "packageType": "kit-element",
26
+ "cdn": "<script src=\"https://unpkg.com/@excom/kit-utils/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/neutron/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/network-status@0.1.0/dist/index.umd.min.js\"></script>",
27
+ "install": {
28
+ "npm": "npm install @excom/network-status"
29
+ },
30
+ "imports": {
31
+ "js": "import \"@excom/network-status\";",
32
+ "html": "<!-- import path to `node_modules` will depend on your build setup -->\n<script type=\"module\" src=\"/node_modules/@excom/network-status\"></script>\n<link rel=\"stylesheet\" href=\"/node_modules/@excom/network-status\">"
33
+ },
34
+ "peerDependencies": []
35
+ },
36
+ "elementApis": [
37
+ {
38
+ "tag": "network-status",
39
+ "kind": "class",
40
+ "attributes": [
41
+ {
42
+ "name": "connection-type",
43
+ "type": "string",
44
+ "description": "Connection type (<code>wifi</code>, <code>cellular</code>, <code>ethernet</code>, …) from the Network Information API. <code>null</code> where unsupported (Safari, Firefox).",
45
+ "fieldName": "connectionType",
46
+ "surface": "state"
47
+ },
48
+ {
49
+ "name": "effective-type",
50
+ "type": "string",
51
+ "description": "Effective connection type (<code>4g</code>, <code>3g</code>, <code>2g</code>, <code>slow-2g</code>) from the Network Information API. <code>null</code> where unsupported (Safari, Firefox).",
52
+ "fieldName": "effectiveType",
53
+ "surface": "state"
54
+ },
55
+ {
56
+ "name": "is-online",
57
+ "type": "boolean",
58
+ "description": "Current <code>navigator.onLine</code> value.",
59
+ "fieldName": "isOnline",
60
+ "surface": "state"
61
+ }
62
+ ],
63
+ "events": [
64
+ {
65
+ "name": "network-status-change",
66
+ "description": "Dispatched when connection details change without an online/offline transition (Network Information API only), never at mount. <code>event.detail</code> is the full <code>.provision</code> payload.",
67
+ "type": "NetworkStatusChangeEvent",
68
+ "typeExpanded": "CustomEvent & { type: \"network-status-change\"; detail: { isOnline: boolean; connectionType?: string | null; effectiveType?: string | null; downlink?: number | null; rtt?: number | null; lastOnline?: number | null; lastOffline?: number | null; }; bubbles: true; cancelable: true; composed: true }"
69
+ },
70
+ {
71
+ "name": "network-status-offline",
72
+ "description": "Dispatched on a transition from online to offline, never at mount. <code>event.detail</code> is the full <code>.provision</code> payload.",
73
+ "type": "NetworkStatusOfflineEvent",
74
+ "typeExpanded": "CustomEvent & { type: \"network-status-offline\"; detail: { isOnline: boolean; connectionType?: string | null; effectiveType?: string | null; downlink?: number | null; rtt?: number | null; lastOnline?: number | null; lastOffline?: number | null; }; bubbles: true; cancelable: true; composed: true }"
75
+ },
76
+ {
77
+ "name": "network-status-online",
78
+ "description": "Dispatched on a transition from offline to online, never at mount. <code>event.detail</code> is the full <code>.provision</code> payload.",
79
+ "type": "NetworkStatusOnlineEvent",
80
+ "typeExpanded": "CustomEvent & { type: \"network-status-online\"; detail: { isOnline: boolean; connectionType?: string | null; effectiveType?: string | null; downlink?: number | null; rtt?: number | null; lastOnline?: number | null; lastOffline?: number | null; }; bubbles: true; cancelable: true; composed: true }"
81
+ }
82
+ ],
83
+ "slots": [],
84
+ "cssProperties": [],
85
+ "cssClasses": [],
86
+ "cssAliases": [],
87
+ "listens": [],
88
+ "commands": [],
89
+ "defaultActions": [],
90
+ "expectedChildren": [],
91
+ "provisions": [
92
+ {
93
+ "name": "provision",
94
+ "type": "NetworkStatusProvision",
95
+ "typeExpanded": "{ isOnline: boolean; connectionType?: string | null; effectiveType?: string | null; downlink?: number | null; rtt?: number | null; lastOnline?: number | null; lastOffline?: number | null; }",
96
+ "description": "Full connectivity snapshot: <code>{ isOnline, connectionType, effectiveType, downlink, rtt, lastOnline, lastOffline }</code>. Set at mount and on every change; the same object dispatched as <code>event.detail</code> on every fired event. <code>lastOnline</code> / <code>lastOffline</code> are stamped on transitions only. Not reflected as an attribute.",
97
+ "fieldName": "provision"
98
+ }
99
+ ]
100
+ }
101
+ ],
102
+ "exportedFiles": {}
103
+ }
@@ -0,0 +1,401 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ fixture,
9
+ it,
10
+ vi,
11
+ wait,
12
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
13
+ import "../../index";
14
+
15
+ const setOnLine = (value: boolean) =>
16
+ Object.defineProperty(navigator, "onLine", {
17
+ writable: true,
18
+ configurable: true,
19
+ value,
20
+ });
21
+
22
+ const createConnection = (fields: Record<string, unknown> = {}) => {
23
+ const connection = new EventTarget();
24
+ Object.assign(connection, {
25
+ type: "wifi",
26
+ effectiveType: "4g",
27
+ downlink: 10,
28
+ rtt: 50,
29
+ ...fields,
30
+ });
31
+ return connection as EventTarget & Record<string, any>;
32
+ };
33
+
34
+ const mount = () =>
35
+ fixture<HTMLNetworkStatusElement>(`<network-status></network-status>`);
36
+
37
+ describe("network-status", () => {
38
+ const originalConnection = Object.getOwnPropertyDescriptor(
39
+ navigator,
40
+ "connection",
41
+ );
42
+
43
+ const setConnection = (name: string, value: unknown) =>
44
+ Object.defineProperty(navigator, name, {
45
+ value,
46
+ writable: true,
47
+ configurable: true,
48
+ });
49
+
50
+ beforeAll(() => {
51
+ setOnLine(true);
52
+ });
53
+
54
+ beforeEach(() => {
55
+ setOnLine(true);
56
+ for (const name of ["connection", "mozConnection", "webkitConnection"]) {
57
+ setConnection(name, undefined);
58
+ }
59
+ });
60
+
61
+ afterEach(() => {
62
+ document.body.innerHTML = "";
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ afterAll(() => {
67
+ vi.clearAllMocks();
68
+ for (const name of ["connection", "mozConnection", "webkitConnection"]) {
69
+ delete (navigator as any)[name];
70
+ }
71
+ if (originalConnection) {
72
+ Object.defineProperty(navigator, "connection", originalConnection);
73
+ }
74
+ });
75
+
76
+ it("responds to online/offline/change events", () => {
77
+ const mockNetworkConnection = createConnection({ type: "cellular" });
78
+ setConnection("connection", mockNetworkConnection);
79
+
80
+ const networkStatus = mount();
81
+
82
+ expect(networkStatus).dom.to.equalTag(
83
+ `<network-status connection-type="cellular" effective-type="4g" is-mounted is-online></network-status>`,
84
+ );
85
+ expect(networkStatus.provision?.downlink).toBe(10);
86
+ expect(networkStatus.provision?.rtt).toBe(50);
87
+ // the first read is state, not a transition, no stamps yet
88
+ expect(networkStatus.provision?.lastOnline).toBeUndefined();
89
+ expect(networkStatus.provision?.lastOffline).toBeUndefined();
90
+
91
+ // Simulate going offline
92
+ setOnLine(false);
93
+ window.dispatchEvent(new Event("offline"));
94
+ expect(networkStatus).dom.to.equalTag(
95
+ `<network-status connection-type="cellular" effective-type="4g" is-mounted></network-status>`,
96
+ );
97
+ expect(networkStatus.provision?.downlink).toBe(10);
98
+ expect(networkStatus.provision?.rtt).toBe(50);
99
+ expect(networkStatus.provision?.lastOnline).toBeUndefined();
100
+ expect(networkStatus.provision?.lastOffline).toBeDefined();
101
+
102
+ // Simulate going online
103
+ setOnLine(true);
104
+ window.dispatchEvent(new Event("online"));
105
+ expect(networkStatus).dom.to.equalTag(
106
+ `<network-status connection-type="cellular" effective-type="4g" is-mounted is-online></network-status>`,
107
+ );
108
+ expect(networkStatus.provision?.downlink).toBe(10);
109
+ expect(networkStatus.provision?.rtt).toBe(50);
110
+ expect(networkStatus.provision?.lastOnline).toBeDefined();
111
+ expect(networkStatus.provision?.lastOffline).toBeDefined();
112
+
113
+ // Simulate network change
114
+ mockNetworkConnection.type = "wifi";
115
+ mockNetworkConnection.effectiveType = null;
116
+ mockNetworkConnection.downlink = 20;
117
+ mockNetworkConnection.rtt = 100;
118
+ mockNetworkConnection.dispatchEvent(new Event("change"));
119
+ expect(networkStatus).dom.to.equalTag(
120
+ `<network-status connection-type="wifi" is-mounted is-online></network-status>`,
121
+ );
122
+ expect(networkStatus.provision?.downlink).toBe(20);
123
+ expect(networkStatus.provision?.rtt).toBe(100);
124
+ expect(networkStatus.provision?.lastOnline).toBeDefined();
125
+ expect(networkStatus.provision?.lastOffline).toBeDefined();
126
+ });
127
+
128
+ describe("without the Network Information API", () => {
129
+ it("still reports online state and leaves connection fields unset", () => {
130
+ const el = mount();
131
+
132
+ expect(el).dom.to.equalTag(
133
+ `<network-status is-mounted is-online></network-status>`,
134
+ );
135
+ expect(el.provision).toEqual(
136
+ expect.objectContaining({
137
+ isOnline: true,
138
+ connectionType: undefined,
139
+ effectiveType: undefined,
140
+ downlink: undefined,
141
+ rtt: undefined,
142
+ }),
143
+ );
144
+ expect(el.provision?.lastOnline).toBeUndefined();
145
+ expect(el.provision?.lastOffline).toBeUndefined();
146
+ });
147
+
148
+ it("reports offline on connect when navigator.onLine is false", () => {
149
+ setOnLine(false);
150
+ const el = mount();
151
+
152
+ expect(el).dom.to.equalTag(
153
+ `<network-status is-mounted></network-status>`,
154
+ );
155
+ expect(el.provision?.isOnline).toBe(false);
156
+ expect(el.provision?.lastOnline).toBeUndefined();
157
+ expect(el.provision?.lastOffline).toBeUndefined();
158
+ });
159
+
160
+ it("does not emit at mount; emits network-status-online after offline β†’ online", () => {
161
+ const el = document.createElement("network-status");
162
+ const online = vi.fn();
163
+ const offline = vi.fn();
164
+ const change = vi.fn();
165
+ const provisionSpy = vi.fn();
166
+ el.addEventListener("network-status-online", online);
167
+ el.addEventListener("network-status-offline", offline);
168
+ el.addEventListener("network-status-change", change);
169
+ el.addEventListener("neutron-provision", provisionSpy);
170
+
171
+ document.body.append(el);
172
+
173
+ // state is set, but the first read is not a transition
174
+ expect(el.hasAttribute("is-online")).toBe(true);
175
+ expect(el.provision?.isOnline).toBe(true);
176
+ expect(provisionSpy).toHaveBeenCalledTimes(1);
177
+ expect(online).not.toHaveBeenCalled();
178
+ expect(offline).not.toHaveBeenCalled();
179
+ expect(change).not.toHaveBeenCalled();
180
+
181
+ setOnLine(false);
182
+ window.dispatchEvent(new Event("offline"));
183
+ expect(offline).toHaveBeenCalledTimes(1);
184
+ expect(online).not.toHaveBeenCalled();
185
+
186
+ setOnLine(true);
187
+ window.dispatchEvent(new Event("online"));
188
+ expect(online).toHaveBeenCalledTimes(1);
189
+ expect(online.mock.calls[0][0].detail.isOnline).toBe(true);
190
+ expect(change).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("reports offline on connect without emitting, then emits network-status-online", () => {
194
+ setOnLine(false);
195
+ const el = document.createElement("network-status");
196
+ const online = vi.fn();
197
+ const offline = vi.fn();
198
+ el.addEventListener("network-status-online", online);
199
+ el.addEventListener("network-status-offline", offline);
200
+
201
+ document.body.append(el);
202
+ expect(el.hasAttribute("is-online")).toBe(false);
203
+ expect(offline).not.toHaveBeenCalled();
204
+
205
+ setOnLine(true);
206
+ window.dispatchEvent(new Event("online"));
207
+ expect(online).toHaveBeenCalledTimes(1);
208
+ expect(offline).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it("transitions offline and online from window events", () => {
212
+ const el = mount();
213
+ const online = vi.fn();
214
+ const offline = vi.fn();
215
+ const change = vi.fn();
216
+ el.addEventListener("network-status-online", online);
217
+ el.addEventListener("network-status-offline", offline);
218
+ el.addEventListener("network-status-change", change);
219
+
220
+ setOnLine(false);
221
+ window.dispatchEvent(new Event("offline"));
222
+ expect(el.hasAttribute("is-online")).toBe(false);
223
+ expect(offline).toHaveBeenCalledTimes(1);
224
+ expect(offline.mock.calls[0][0].detail).toBe(el.provision);
225
+ expect(offline.mock.calls[0][0].detail.isOnline).toBe(false);
226
+
227
+ setOnLine(true);
228
+ window.dispatchEvent(new Event("online"));
229
+ expect(el.hasAttribute("is-online")).toBe(true);
230
+ expect(online).toHaveBeenCalledTimes(1);
231
+ expect(online.mock.calls[0][0].detail).toBe(el.provision);
232
+ expect(online.mock.calls[0][0].detail.isOnline).toBe(true);
233
+
234
+ expect(change).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it("preserves lastOnline / lastOffline across transitions", () => {
238
+ vi.spyOn(Date, "now").mockReturnValue(1000);
239
+ const el = mount();
240
+ // mount is not a transition, so nothing is stamped
241
+ expect(el.provision?.lastOnline).toBeUndefined();
242
+
243
+ vi.spyOn(Date, "now").mockReturnValue(2000);
244
+ setOnLine(false);
245
+ window.dispatchEvent(new Event("offline"));
246
+ expect(el.provision?.lastOnline).toBeUndefined();
247
+ expect(el.provision?.lastOffline).toBe(2000);
248
+
249
+ vi.spyOn(Date, "now").mockReturnValue(3000);
250
+ setOnLine(true);
251
+ window.dispatchEvent(new Event("online"));
252
+ expect(el.provision?.lastOnline).toBe(3000);
253
+ expect(el.provision?.lastOffline).toBe(2000);
254
+ });
255
+
256
+ it("emits network-status-change when online fires without a transition", () => {
257
+ const el = mount();
258
+ const online = vi.fn();
259
+ const change = vi.fn();
260
+ el.addEventListener("network-status-online", online);
261
+ el.addEventListener("network-status-change", change);
262
+
263
+ // already online, a second `online` is not a transition
264
+ window.dispatchEvent(new Event("online"));
265
+
266
+ expect(online).not.toHaveBeenCalled();
267
+ expect(change).toHaveBeenCalledTimes(1);
268
+ expect(el.hasAttribute("is-online")).toBe(true);
269
+ });
270
+ });
271
+
272
+ describe("Network Information API", () => {
273
+ it("reads type / effectiveType / downlink / rtt from navigator.connection", () => {
274
+ setConnection(
275
+ "connection",
276
+ createConnection({ type: "ethernet", effectiveType: "4g", downlink: 100, rtt: 5 }),
277
+ );
278
+ const el = mount();
279
+
280
+ expect(el).dom.to.equalTag(
281
+ `<network-status connection-type="ethernet" effective-type="4g" is-mounted is-online></network-status>`,
282
+ );
283
+ expect(el.provision).toEqual(
284
+ expect.objectContaining({ downlink: 100, rtt: 5 }),
285
+ );
286
+ });
287
+
288
+ it("falls back to navigator.mozConnection", () => {
289
+ setConnection("mozConnection", createConnection({ type: "cellular" }));
290
+ const el = mount();
291
+ expect(el.connectionType).toBe("cellular");
292
+ });
293
+
294
+ it("falls back to navigator.webkitConnection", () => {
295
+ setConnection("webkitConnection", createConnection({ type: "bluetooth" }));
296
+ const el = mount();
297
+ expect(el.connectionType).toBe("bluetooth");
298
+ });
299
+
300
+ it("emits network-status-change (not online/offline) on a connection change", () => {
301
+ const connection = createConnection({ effectiveType: "4g" });
302
+ setConnection("connection", connection);
303
+ const el = mount();
304
+ const online = vi.fn();
305
+ const offline = vi.fn();
306
+ const change = vi.fn();
307
+ el.addEventListener("network-status-online", online);
308
+ el.addEventListener("network-status-offline", offline);
309
+ el.addEventListener("network-status-change", change);
310
+
311
+ connection.effectiveType = "slow-2g";
312
+ connection.dispatchEvent(new Event("change"));
313
+
314
+ expect(change).toHaveBeenCalledTimes(1);
315
+ expect(change.mock.calls[0][0].detail.effectiveType).toBe("slow-2g");
316
+ expect(el.effectiveType).toBe("slow-2g");
317
+ expect(online).not.toHaveBeenCalled();
318
+ expect(offline).not.toHaveBeenCalled();
319
+ });
320
+
321
+ it("subscribes to the connection's change event and unsubscribes on disconnect", async () => {
322
+ const connection = createConnection();
323
+ const addSpy = vi.spyOn(connection, "addEventListener");
324
+ const removeSpy = vi.spyOn(connection, "removeEventListener");
325
+ setConnection("connection", connection);
326
+
327
+ const el = mount();
328
+ expect(addSpy).toHaveBeenCalledWith("change", expect.any(Function));
329
+ const handler = addSpy.mock.calls[0][1];
330
+
331
+ el.remove();
332
+ await wait(0);
333
+
334
+ expect(removeSpy).toHaveBeenCalledWith("change", handler);
335
+ expect(el.networkConnection).toBe(null);
336
+ });
337
+ });
338
+
339
+ describe("lifecycle", () => {
340
+ it("removes window listeners on disconnect and stops updating", async () => {
341
+ const removeSpy = vi.spyOn(window, "removeEventListener");
342
+ const el = mount();
343
+
344
+ el.remove();
345
+ await wait(0);
346
+
347
+ expect(removeSpy).toHaveBeenCalledWith("online", expect.any(Function));
348
+ expect(removeSpy).toHaveBeenCalledWith("offline", expect.any(Function));
349
+
350
+ setOnLine(false);
351
+ window.dispatchEvent(new Event("offline"));
352
+ expect(el.isOnline).toBe(true);
353
+ });
354
+
355
+ it("keeps its listeners when moved synchronously within the document", async () => {
356
+ const connection = createConnection();
357
+ const connectionRemove = vi.spyOn(connection, "removeEventListener");
358
+ const connectionAdd = vi.spyOn(connection, "addEventListener");
359
+ setConnection("connection", connection);
360
+ const windowRemove = vi.spyOn(window, "removeEventListener");
361
+
362
+ const el = mount();
363
+ const parent = el.parentElement!;
364
+ expect(connectionAdd).toHaveBeenCalledTimes(1);
365
+
366
+ el.remove();
367
+ parent.append(el);
368
+ await wait(0);
369
+
370
+ expect(windowRemove).not.toHaveBeenCalledWith(
371
+ "online",
372
+ expect.any(Function),
373
+ );
374
+ expect(connectionRemove).not.toHaveBeenCalled();
375
+ // not re-subscribed either
376
+ expect(connectionAdd).toHaveBeenCalledTimes(1);
377
+ expect(el.networkConnection).toBe(connection);
378
+
379
+ // still live after the move
380
+ setOnLine(false);
381
+ window.dispatchEvent(new Event("offline"));
382
+ expect(el.hasAttribute("is-online")).toBe(false);
383
+ });
384
+
385
+ it("re-subscribes when reconnected after a real disconnect", async () => {
386
+ const addSpy = vi.spyOn(window, "addEventListener");
387
+ const el = mount();
388
+ const onlineAdds = () =>
389
+ addSpy.mock.calls.filter(([type]) => type === "online").length;
390
+ expect(onlineAdds()).toBe(1);
391
+
392
+ el.remove();
393
+ await wait(0);
394
+ document.body.append(el);
395
+ await wait(0);
396
+
397
+ expect(onlineAdds()).toBe(2);
398
+ expect(el.hasAttribute("is-online")).toBe(true);
399
+ });
400
+ });
401
+ });
@@ -0,0 +1,20 @@
1
+ import "../../index";
2
+ import {
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+ import { mountView, readDemo } from "@excom/quark/support/tests/view-helpers";
9
+
10
+ describe("offline-banner view", () => {
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ it("renders the offline banner markup next to network-status", async () => {
16
+ const { root } = await mountView(readDemo(import.meta.url, "offline-banner"));
17
+ expect(root.querySelector("network-status")).toBeTruthy();
18
+ expect(root.querySelector("[role='alert']")?.textContent).toMatch(/offline/i);
19
+ });
20
+ });
@@ -0,0 +1,22 @@
1
+ import "../../index";
2
+ import {
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+ import { mountView, readDemo } from "@excom/quark/support/tests/view-helpers";
9
+
10
+ describe("simple view", () => {
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ it("reflects online status", async () => {
16
+ const { root } = await mountView(readDemo(import.meta.url, "simple"));
17
+ const el = root.querySelector("network-status")!;
18
+ expect(el.hasAttribute("is-online") || el.hasAttribute("is-offline")).toBe(
19
+ true,
20
+ );
21
+ });
22
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }