Skip to main content

max / quasi

4.0 KB · 98 lines History Blame Raw
1 // What a browser knows about a selection and a description cannot.
2 //
3 // The ticks on a screen are the host's until something submits them: a route
4 // answers a screen built from the store, and how many boxes the reader has
5 // checked since is not a fact the store has. That is why `Act::over` gathers
6 // the ticks at press time rather than the handler being told up front, and it
7 // is why the two things every bulk bar does -- say how many are selected, and
8 // refuse to act on none -- have to be said here.
9 //
10 // Nothing in this file knows what a row stands for. It reads two hooks the
11 // renderer emits, `.row-select` on a tick and `[data-over]` on a control that
12 // commits one, and it is the whole of the per-app selection plumbing this stack
13 // exists to delete.
14 (() => {
15 "use strict";
16
17 const TICK = ".row-select";
18 const COMMIT = "[data-over]";
19 // Where a control's own words are kept, so appending a count to them is not
20 // a one-way edit. Read back on every sync rather than the label being
21 // reparsed, which is how "Complete (3) (2)" happens.
22 const LABEL = "data-over-label";
23
24 /** Every control inside `root` that acts on the selection. */
25 const commits = (root) => root.querySelectorAll(COMMIT);
26
27 /** How many ticks are set, across the whole document. */
28 const chosen = () => document.querySelectorAll(`${TICK}:checked`).length;
29
30 /**
31 * A control's own words, remembered the first time they are seen.
32 *
33 * A button carries them as its text. A field group carries none of its own
34 * -- its words are the question inside it -- so it is left alone and only
35 * disabled.
36 */
37 const words = (control) => {
38 if (!control.hasAttribute(LABEL)) {
39 control.setAttribute(LABEL, control.textContent.trim());
40 }
41 return control.getAttribute(LABEL);
42 };
43
44 /**
45 * Disable the control, or the controls inside it.
46 *
47 * A commit act is a button and takes the attribute itself. A picker over a
48 * selection is a wrapper around a control makeover emitted, so the wrapper
49 * is not the thing a browser can disable and the input inside it is.
50 */
51 const inert = (control, off) => {
52 const inputs = control.querySelectorAll("input, select, textarea, button");
53 if (inputs.length === 0) {
54 control.toggleAttribute("disabled", off);
55 control.setAttribute("aria-disabled", String(off));
56 return;
57 }
58 for (const input of inputs) {
59 input.toggleAttribute("disabled", off);
60 }
61 };
62
63 const sync = () => {
64 const count = chosen();
65 for (const control of commits(document)) {
66 inert(control, count === 0);
67 // Only a control that says what it does gets the number
68 // appended. A picker's words are a question, and a question
69 // with a count after it reads as one more option.
70 if (control.tagName === "BUTTON") {
71 const label = words(control);
72 control.textContent = count === 0 ? label : `${label} (${count})`;
73 }
74 }
75 };
76
77 // Delegated, because the rows a tick lives on are replaced wholesale by
78 // every filter, sort and write on the screen. Binding to the boxes
79 // themselves would last until the first swap.
80 document.addEventListener("change", (event) => {
81 if (event.target instanceof Element && event.target.matches(TICK)) {
82 sync();
83 }
84 });
85
86 // A swap brings new rows, and the new rows arrive with whatever the server
87 // said about their ticks -- which after a bulk write is none of them. Both
88 // htmx's event and the initial parse land here.
89 // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same
90 // event `htmx:afterSettle`, and 4 renamed every event to phase:action.
91 document.addEventListener("htmx:after:settle", sync);
92 if (document.readyState === "loading") {
93 document.addEventListener("DOMContentLoaded", sync);
94 } else {
95 sync();
96 }
97 })();
98