Skip to main content

max / quasi

17.8 KB · 400 lines History Blame Raw
1 //! Local interaction, emitted.
2 //!
3 //! _hyperscript was as the way this renderer performs what happens without a
4 //! request, and nothing emitted one for eleven days. This is the adoption
5 //! landing: every program in this file is written by the emitter from a
6 //! description, and none of it is hand-written into a template anywhere.
7 //!
8 //! # Why an attribute is allowed to hold a program
9 //!
10 //! The test is provenance rather than syntax: emitted is fine, hand-written is
11 //! not. What the rule defends against is a script nobody remembers is running,
12 //! and emitted code cannot become that -- it regenerates from this file every
13 //! time a screen is drawn, so deleting the source deletes every copy of it.
14 //!
15 //! The adoption carries one rule with it and this file keeps it: **never
16 //! hyperscript's `js` feature**. It is the only path in the interpreter that
17 //! reaches `new Function`, and `script-src 'self'` without `unsafe-eval` is
18 //! what MNW serves. Nothing here emits a `js` block, and nothing here builds a
19 //! program out of text a user typed.
20 //!
21 //! # What is emitted, and where the behaviour is otherwise
22 //!
23 //! One case today: the chrome of a region that shows its children one at a
24 //! time. Moving the frame is the definition of local: the bytes are in the
25 //! document, and a round trip to reveal them is worse on every gallery this
26 //! was measured against.
27 //!
28 //! Two local behaviours are shipped as scripts rather than as programs, and
29 //! both for one reason: they are built out of app text. `fill.js` carries the
30 //! value an act deposits and `reveal.js` the values a region watches for, and
31 //! this file's standing rule is that no program is written from text a user
32 //! typed. As escaped attributes those values are data.
33 //!
34 //! [`Destination::Local`](quasi_router::Destination::Local) is the other half
35 //! and is still unemitted, for the reason `crate::CLASS` gives: what happens
36 //! locally is named by the member carrying the action, and no member in the
37 //! vocabulary names one yet. When one lands, its program belongs here.
38 //!
39 //! # The handle gate
40 //!
41 //! Every program addresses its region by id, inside a CSS attribute selector,
42 //! inside an HTML attribute, inside a language with `${}` interpolation. A
43 //! region id is app text, so building a program out of one that is not a plain
44 //! handle would be this file writing code from arbitrary input, which is where
45 //! injection lives. [`handle`] is the gate: a plain handle gets a program, and
46 //! anything else gets the `data-shows` mark and no behaviour, which is a
47 //! narrow fallback rather than a program built out of arbitrary text.
48
49 use makeover_webview::form::escape_into;
50 use std::fmt::Write as _;
51
52 /// The id as something a selector can hold, if it is one.
53 ///
54 /// Letters, digits, `-` and `_`, and not empty. Deliberately narrower than what
55 /// CSS can escape: a hex escape (`\24 ` for `$`) is valid CSS and is read by
56 /// browsers, and it is not read by every selector engine, so a program built on
57 /// one would work in the places it was tried and fail somewhere else. Every
58 /// region id in the tree is a handle already.
59 pub(crate) fn handle(id: &str) -> Option<&str> {
60 let plain = !id.is_empty()
61 && id
62 .bytes()
63 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
64 plain.then_some(id)
65 }
66
67 /// Write ` _="<program>"`, escaped.
68 ///
69 /// One place, so the escaping cannot be forgotten at a call site. The program
70 /// is this crate's own text plus a handle and some integers, so the escaping is
71 /// discipline rather than a guard: it costs a scan and it means the emitter
72 /// never has a string that reaches an attribute unescaped.
73 fn program(source: &str, out: &mut String) {
74 out.push_str(" _=\"");
75 escape_into(source, out);
76 out.push('"');
77 }
78
79 /// Wherever the reader came from.
80 ///
81 /// [`Destination::Back`] says the address is the host's, and in a browser the
82 /// host holding it is the browser: the runtime's history and the document's
83 /// are the same list here, because every place this renderer reaches was a
84 /// navigation htmx pushed.
85 ///
86 /// `call history.back()` and not a `js` block. The interpreter evaluates a
87 /// method call on a global without reaching `new Function`, which is the one
88 /// path this module's header forbids, so this runs under `script-src 'self'`
89 /// with no `unsafe-eval`.
90 ///
91 /// # Why the gesture is a parameter
92 ///
93 /// A visible control is pressed and a [`Chrome`] binding is not: its button is
94 /// `hidden` and never focused, so it is never clicked. Every other destination
95 /// gets its gesture from htmx, which fires the request itself; this one is
96 /// performed here, so the gesture has to be stated here too. Handing back a
97 /// program that only listens for a click would make every bound key silently
98 /// do nothing, which is the failure this whole variant exists to end.
99 ///
100 /// `from window` for the same reason `Fires::Key` writes `from:body`: the
101 /// element cannot receive a keystroke it can never be focused for.
102 ///
103 /// # What is interpolated, and why it is allowed to be
104 ///
105 /// The filter, and only the filter. It is not app text arriving whole: the
106 /// modifiers are literals chosen in `chrome::trigger_filter`, and the base is
107 /// either a single character run through `js_string` or a name from the closed
108 /// list in `chrome::named_key`. That is the same discipline the handle gate
109 /// applies to a region id -- a value this crate validated rather than one it
110 /// was handed -- so no second gate is needed.
111 ///
112 /// [`Chrome`]: quasi_router::Chrome
113 /// [`Destination::Back`]: quasi_router::Destination::Back
114 pub(crate) fn back(key: Option<&str>, out: &mut String) {
115 match key {
116 Some(filter) => program(
117 &format!("on keydown[{filter}] from window call history.back()"),
118 out,
119 ),
120 None => program("on click call history.back()", out),
121 }
122 }
123
124 /// A tab, which shows the frame it names and takes the strip's marks with it.
125 ///
126 /// Three things move together, because a tab strip says the same fact three
127 /// ways: the frame that is current, the button that is chosen, and
128 /// `aria-selected`, which is what a screen reader hears and what nothing in a
129 /// stylesheet can stand in for.
130 ///
131 /// `take .chosen` with no `from` takes it from the pressed control's siblings,
132 /// which is every button in the strip and nothing else. The `aria-selected`
133 /// half needs the loop because `take` moves a class and not an attribute value.
134 pub(crate) fn tab(handle: &str, at: usize, out: &mut String) {
135 program(
136 &format!(
137 "on click take .chosen \
138 then for tab in <[data-shows]/> in the closest <[role='tablist']/> \
139 set @aria-selected of tab to 'false' end \
140 then set @aria-selected of me to 'true' \
141 then take .current from <[data-frame='{handle}']/> \
142 for the first <[data-frame='{handle}'][data-shown='{at}']/>"
143 ),
144 out,
145 );
146 }
147
148 /// A summary line, which opens and closes the one child under it.
149 ///
150 /// The only control here that toggles rather than picks: a dismissible region
151 /// with one named child is a disclosure, and pressing it again is how a reader
152 /// closes it. `aria-expanded` is read back off the frame rather than tracked,
153 /// so the attribute cannot drift from what the page is showing.
154 pub(crate) fn disclosure(handle: &str, out: &mut String) {
155 program(
156 &format!(
157 "on click toggle .current on the first <[data-frame='{handle}']/> \
158 then if the first <[data-frame='{handle}']/> matches '.current' \
159 set @aria-expanded of me to 'true' \
160 else set @aria-expanded of me to 'false' end"
161 ),
162 out,
163 );
164 }
165
166 /// Which way a step control moves.
167 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
168 pub(crate) enum Step {
169 /// The frame before this one, wrapping to the last.
170 Back,
171 /// The frame after this one, wrapping to the first.
172 Forward,
173 }
174
175 /// Previous or next, plus the counter beside them.
176 ///
177 /// The index is read off the frame that is current rather than held anywhere,
178 /// so a swap that brings new frames is stepped from what arrived and not from
179 /// what was here before. `(((at + step) + total) mod total)` rather than a
180 /// comparison: the `+ total` is what makes the backwards step wrap instead of
181 /// going negative, which JavaScript's `%` would not do on its own.
182 ///
183 /// A region showing nothing -- a dismissible one that is closed -- has no frame
184 /// to read, and the fallback index is picked so that the wrap lands where a
185 /// reader would expect: forward opens the first frame, back opens the last.
186 ///
187 /// The counter is written here because it is the same gesture. A carousel whose
188 /// position says `1 / 3` after three presses is a carousel saying something
189 /// false, and there is nothing else awake to correct it.
190 pub(crate) fn step(handle: &str, step: Step, total: usize, out: &mut String) {
191 // Nothing to step through, and `mod 0` is not a number. A region with no
192 // frames still draws its counter row, which is the empty gallery three MNW
193 // pages have whenever the creator uploaded nothing.
194 if total == 0 {
195 return;
196 }
197 let (by, closed) = match step {
198 // `total` and not `-1`: from closed, one step back off the end lands on
199 // the last frame, which is `total - 1` after the wrap.
200 Step::Back => ("- 1", total.to_string()),
201 Step::Forward => ("+ 1", "-1".to_owned()),
202 };
203 program(
204 &format!(
205 "on click set frames to <[data-frame='{handle}']/> \
206 then set shown to the first <[data-frame='{handle}'].current/> \
207 then if no shown set at to {closed} else set at to (@data-shown of shown) as Int end \
208 then set there to (((at {by}) + {total}) mod {total}) \
209 then take .current from frames \
210 for the first <[data-frame='{handle}'][data-shown='${{there}}']/> \
211 then put ((there + 1) + ' / {total}') into the first <[data-position='{handle}']/>"
212 ),
213 out,
214 );
215 }
216
217 /// A field that owns a suggestion list: the keyboard half, on the box itself.
218 ///
219 /// And the first [`Destination::Local`] this file has had a program to write
220 /// for. Ownership is what makes it writable: the list belongs to the field, so
221 /// moving the highlight and picking an entry both know which box they are
222 /// about without a description saying so.
223 ///
224 /// Four handlers, one gesture each, and every one of them reads the state back
225 /// out of the document rather than holding it. Which entry is current is a
226 /// class on that entry, and `aria-activedescendant` on the box is the same fact
227 /// said to a screen reader — the pair `tab` keeps in step for the same reason,
228 /// because nothing in a stylesheet stands in for what a reader hears.
229 ///
230 /// The wrap is `step`'s arithmetic and for its reason: `+ length` is what makes
231 /// a step off the top land on the bottom rather than on a negative index. An
232 /// empty list is not a list to move through, so both arrows exit and leave the
233 /// box alone.
234 ///
235 /// # What picking does, and what it deliberately does not
236 ///
237 /// It writes [`Choice::value`] into the box, closes the list, and sends
238 /// `change` — which is what a browser sends when a control's value settles, so
239 /// a [`Field::writes`] route fires exactly as it would have if the value had
240 /// been typed and left. What it does not send is `keyup`: the questions a field
241 /// asks *while it is being typed into* are not questions a pick asks, and a
242 /// synthetic keystroke would re-ask this very route about the value it just
243 /// answered.
244 ///
245 /// # Enter reaches a candidate that acts, rather than emptying the box
246 ///
247 /// A candidate carrying [`Candidate::picks`] has no `data-value`, since nothing
248 /// is written when picking performs a call. Writing one anyway put `null` in
249 /// the box and the call never went out, so the keyboard could not take the two
250 /// rows the member exists for: a tag that adds a facet, and a search hit that
251 /// navigates. Enter presses the row instead — `click()` and not a dispatched
252 /// event, because only the method runs an anchor's activation behaviour, and
253 /// the row is an anchor exactly when its pick replaces the document.
254 ///
255 /// [`Candidate::picks`]: quasi_router::Candidate::picks
256 ///
257 /// [`Destination::Local`]: quasi_router::Destination::Local
258 /// [`Choice::value`]: quasi_router::Choice::value
259 /// [`Field::writes`]: quasi_router::Field::writes
260 pub(crate) fn combobox(list: &str, out: &mut String) {
261 let mut source = String::new();
262 for (key, from, by) in [
263 // The index a step starts from when nothing is current yet, picked so
264 // the first press lands where a reader expects: down opens on the
265 // first entry, up on the last.
266 ("ArrowDown", "-1", "+ 1"),
267 ("ArrowUp", "0", "- 1"),
268 ] {
269 let _ = write!(
270 source,
271 "on keydown[key=='{key}'] \
272 set opts to <#{list} [role='option']/> \
273 then if no opts then exit end \
274 then halt the event \
275 then set cur to the first <#{list} [role='option'][aria-selected='true']/> \
276 then if no cur then set at to {from} else set at to (@data-at of cur) as Int end \
277 then set there to (((at {by}) + (the length of opts)) mod (the length of opts)) \
278 then for opt in opts set @aria-selected of opt to 'false' end \
279 then set @aria-selected of (opts[there]) to 'true' \
280 then set @aria-activedescendant of me to (@id of opts[there]) \
281 end "
282 );
283 }
284 // Enter is the one gesture that has to decide whether it is the list's at
285 // all: with nothing highlighted it belongs to the form, and swallowing it
286 // would be a suggestion list that stops a form being submitted from the
287 // keyboard.
288 let _ = write!(
289 source,
290 "on keydown[key=='Enter'] \
291 set cur to the first <#{list} [role='option'][aria-selected='true']/> \
292 then if no cur then exit end \
293 then halt the event \
294 then if no (@data-value of cur) \
295 call cur.click() then {close} \
296 else set my value to (@data-value of cur) \
297 then {close} \
298 then send change to me end \
299 end \
300 on keydown[key=='Escape'] \
301 set opts to <#{list} [role='option']/> \
302 then if no opts then exit end \
303 then halt the event \
304 then {close} \
305 end \
306 on focusout {close} end",
307 close = closing(list),
308 );
309 program(&source, out);
310 }
311
312 /// Put the list away: empty it, and say so on the box.
313 ///
314 /// One string because the three gestures that close a list have to close it the
315 /// same way. `aria-expanded` and `aria-activedescendant` are what a reader is
316 /// told, and leaving either behind describes a list that is not there.
317 fn closing(list: &str) -> String {
318 format!(
319 "set @aria-expanded of me to 'false' \
320 then set @aria-activedescendant of me to '' \
321 then put '' into the first <#{list}/>"
322 )
323 }
324
325 /// One entry in a suggestion list: the pointer half.
326 ///
327 /// The value is read off the entry's own `data-value` rather than written into
328 /// this program. A candidate is app text — a tag somebody typed, a title
329 /// somebody chose — and a program built out of it is this file writing code
330 /// from arbitrary input, which is the [`handle`] gate's whole argument one
331 /// level down.
332 ///
333 /// `mousedown` is halted so that pressing an entry does not take focus off the
334 /// box. It is the alternative to closing the list on a timer, which is what a
335 /// hand-written combobox does and what makes a slow click miss.
336 pub(crate) fn suggestion(input: &str, list: &str, out: &mut String) {
337 program(
338 &format!(
339 "on mousedown halt the event end \
340 on click set box to the first <#{input}/> \
341 then if no box then exit end \
342 then set picked to @data-value \
343 then set the value of box to picked \
344 then set @aria-expanded of box to 'false' \
345 then set @aria-activedescendant of box to '' \
346 then send change to box \
347 then put '' into the first <#{list}/> \
348 end"
349 ),
350 out,
351 );
352 }
353
354 /// A candidate that performs an action instead of writing its value.
355 ///
356 /// The closing half of [`suggestion`] and none of the writing half: the box is
357 /// collapsed and the list emptied, and what the click *does* is htmx's,
358 /// emitted beside this by `node::action_attrs`.
359 ///
360 /// `mousedown` is halted for [`suggestion`]'s reason -- the box's blur would
361 /// otherwise tear the list down before the click landed on it -- and the click
362 /// itself is deliberately not halted, because htmx is listening for it.
363 pub(crate) fn suggestion_acting(input: &str, list: &str, out: &mut String) {
364 program(
365 &format!(
366 "on mousedown halt the event end \
367 on click set box to the first <#{input}/> \
368 then if no box then exit end \
369 then set @aria-expanded of box to 'false' \
370 then set @aria-activedescendant of box to '' \
371 then put '' into the first <#{list}/> \
372 end"
373 ),
374 out,
375 );
376 }
377
378 /// The list itself: what an arriving answer says about the box.
379 ///
380 /// The route answers with candidates or with none, and both are answers. A box
381 /// whose `aria-expanded` still says `true` over an empty list is describing
382 /// something that is not on the screen, and this is the only moment either can
383 /// change without a gesture.
384 ///
385 /// Counted rather than `:empty`, which is a whitespace question and not a
386 /// content one.
387 pub(crate) fn suggestion_list(input: &str, list: &str, out: &mut String) {
388 program(
389 &format!(
390 "on htmx:afterSettle set box to the first <#{input}/> \
391 then if no box then exit end \
392 then if (the length of <#{list} [role='option']/>) is 0 \
393 set @aria-expanded of box to 'false' \
394 else set @aria-expanded of box to 'true' end \
395 end"
396 ),
397 out,
398 );
399 }
400