//! Local interaction, emitted. //! //! _hyperscript was as the way this renderer performs what happens without a //! request, and nothing emitted one for eleven days. This is the adoption //! landing: every program in this file is written by the emitter from a //! description, and none of it is hand-written into a template anywhere. //! //! # Why an attribute is allowed to hold a program //! //! The test is provenance rather than syntax: emitted is fine, hand-written is //! not. What the rule defends against is a script nobody remembers is running, //! and emitted code cannot become that -- it regenerates from this file every //! time a screen is drawn, so deleting the source deletes every copy of it. //! //! The adoption carries one rule with it and this file keeps it: **never //! hyperscript's `js` feature**. It is the only path in the interpreter that //! reaches `new Function`, and `script-src 'self'` without `unsafe-eval` is //! what MNW serves. Nothing here emits a `js` block, and nothing here builds a //! program out of text a user typed. //! //! # What is emitted, and where the behaviour is otherwise //! //! One case today: the chrome of a region that shows its children one at a //! time. Moving the frame is the definition of local: the bytes are in the //! document, and a round trip to reveal them is worse on every gallery this //! was measured against. //! //! Two local behaviours are shipped as scripts rather than as programs, and //! both for one reason: they are built out of app text. `fill.js` carries the //! value an act deposits and `reveal.js` the values a region watches for, and //! this file's standing rule is that no program is written from text a user //! typed. As escaped attributes those values are data. //! //! [`Destination::Local`](quasi_router::Destination::Local) is the other half //! and is still unemitted, for the reason `crate::CLASS` gives: what happens //! locally is named by the member carrying the action, and no member in the //! vocabulary names one yet. When one lands, its program belongs here. //! //! # The handle gate //! //! Every program addresses its region by id, inside a CSS attribute selector, //! inside an HTML attribute, inside a language with `${}` interpolation. A //! region id is app text, so building a program out of one that is not a plain //! handle would be this file writing code from arbitrary input, which is where //! injection lives. [`handle`] is the gate: a plain handle gets a program, and //! anything else gets the `data-shows` mark and no behaviour, which is a //! narrow fallback rather than a program built out of arbitrary text. use makeover_webview::form::escape_into; use std::fmt::Write as _; /// The id as something a selector can hold, if it is one. /// /// Letters, digits, `-` and `_`, and not empty. Deliberately narrower than what /// CSS can escape: a hex escape (`\24 ` for `$`) is valid CSS and is read by /// browsers, and it is not read by every selector engine, so a program built on /// one would work in the places it was tried and fail somewhere else. Every /// region id in the tree is a handle already. pub(crate) fn handle(id: &str) -> Option<&str> { let plain = !id.is_empty() && id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); plain.then_some(id) } /// Write ` _=""`, escaped. /// /// One place, so the escaping cannot be forgotten at a call site. The program /// is this crate's own text plus a handle and some integers, so the escaping is /// discipline rather than a guard: it costs a scan and it means the emitter /// never has a string that reaches an attribute unescaped. fn program(source: &str, out: &mut String) { out.push_str(" _=\""); escape_into(source, out); out.push('"'); } /// Wherever the reader came from. /// /// [`Destination::Back`] says the address is the host's, and in a browser the /// host holding it is the browser: the runtime's history and the document's /// are the same list here, because every place this renderer reaches was a /// navigation htmx pushed. /// /// `call history.back()` and not a `js` block. The interpreter evaluates a /// method call on a global without reaching `new Function`, which is the one /// path this module's header forbids, so this runs under `script-src 'self'` /// with no `unsafe-eval`. /// /// # Why the gesture is a parameter /// /// A visible control is pressed and a [`Chrome`] binding is not: its button is /// `hidden` and never focused, so it is never clicked. Every other destination /// gets its gesture from htmx, which fires the request itself; this one is /// performed here, so the gesture has to be stated here too. Handing back a /// program that only listens for a click would make every bound key silently /// do nothing, which is the failure this whole variant exists to end. /// /// `from window` for the same reason `Fires::Key` writes `from:body`: the /// element cannot receive a keystroke it can never be focused for. /// /// # What is interpolated, and why it is allowed to be /// /// The filter, and only the filter. It is not app text arriving whole: the /// modifiers are literals chosen in `chrome::trigger_filter`, and the base is /// either a single character run through `js_string` or a name from the closed /// list in `chrome::named_key`. That is the same discipline the handle gate /// applies to a region id -- a value this crate validated rather than one it /// was handed -- so no second gate is needed. /// /// [`Chrome`]: quasi_router::Chrome /// [`Destination::Back`]: quasi_router::Destination::Back pub(crate) fn back(key: Option<&str>, out: &mut String) { match key { Some(filter) => program( &format!("on keydown[{filter}] from window call history.back()"), out, ), None => program("on click call history.back()", out), } } /// A tab, which shows the frame it names and takes the strip's marks with it. /// /// Three things move together, because a tab strip says the same fact three /// ways: the frame that is current, the button that is chosen, and /// `aria-selected`, which is what a screen reader hears and what nothing in a /// stylesheet can stand in for. /// /// `take .chosen` with no `from` takes it from the pressed control's siblings, /// which is every button in the strip and nothing else. The `aria-selected` /// half needs the loop because `take` moves a class and not an attribute value. pub(crate) fn tab(handle: &str, at: usize, out: &mut String) { program( &format!( "on click take .chosen \ then for tab in <[data-shows]/> in the closest <[role='tablist']/> \ set @aria-selected of tab to 'false' end \ then set @aria-selected of me to 'true' \ then take .current from <[data-frame='{handle}']/> \ for the first <[data-frame='{handle}'][data-shown='{at}']/>" ), out, ); } /// A summary line, which opens and closes the one child under it. /// /// The only control here that toggles rather than picks: a dismissible region /// with one named child is a disclosure, and pressing it again is how a reader /// closes it. `aria-expanded` is read back off the frame rather than tracked, /// so the attribute cannot drift from what the page is showing. pub(crate) fn disclosure(handle: &str, out: &mut String) { program( &format!( "on click toggle .current on the first <[data-frame='{handle}']/> \ then if the first <[data-frame='{handle}']/> matches '.current' \ set @aria-expanded of me to 'true' \ else set @aria-expanded of me to 'false' end" ), out, ); } /// Which way a step control moves. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Step { /// The frame before this one, wrapping to the last. Back, /// The frame after this one, wrapping to the first. Forward, } /// Previous or next, plus the counter beside them. /// /// The index is read off the frame that is current rather than held anywhere, /// so a swap that brings new frames is stepped from what arrived and not from /// what was here before. `(((at + step) + total) mod total)` rather than a /// comparison: the `+ total` is what makes the backwards step wrap instead of /// going negative, which JavaScript's `%` would not do on its own. /// /// A region showing nothing -- a dismissible one that is closed -- has no frame /// to read, and the fallback index is picked so that the wrap lands where a /// reader would expect: forward opens the first frame, back opens the last. /// /// The counter is written here because it is the same gesture. A carousel whose /// position says `1 / 3` after three presses is a carousel saying something /// false, and there is nothing else awake to correct it. pub(crate) fn step(handle: &str, step: Step, total: usize, out: &mut String) { // Nothing to step through, and `mod 0` is not a number. A region with no // frames still draws its counter row, which is the empty gallery three MNW // pages have whenever the creator uploaded nothing. if total == 0 { return; } let (by, closed) = match step { // `total` and not `-1`: from closed, one step back off the end lands on // the last frame, which is `total - 1` after the wrap. Step::Back => ("- 1", total.to_string()), Step::Forward => ("+ 1", "-1".to_owned()), }; program( &format!( "on click set frames to <[data-frame='{handle}']/> \ then set shown to the first <[data-frame='{handle}'].current/> \ then if no shown set at to {closed} else set at to (@data-shown of shown) as Int end \ then set there to (((at {by}) + {total}) mod {total}) \ then take .current from frames \ for the first <[data-frame='{handle}'][data-shown='${{there}}']/> \ then put ((there + 1) + ' / {total}') into the first <[data-position='{handle}']/>" ), out, ); } /// A field that owns a suggestion list: the keyboard half, on the box itself. /// /// And the first [`Destination::Local`] this file has had a program to write /// for. Ownership is what makes it writable: the list belongs to the field, so /// moving the highlight and picking an entry both know which box they are /// about without a description saying so. /// /// Four handlers, one gesture each, and every one of them reads the state back /// out of the document rather than holding it. Which entry is current is a /// class on that entry, and `aria-activedescendant` on the box is the same fact /// said to a screen reader — the pair `tab` keeps in step for the same reason, /// because nothing in a stylesheet stands in for what a reader hears. /// /// The wrap is `step`'s arithmetic and for its reason: `+ length` is what makes /// a step off the top land on the bottom rather than on a negative index. An /// empty list is not a list to move through, so both arrows exit and leave the /// box alone. /// /// # What picking does, and what it deliberately does not /// /// It writes [`Choice::value`] into the box, closes the list, and sends /// `change` — which is what a browser sends when a control's value settles, so /// a [`Field::writes`] route fires exactly as it would have if the value had /// been typed and left. What it does not send is `keyup`: the questions a field /// asks *while it is being typed into* are not questions a pick asks, and a /// synthetic keystroke would re-ask this very route about the value it just /// answered. /// /// # Enter reaches a candidate that acts, rather than emptying the box /// /// A candidate carrying [`Candidate::picks`] has no `data-value`, since nothing /// is written when picking performs a call. Writing one anyway put `null` in /// the box and the call never went out, so the keyboard could not take the two /// rows the member exists for: a tag that adds a facet, and a search hit that /// navigates. Enter presses the row instead — `click()` and not a dispatched /// event, because only the method runs an anchor's activation behaviour, and /// the row is an anchor exactly when its pick replaces the document. /// /// [`Candidate::picks`]: quasi_router::Candidate::picks /// /// [`Destination::Local`]: quasi_router::Destination::Local /// [`Choice::value`]: quasi_router::Choice::value /// [`Field::writes`]: quasi_router::Field::writes pub(crate) fn combobox(list: &str, out: &mut String) { let mut source = String::new(); for (key, from, by) in [ // The index a step starts from when nothing is current yet, picked so // the first press lands where a reader expects: down opens on the // first entry, up on the last. ("ArrowDown", "-1", "+ 1"), ("ArrowUp", "0", "- 1"), ] { let _ = write!( source, "on keydown[key=='{key}'] \ set opts to <#{list} [role='option']/> \ then if no opts then exit end \ then halt the event \ then set cur to the first <#{list} [role='option'][aria-selected='true']/> \ then if no cur then set at to {from} else set at to (@data-at of cur) as Int end \ then set there to (((at {by}) + (the length of opts)) mod (the length of opts)) \ then for opt in opts set @aria-selected of opt to 'false' end \ then set @aria-selected of (opts[there]) to 'true' \ then set @aria-activedescendant of me to (@id of opts[there]) \ end " ); } // Enter is the one gesture that has to decide whether it is the list's at // all: with nothing highlighted it belongs to the form, and swallowing it // would be a suggestion list that stops a form being submitted from the // keyboard. let _ = write!( source, "on keydown[key=='Enter'] \ set cur to the first <#{list} [role='option'][aria-selected='true']/> \ then if no cur then exit end \ then halt the event \ then if no (@data-value of cur) \ call cur.click() then {close} \ else set my value to (@data-value of cur) \ then {close} \ then send change to me end \ end \ on keydown[key=='Escape'] \ set opts to <#{list} [role='option']/> \ then if no opts then exit end \ then halt the event \ then {close} \ end \ on focusout {close} end", close = closing(list), ); program(&source, out); } /// Put the list away: empty it, and say so on the box. /// /// One string because the three gestures that close a list have to close it the /// same way. `aria-expanded` and `aria-activedescendant` are what a reader is /// told, and leaving either behind describes a list that is not there. fn closing(list: &str) -> String { format!( "set @aria-expanded of me to 'false' \ then set @aria-activedescendant of me to '' \ then put '' into the first <#{list}/>" ) } /// One entry in a suggestion list: the pointer half. /// /// The value is read off the entry's own `data-value` rather than written into /// this program. A candidate is app text — a tag somebody typed, a title /// somebody chose — and a program built out of it is this file writing code /// from arbitrary input, which is the [`handle`] gate's whole argument one /// level down. /// /// `mousedown` is halted so that pressing an entry does not take focus off the /// box. It is the alternative to closing the list on a timer, which is what a /// hand-written combobox does and what makes a slow click miss. pub(crate) fn suggestion(input: &str, list: &str, out: &mut String) { program( &format!( "on mousedown halt the event end \ on click set box to the first <#{input}/> \ then if no box then exit end \ then set picked to @data-value \ then set the value of box to picked \ then set @aria-expanded of box to 'false' \ then set @aria-activedescendant of box to '' \ then send change to box \ then put '' into the first <#{list}/> \ end" ), out, ); } /// A candidate that performs an action instead of writing its value. /// /// The closing half of [`suggestion`] and none of the writing half: the box is /// collapsed and the list emptied, and what the click *does* is htmx's, /// emitted beside this by `node::action_attrs`. /// /// `mousedown` is halted for [`suggestion`]'s reason -- the box's blur would /// otherwise tear the list down before the click landed on it -- and the click /// itself is deliberately not halted, because htmx is listening for it. pub(crate) fn suggestion_acting(input: &str, list: &str, out: &mut String) { program( &format!( "on mousedown halt the event end \ on click set box to the first <#{input}/> \ then if no box then exit end \ then set @aria-expanded of box to 'false' \ then set @aria-activedescendant of box to '' \ then put '' into the first <#{list}/> \ end" ), out, ); } /// The list itself: what an arriving answer says about the box. /// /// The route answers with candidates or with none, and both are answers. A box /// whose `aria-expanded` still says `true` over an empty list is describing /// something that is not on the screen, and this is the only moment either can /// change without a gesture. /// /// Counted rather than `:empty`, which is a whitespace question and not a /// content one. pub(crate) fn suggestion_list(input: &str, list: &str, out: &mut String) { program( &format!( "on htmx:afterSettle set box to the first <#{input}/> \ then if no box then exit end \ then if (the length of <#{list} [role='option']/>) is 0 \ set @aria-expanded of box to 'false' \ else set @aria-expanded of box to 'true' end \ end" ), out, ); }