Skip to main content

max / makenotwork

7.9 KB · 205 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
42 /** The attributes a rename has to follow the index through. */
43 const NAMED = ["name", "id", "for", "aria-describedby", "aria-labelledby"];
44
45 /** The group a control inside one belongs to. */
46 const groupOf = (element) => element.closest(`[${NAME}]`);
47
48 /** Where a group keeps its slots. */
49 const boxOf = (group) => group.querySelector(`[${SLOTS}]`);
50
51 /** The slots standing in a group, in document order. */
52 const slotsOf = (group) => [
53 ...(boxOf(group)?.querySelectorAll(`:scope > [${AT}]`) ?? []),
54 ];
55
56 /** A number written in an attribute, or a fallback when it is not there. */
57 const count = (group, attribute, fallback) => {
58 const written = group.getAttribute(attribute);
59 if (written === null) {
60 return fallback;
61 }
62 const value = Number.parseInt(written, 10);
63 return Number.isNaN(value) ? fallback : value;
64 };
65
66 /**
67 * Move one slot to a place in the order.
68 *
69 * The index is in the slot's own attribute, in every name it submits under
70 * and in the ordinal a person reads. All three are rewritten together, or a
71 * removed slot leaves the ones after it answering under names nobody asked
72 * for.
73 *
74 * The rename is a replacement of `question[old]` wherever it appears, which
75 * covers the derived ids beside the name itself -- `question[0]-hint`,
76 * `question[0]-error` -- without this having to know what the field
77 * emitter derives.
78 */
79 const renumber = (slot, question, label, to) => {
80 const from = slot.getAttribute(AT);
81 slot.setAttribute(AT, String(to));
82 if (from === null) {
83 return;
84 }
85 const was = `${question}[${from}]`;
86 const now = `${question}[${to}]`;
87 if (was !== now) {
88 for (const element of [slot, ...slot.querySelectorAll("*")]) {
89 for (const attribute of NAMED) {
90 const value = element.getAttribute(attribute);
91 if (value !== null && value.includes(was)) {
92 element.setAttribute(attribute, value.split(was).join(now));
93 }
94 }
95 }
96 }
97 // The visible ordinal, which `Repeat::ordinal` wrote as "<label> <n>"
98 // counting from one. Rewritten from the group's own label rather than
99 // by editing whatever text is in there, so a slot cannot end up named
100 // after the one it replaced.
101 if (label !== null) {
102 const named = slot.querySelector("label[for]");
103 if (named !== null) {
104 named.textContent = `${label} ${to + 1}`;
105 }
106 }
107 };
108
109 /**
110 * Put a group's slots and its two controls back in agreement.
111 *
112 * Called after every change and on arrival, so a group the reader has not
113 * touched is in the same state as one they have.
114 */
115 const settle = (group) => {
116 const question = group.getAttribute(NAME) ?? "";
117 const label = group.getAttribute(LABEL);
118 const slots = slotsOf(group);
119 const least = count(group, LEAST, 0);
120 const most = count(group, MOST, Number.POSITIVE_INFINITY);
121 slots.forEach((slot, at) => renumber(slot, question, label, at));
122 for (const control of group.querySelectorAll(`[${REMOVE}]`)) {
123 control.disabled = slots.length <= least;
124 }
125 for (const control of group.querySelectorAll(`[${ADD}]`)) {
126 control.disabled = slots.length >= most;
127 }
128 };
129
130 /** Every group in the document, settled. */
131 const settleAll = () => {
132 for (const group of document.querySelectorAll(`[${NAME}]`)) {
133 settle(group);
134 }
135 };
136
137 /** Clone the blank slot onto the end, if the ceiling allows another. */
138 const add = (group) => {
139 const blank = group.querySelector("template");
140 const box = boxOf(group);
141 if (blank === null || box === null) {
142 return;
143 }
144 const most = count(group, MOST, Number.POSITIVE_INFINITY);
145 if (slotsOf(group).length >= most) {
146 return;
147 }
148 const made = blank.content.cloneNode(true);
149 const slot = made.firstElementChild;
150 box.append(made);
151 settle(group);
152 // The reader pressed add because they have something to type, so the
153 // caret goes where they meant it to. Nothing else in this file moves
154 // focus: a settle after a swap must not steal it.
155 slot?.querySelector("input, select, textarea")?.focus();
156 };
157
158 /** Take one slot out, if the floor allows one fewer. */
159 const remove = (group, slot) => {
160 const least = count(group, LEAST, 0);
161 if (slotsOf(group).length <= least) {
162 return;
163 }
164 slot.remove();
165 settle(group);
166 };
167
168 // Delegated, so a group that arrives in a swap needs no wiring of its own.
169 document.addEventListener("click", (event) => {
170 const target = event.target;
171 if (!(target instanceof Element)) {
172 return;
173 }
174 const adder = target.closest(`[${ADD}]`);
175 if (adder !== null) {
176 const group = groupOf(adder);
177 if (group !== null) {
178 event.preventDefault();
179 add(group);
180 }
181 return;
182 }
183 const remover = target.closest(`[${REMOVE}]`);
184 if (remover === null) {
185 return;
186 }
187 const group = groupOf(remover);
188 const slot = remover.closest(`[${AT}]`);
189 if (group !== null && slot !== null) {
190 event.preventDefault();
191 remove(group, slot);
192 }
193 });
194
195 // A group that arrives mid-page settles like one that was parsed with the
196 // document. `htmx:after:settle` is htmx 4's name for it; 2.x spelled the
197 // same event `htmx:afterSettle`.
198 document.addEventListener("htmx:after:settle", settleAll);
199 if (document.readyState === "loading") {
200 document.addEventListener("DOMContentLoaded", settleAll);
201 } else {
202 settleAll();
203 }
204 })();
205