max / quasi
| 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 escape_into; |
| 50 | use 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 |
| 60 | let plain = !id.is_empty |
| 61 | && id |
| 62 | .bytes |
| 63 | .all; |
| 64 | plain.then_some |
| 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 | |
| 74 | out.push_str; |
| 75 | escape_into; |
| 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 |
| 115 | match key |
| 116 | Some => program |
| 117 | &format!, |
| 118 | out, |
| 119 | , |
| 120 | None => program, |
| 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 |
| 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 |
| 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 | |
| 168 | pub |
| 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 |
| 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 = 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 | Back => , |
| 201 | Forward => , |
| 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 |
| 261 | let mut source = Stringnew; |
| 262 | for 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 | , |
| 267 | , |
| 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, |
| 308 | ); |
| 309 | program; |
| 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 | |
| 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 |
| 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 |
| 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 |
| 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 |