// Putting a chosen value into the box the reader was already typing in. // // `f35aafee`. An `Act` can name a field on the same screen that receives its // value, and this is the browser's half of "the renderer decides where in it // the value lands". Here that is the selection: a reader who has put the caret // in the middle of a paragraph and opened a picker means to insert there, and // that is what every editor they have ever used does. // // It reads two attributes the node emitter writes and nothing else: `data-fills` // names the field, `data-fill` carries the value. Both are ordinary escaped // attributes, so nothing here is a program built out of app text -- which is why // the behaviour is a script rather than one of the emitted _hyperscript // programs, whose one rule is that no program is written from text a user typed. // A media file called `"; alert(1); "` is a value in an attribute here and could // not be one in a program. // // What it does NOT do is call anything. The act's own action is emitted beside // this and htmx performs it as it would any other; an act that only fills says // so with `Destination::Local`, which emits no transport at all. (() => { "use strict"; /** The field the act writes into. */ const FIELD = "data-fills"; /** What lands there. */ const VALUE = "data-fill"; /** * The box on this document under that name. * * By `name` rather than by `id`, because `name` is what the description * carries: it is what a submit sends the value under and what `Field::writes` * names. An id is scoped per form instance by the emitter and is therefore * not the string the description wrote. * * Searched from the pressed control outward: the nearest enclosing form * first, then the document. A screen showing the same form twice -- an * edit modal over a list -- otherwise gets the first copy in the document * whichever one the reader is in. */ const box = (control, name) => { const selector = `[name="${CSS.escape(name)}"]`; return control.closest("form")?.querySelector(selector) ?? document.querySelector(selector); }; /** * Put `text` where the caret is, and leave the caret after it. * * `selectionStart` is null on an input whose type has no text selection -- * a colour or a date -- and on anything that is not a text control at all. * Appending is the honest fallback there: the value still arrives, which is * what the description asked for, and the position was never described. */ const insert = (target, text) => { const value = target.value ?? ""; const at = typeof target.selectionStart === "number" ? target.selectionStart : value.length; const stop = typeof target.selectionEnd === "number" ? target.selectionEnd : at; target.value = value.slice(0, at) + text + value.slice(stop); const after = at + text.length; try { target.setSelectionRange(after, after); } catch { // Same class of control as the null above: it holds a value and has // no selection to set. The text is in it either way. } // What a browser sends when a control's value settles, so an autosave // debounce, a word count and a `Field::writes` route all see this the // way they see typing. Without it the three surfaces this was measured // on would show the reference and save a document without it. target.dispatchEvent(new Event("input", { bubbles: true })); }; // One delegated listener rather than one per control. A picker draws a card // per media file and its markup arrives in a swap, so binding per element // would mean rebinding on every swap; the document has been listening the // whole time instead. document.addEventListener("click", (event) => { const control = event.target?.closest?.(`[${FIELD}]`); if (!control) { return; } const target = box(control, control.getAttribute(FIELD)); if (!target) { // A description naming a field that is not on the screen. Nothing // is written and nothing is broken: the act's own action still // runs, which is the same shape as a renderer ignoring a key it // does not know. return; } target.focus(); insert(target, control.getAttribute(VALUE) ?? ""); }); })();