Skip to main content

max / makenotwork

6.0 KB · 141 lines History Blame Raw
1 // The half of a wait that only the browser can see.
2 //
3 // The node emitter has written `data-awaiting` since the vocabulary gained
4 // `Awaiting`: `determinate` with a `data-awaiting-amount` beside it, or
5 // `indeterminate` alone. `makeover-webview` 0.60.0 draws both. Neither of them
6 // knows when a wait is actually running, and that is what this file supplies.
7 //
8 // Three things, and nothing else:
9 //
10 // 1. `aria-busy` on the element that made the request, for as long as the
11 // request is in flight. That is what the design system keys the mark off,
12 // and it is a standard attribute rather than a class of ours, so it is
13 // announced as well as drawn.
14 // 2. `--awaiting-share`, for a control whose wait has a measured size, from
15 // bytes actually observed arriving. Never from elapsed time: see below.
16 // 3. Nothing at all for a control that is not marked as awaiting, which is
17 // nearly all of them.
18 //
19 // # Why the attribute alone was not enough
20 //
21 // `data-awaiting` is a fact about the control -- pressing this waits -- and it
22 // is true when the page is painted and stays true. Styling it directly would
23 // put a blinking mark on every awaiting control on the screen from the moment
24 // the document loaded. The running-or-not half is true only between two events,
25 // which makes it the binder's, which is this.
26 //
27 // # What the share may not be
28 //
29 // Rule 1 of wiki `loading-and-progress-standard`, and `Awaiting`'s own docs
30 // before it: what is done over what there is, and never a remaining time, an
31 // arrival time, or a rate extrapolated forward. So the share is set from
32 // `event.loaded` -- bytes the browser says have landed -- and from nothing
33 // else. A control with no readable progress keeps a share of zero and draws an
34 // empty trough, which is the honest picture: the size is known and the delivery
35 // is not.
36 //
37 // The one thing this file must never grow is a timer that advances the share.
38 // A bar walking forward on a stalled transfer is the confidently-wrong drawing
39 // the rule exists to forbid, and it is the easiest thing in the world to add.
40 //
41 // # Degrading
42 //
43 // A page that does not serve this file draws no mark and no bar, and every
44 // control still locks itself through `hx-disable` exactly as before. That is
45 // the direction the rest of this renderer degrades in: less decoration, never
46 // less behaviour.
47 (() => {
48 "use strict";
49
50 /** The fact about the control: this call waits. */
51 const AWAITING = "data-awaiting";
52 /** The measured payload size, when the description carried one. */
53 const AMOUNT = "data-awaiting-amount";
54 /** How much of it has landed, as a share from 0 to 1. */
55 const SHARE = "--awaiting-share";
56
57 /**
58 * The element a request belongs to, or null when it is not our business.
59 *
60 * htmx names the element on the event detail. An event for something that
61 * never described a wait is left alone rather than given one: the emitter
62 * decides which calls are worth marking, and a renderer that marked every
63 * request would be overruling it.
64 */
65 const marked = (event) => {
66 const element = event.detail?.elt;
67 if (!element || typeof element.hasAttribute !== "function") {
68 return null;
69 }
70 return element.hasAttribute(AWAITING) ? element : null;
71 };
72
73 /** Say a wait has started, with nothing delivered yet. */
74 const begin = (event) => {
75 const element = marked(event);
76 if (!element) {
77 return;
78 }
79 element.setAttribute("aria-busy", "true");
80 element.style.removeProperty(SHARE);
81 };
82
83 /**
84 * Say the wait is over.
85 *
86 * The share is cleared with it. A control pressed twice would otherwise
87 * start its second wait wearing the first one's bar, which is a number
88 * about a payload that has already arrived.
89 */
90 const end = (event) => {
91 const element = marked(event);
92 if (!element) {
93 return;
94 }
95 element.removeAttribute("aria-busy");
96 element.style.removeProperty(SHARE);
97 };
98
99 /**
100 * What has landed, over what the description said there was.
101 *
102 * `event.loaded` when the browser is counting, against the described
103 * amount rather than against `event.total`. The two usually agree, and
104 * where they do not the description is the one the bar was drawn for: a
105 * server that gzips a response reports a total the screen never described.
106 *
107 * A response with no readable length leaves the share alone, so the trough
108 * stays empty rather than jumping to something invented.
109 */
110 const progressed = (event) => {
111 const element = marked(event);
112 if (!element || element.getAttribute(AWAITING) !== "determinate") {
113 return;
114 }
115 const amount = Number(element.getAttribute(AMOUNT));
116 const loaded = Number(event.detail?.loaded);
117 if (!Number.isFinite(amount) || amount <= 0 || !Number.isFinite(loaded)) {
118 return;
119 }
120 element.style.setProperty(SHARE, String(Math.min(loaded / amount, 1)));
121 };
122
123 // htmx 4 spells every event `phase:action`; 2.x spelled these
124 // `htmx:beforeRequest`, `htmx:afterRequest` and `htmx:xhr:progress`.
125 //
126 // Delegated on the document rather than bound per control, for the reason
127 // the reveal and repeat scripts give: a swap brings new controls and takes
128 // old ones away, and a listener on the document covers both without a
129 // re-scan.
130 document.addEventListener("htmx:before:request", begin);
131 document.addEventListener("htmx:after:request", end);
132 document.addEventListener("htmx:xhr:progress", progressed);
133
134 // A request that is aborted or errors out never reaches `after:request` in
135 // every htmx version, and a control left wearing `aria-busy` blinks for the
136 // rest of the page's life. Cheap insurance, and the same handler.
137 document.addEventListener("htmx:abort", end);
138 document.addEventListener("htmx:response:error", end);
139 document.addEventListener("htmx:send:error", end);
140 })();
141