Skip to main content

max / quasi

4.4 KB · 96 lines History Blame Raw
1 // Putting a chosen value into the box the reader was already typing in.
2 //
3 // `f35aafee`. An `Act` can name a field on the same screen that receives its
4 // value, and this is the browser's half of "the renderer decides where in it
5 // the value lands". Here that is the selection: a reader who has put the caret
6 // in the middle of a paragraph and opened a picker means to insert there, and
7 // that is what every editor they have ever used does.
8 //
9 // It reads two attributes the node emitter writes and nothing else: `data-fills`
10 // names the field, `data-fill` carries the value. Both are ordinary escaped
11 // attributes, so nothing here is a program built out of app text -- which is why
12 // the behaviour is a script rather than one of the emitted _hyperscript
13 // programs, whose one rule is that no program is written from text a user typed.
14 // A media file called `"; alert(1); "` is a value in an attribute here and could
15 // not be one in a program.
16 //
17 // What it does NOT do is call anything. The act's own action is emitted beside
18 // this and htmx performs it as it would any other; an act that only fills says
19 // so with `Destination::Local`, which emits no transport at all.
20 (() => {
21 "use strict";
22
23 /** The field the act writes into. */
24 const FIELD = "data-fills";
25 /** What lands there. */
26 const VALUE = "data-fill";
27
28 /**
29 * The box on this document under that name.
30 *
31 * By `name` rather than by `id`, because `name` is what the description
32 * carries: it is what a submit sends the value under and what `Field::writes`
33 * names. An id is scoped per form instance by the emitter and is therefore
34 * not the string the description wrote.
35 *
36 * Searched from the pressed control outward: the nearest enclosing form
37 * first, then the document. A screen showing the same form twice -- an
38 * edit modal over a list -- otherwise gets the first copy in the document
39 * whichever one the reader is in.
40 */
41 const box = (control, name) => {
42 const selector = `[name="${CSS.escape(name)}"]`;
43 return control.closest("form")?.querySelector(selector)
44 ?? document.querySelector(selector);
45 };
46
47 /**
48 * Put `text` where the caret is, and leave the caret after it.
49 *
50 * `selectionStart` is null on an input whose type has no text selection --
51 * a colour or a date -- and on anything that is not a text control at all.
52 * Appending is the honest fallback there: the value still arrives, which is
53 * what the description asked for, and the position was never described.
54 */
55 const insert = (target, text) => {
56 const value = target.value ?? "";
57 const at = typeof target.selectionStart === "number" ? target.selectionStart : value.length;
58 const stop = typeof target.selectionEnd === "number" ? target.selectionEnd : at;
59
60 target.value = value.slice(0, at) + text + value.slice(stop);
61 const after = at + text.length;
62 try {
63 target.setSelectionRange(after, after);
64 } catch {
65 // Same class of control as the null above: it holds a value and has
66 // no selection to set. The text is in it either way.
67 }
68 // What a browser sends when a control's value settles, so an autosave
69 // debounce, a word count and a `Field::writes` route all see this the
70 // way they see typing. Without it the three surfaces this was measured
71 // on would show the reference and save a document without it.
72 target.dispatchEvent(new Event("input", { bubbles: true }));
73 };
74
75 // One delegated listener rather than one per control. A picker draws a card
76 // per media file and its markup arrives in a swap, so binding per element
77 // would mean rebinding on every swap; the document has been listening the
78 // whole time instead.
79 document.addEventListener("click", (event) => {
80 const control = event.target?.closest?.(`[${FIELD}]`);
81 if (!control) {
82 return;
83 }
84 const target = box(control, control.getAttribute(FIELD));
85 if (!target) {
86 // A description naming a field that is not on the screen. Nothing
87 // is written and nothing is broken: the act's own action still
88 // runs, which is the same shape as a renderer ignoring a key it
89 // does not know.
90 return;
91 }
92 target.focus();
93 insert(target, control.getAttribute(VALUE) ?? "");
94 });
95 })();
96