Skip to main content

max / quasi

10.4 KB · 274 lines History Blame Raw
1 // A question answered N times, with the reader adding and removing the slots.
2 //
3 // `60d1753c`, ruled 2026-08-25: a repeating group enters the vocabulary and
4 // submits once. This is the browser's half of the third hard part -- the reader
5 // creates and destroys slots without a round trip on every one. The bytes are
6 // already in the document: the emitter writes a `template` holding one blank
7 // slot, and adding is cloning it.
8 //
9 // A round trip instead would re-render a form the reader is midway through,
10 // which is `a135f898`, and it would ask a route for an empty box.
11 //
12 // It reads four attributes the node emitter writes and nothing else:
13 // `data-repeat` names the question, `data-repeat-label` is what its slots are
14 // called, and `data-repeat-least` / `data-repeat-most` are the floor and the
15 // ceiling. All four are ordinary escaped attributes, so nothing here is a
16 // program built out of app text -- which is why this is a script rather than
17 // one of the emitted _hyperscript programs, whose one rule is that no program
18 // is written from text a user typed.
19 //
20 // A page that does not serve this file shows every slot the description
21 // offered, fillable and submittable, with two controls that do nothing.
22 (() => {
23 "use strict";
24
25 /** The question a fieldset repeats. */
26 const NAME = "data-repeat";
27 /** What one slot of it is called. */
28 const LABEL = "data-repeat-label";
29 /** The fewest slots that may stand. */
30 const LEAST = "data-repeat-least";
31 /** The most that may stand. Absent means no ceiling. */
32 const MOST = "data-repeat-most";
33 /** The box the slots sit in. */
34 const SLOTS = "data-repeat-slots";
35 /** One slot, and where in the order it currently sits. */
36 const AT = "data-repeat-at";
37 /** The control that adds one. */
38 const ADD = "data-repeat-add";
39 /** The control that takes one away. */
40 const REMOVE = "data-repeat-remove";
41 /** The control a question's slots come from, when they come from another. */
42 const FROM = "data-repeat-add-from";
43 /** A slot named by what is in it rather than by where it is. */
44 const NAMED = "data-repeat-named";
45
46 /** The attributes a rename has to follow the index through. */
47 const NAMED = ["name", "id", "for", "aria-describedby", "aria-labelledby"];
48
49 /** The group a control inside one belongs to. */
50 const groupOf = (element) => element.closest(`[${NAME}]`);
51
52 /** Where a group keeps its slots. */
53 const boxOf = (group) => group.querySelector(`[${SLOTS}]`);
54
55 /** The slots standing in a group, in document order. */
56 const slotsOf = (group) => [
57 ...(boxOf(group)?.querySelectorAll(`:scope > [${AT}]`) ?? []),
58 ];
59
60 /** A number written in an attribute, or a fallback when it is not there. */
61 const count = (group, attribute, fallback) => {
62 const written = group.getAttribute(attribute);
63 if (written === null) {
64 return fallback;
65 }
66 const value = Number.parseInt(written, 10);
67 return Number.isNaN(value) ? fallback : value;
68 };
69
70 /**
71 * Move one slot to a place in the order.
72 *
73 * The index is in the slot's own attribute, in every name it submits under
74 * and in the ordinal a person reads. All three are rewritten together, or a
75 * removed slot leaves the ones after it answering under names nobody asked
76 * for.
77 *
78 * The rename is a replacement of `question[old]` wherever it appears, which
79 * covers the derived ids beside the name itself -- `question[0]-hint`,
80 * `question[0]-error` -- without this having to know what the field
81 * emitter derives.
82 */
83 const renumber = (slot, question, label, to) => {
84 const from = slot.getAttribute(AT);
85 slot.setAttribute(AT, String(to));
86 if (from === null) {
87 return;
88 }
89 const was = `${question}[${from}]`;
90 const now = `${question}[${to}]`;
91 if (was !== now) {
92 for (const element of [slot, ...slot.querySelectorAll("*")]) {
93 for (const attribute of NAMED) {
94 const value = element.getAttribute(attribute);
95 if (value !== null && value.includes(was)) {
96 element.setAttribute(attribute, value.split(was).join(now));
97 }
98 }
99 }
100 }
101 // The visible ordinal, which `Repeat::ordinal` wrote as "<label> <n>"
102 // counting from one. Rewritten from the group's own label rather than
103 // by editing whatever text is in there, so a slot cannot end up named
104 // after the one it replaced.
105 // Unless the slot is named by what is in it, which is a queue's file.
106 // Renumbering over that would replace the useful name with a position
107 // the reader can already see.
108 if (label !== null && !slot.hasAttribute(NAMED)) {
109 const named = slot.querySelector("label[for]");
110 if (named !== null) {
111 named.textContent = `${label} ${to + 1}`;
112 }
113 }
114 };
115
116 /**
117 * Put a group's slots and its two controls back in agreement.
118 *
119 * Called after every change and on arrival, so a group the reader has not
120 * touched is in the same state as one they have.
121 */
122 const settle = (group) => {
123 const question = group.getAttribute(NAME) ?? "";
124 const label = group.getAttribute(LABEL);
125 const slots = slotsOf(group);
126 const least = count(group, LEAST, 0);
127 const most = count(group, MOST, Number.POSITIVE_INFINITY);
128 slots.forEach((slot, at) => renumber(slot, question, label, at));
129 for (const control of group.querySelectorAll(`[${REMOVE}]`)) {
130 control.disabled = slots.length <= least;
131 }
132 for (const control of group.querySelectorAll(`[${ADD}]`)) {
133 control.disabled = slots.length >= most;
134 }
135 };
136
137 /** Every group in the document, settled. */
138 const settleAll = () => {
139 for (const group of document.querySelectorAll(`[${NAME}]`)) {
140 settle(group);
141 }
142 };
143
144 /**
145 * Clone the blank slot onto the end, if the ceiling allows another.
146 *
147 * `focus` is false for a slot nobody asked for by pressing add: a picker
148 * that made seven of them would otherwise leave the caret in the last.
149 * Answers the new slot, or null when the ceiling refused it.
150 */
151 const add = (group, focus = true) => {
152 const blank = group.querySelector("template");
153 const box = boxOf(group);
154 if (blank === null || box === null) {
155 return;
156 }
157 const most = count(group, MOST, Number.POSITIVE_INFINITY);
158 if (slotsOf(group).length >= most) {
159 return;
160 }
161 const made = blank.content.cloneNode(true);
162 const slot = made.firstElementChild;
163 box.append(made);
164 settle(group);
165 if (focus) {
166 // The reader pressed add because they have something to type, so
167 // the caret goes where they meant it to. Nothing else in this file
168 // moves focus: a settle after a swap must not steal it.
169 slot?.querySelector("input, select, textarea")?.focus();
170 }
171 return slot ?? null;
172 };
173
174 /** Take one slot out, if the floor allows one fewer. */
175 const remove = (group, slot) => {
176 const least = count(group, LEAST, 0);
177 if (slotsOf(group).length <= least) {
178 return;
179 }
180 slot.remove();
181 settle(group);
182 };
183
184 /** The control a group's slots come from, by the name the group carries. */
185 const sourceOf = (group) => {
186 const name = group.getAttribute(FROM);
187 if (name === null) {
188 return null;
189 }
190 return (
191 document.getElementById(name) ??
192 document.querySelector(`[name="${CSS.escape(name)}"]`)
193 );
194 };
195
196 /**
197 * A slot per file the reader picked, for a question whose slots come from
198 * another control.
199 *
200 * What goes *in* the slot is not this file's business: only the control
201 * that made it knows what a file is called or what to guess for it. Each
202 * slot is announced as it arrives and whoever owns the surface fills it.
203 */
204 const took = (group, source) => {
205 const files = [...(source.files ?? [])];
206 for (const [at, file] of files.entries()) {
207 const slot = add(group, false);
208 if (slot === null) {
209 // The ceiling refused it, and a picker may not talk a question
210 // past its own most.
211 break;
212 }
213 slot.dispatchEvent(
214 new CustomEvent("quasi:repeat:took", {
215 bubbles: true,
216 detail: { slot, file, at, source },
217 }),
218 );
219 }
220 // Emptied so that picking the same file twice in a row is two slots
221 // rather than one silent no-op.
222 source.value = "";
223 };
224
225 document.addEventListener("change", (event) => {
226 const target = event.target;
227 if (!(target instanceof Element)) {
228 return;
229 }
230 for (const group of document.querySelectorAll(`[${FROM}]`)) {
231 if (sourceOf(group) === target) {
232 took(group, target);
233 }
234 }
235 });
236
237 // Delegated, so a group that arrives in a swap needs no wiring of its own.
238 document.addEventListener("click", (event) => {
239 const target = event.target;
240 if (!(target instanceof Element)) {
241 return;
242 }
243 const adder = target.closest(`[${ADD}]`);
244 if (adder !== null) {
245 const group = groupOf(adder);
246 if (group !== null) {
247 event.preventDefault();
248 add(group);
249 }
250 return;
251 }
252 const remover = target.closest(`[${REMOVE}]`);
253 if (remover === null) {
254 return;
255 }
256 const group = groupOf(remover);
257 const slot = remover.closest(`[${AT}]`);
258 if (group !== null && slot !== null) {
259 event.preventDefault();
260 remove(group, slot);
261 }
262 });
263
264 // A group that arrives mid-page settles like one that was parsed with the
265 // document. `htmx:after:settle` is htmx 4's name for it; 2.x spelled the
266 // same event `htmx:afterSettle`.
267 document.addEventListener("htmx:after:settle", settleAll);
268 if (document.readyState === "loading") {
269 document.addEventListener("DOMContentLoaded", settleAll);
270 } else {
271 settleAll();
272 }
273 })();
274