Skip to main content

max / quasi

5.3 KB · 124 lines History Blame Raw
1 // A region that is out only while a control holds a value.
2 //
3 // `079a011e`, ruled 2026-08-25: the condition lives on the region. A region
4 // names the control it watches and the value that brings it out, and every
5 // renderer answers it from what it already has. This is the browser's half, and
6 // it makes no request: the bytes are in the document, and a round trip to
7 // reveal a section of a form the reader is midway through would re-render boxes
8 // holding values they have not committed yet.
9 //
10 // It reads three attributes the node emitter writes and nothing else:
11 // `data-reveal` names the control, `data-reveal-when` says whether the region
12 // wants anything, nothing or one of a set of values, and `data-reveal-values`
13 // carries that set as JSON. All three are ordinary escaped attributes, so
14 // nothing here is a program built out of app text -- which is why the behaviour
15 // is a script rather than one of the emitted _hyperscript programs, whose one
16 // rule is that no program is written from text a user typed.
17 //
18 // A page that does not serve this file shows every conditional region, which is
19 // the direction everything else in this renderer degrades in: more content
20 // rather than less. The six hand-written toggles this replaces fail the other
21 // way, since their markup starts hidden.
22 (() => {
23 "use strict";
24
25 /** The control a region watches. */
26 const CONTROL = "data-reveal";
27 /** What that control has to hold: `any`, `none` or `value`. */
28 const WHEN = "data-reveal-when";
29 /** The values `value` accepts, as a JSON array. */
30 const VALUES = "data-reveal-values";
31
32 /**
33 * What the named control is holding now, or null when it holds nothing.
34 *
35 * By `name` rather than by `id`, because `name` is what the description
36 * carries: it is what a submit sends the value under and what a region
37 * names. An id is scoped per form instance by the emitter and is therefore
38 * not the string the description wrote.
39 *
40 * Searched from the region outward: the nearest enclosing form first, then
41 * the document, which is `fill.js`'s rule and is here for the same reason.
42 * A screen showing the same form twice -- an edit modal over a list --
43 * otherwise reads the first copy in the document whichever one the reader
44 * is in.
45 *
46 * A tick is there by presence, the way a form submits one, so an unticked
47 * box and an empty box are one answer. That is the convention the router
48 * states and the other two renderers hold.
49 */
50 const held = (region, name) => {
51 const selector = `[name="${CSS.escape(name)}"]`;
52 const scope = region.closest("form") ?? document;
53 const controls = scope.querySelectorAll(selector);
54 let value = null;
55 for (const control of controls) {
56 if (control.type === "checkbox" || control.type === "radio") {
57 if (control.checked) {
58 value = control.value === "" ? "on" : control.value;
59 }
60 continue;
61 }
62 value = control.value ?? null;
63 }
64 return value === "" ? null : value;
65 };
66
67 /** Whether a region asking for this is satisfied by what is held. */
68 const satisfied = (region, value) => {
69 const when = region.getAttribute(WHEN);
70 if (when === "any") {
71 return value !== null;
72 }
73 if (when === "none") {
74 return value === null;
75 }
76 if (value === null) {
77 return false;
78 }
79 let wanted;
80 try {
81 wanted = JSON.parse(region.getAttribute(VALUES) ?? "[]");
82 } catch {
83 // An attribute this file did not write. Nothing is revealed on a
84 // condition nobody can read, which is the same answer a region
85 // watching a control that is not on the screen gets.
86 return false;
87 }
88 return Array.isArray(wanted) && wanted.includes(value);
89 };
90
91 /**
92 * Put every conditional region where its condition says it belongs.
93 *
94 * A walk over the document rather than over a list captured when the page
95 * loaded, for the reason the clock gives: a swap brings new regions and
96 * takes old ones away, and both are found by the next pass.
97 */
98 const settle = () => {
99 for (const region of document.querySelectorAll(`[${CONTROL}]`)) {
100 const value = held(region, region.getAttribute(CONTROL) ?? "");
101 region.hidden = !satisfied(region, value);
102 }
103 };
104
105 // Delegated, and on both events a control settles under: `change` is what a
106 // checkbox and a select fire, `input` is what a box being typed into fires,
107 // and a region may watch either.
108 document.addEventListener("change", settle);
109 document.addEventListener("input", settle);
110
111 // A region that arrives mid-page should not wait for the reader to touch
112 // something before it decides whether it applies. htmx's event and the
113 // initial parse both land here.
114 //
115 // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same event
116 // `htmx:afterSettle`, and 4 renamed every event to phase:action.
117 document.addEventListener("htmx:after:settle", settle);
118 if (document.readyState === "loading") {
119 document.addEventListener("DOMContentLoaded", settle);
120 } else {
121 settle();
122 }
123 })();
124