Skip to main content

max / quasi

59.2 KB · 1416 lines History Blame Raw
1 //! Nodes to markup.
2 //!
3 //! Every function here takes a piece of [`quasi_router`]'s screen tree and
4 //! pushes markup onto a buffer. Nothing returns a `Result`: a description that
5 //! exists is renderable by construction, which is the property the owned mirror
6 //! in `quasi-router` was built to have.
7 //!
8 //! # Where the htmx goes in
9 //!
10 //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path
11 //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is
12 //! the whole of what "htmx is the transport" means in code. Nothing else in
13 //! this file knows the word htmx, so decision 13's claim that the transport is
14 //! replaceable is a claim about one function rather than about the crate.
15 //!
16 //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set
17 //! when a control calls a route the description layer does not serve. Decision 7
18 //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from
19 //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router
20 //! is the only party that knows what it just changed, and a control that also
21 //! named a target would be a second party deciding one thing. That reasoning
22 //! assumes the responder is described. A plain API route is not, cannot name a
23 //! region, and leaves the answer to land wherever the transport defaults, which
24 //! for htmx is inside the pressed button. See `Action::replaces` for the whole
25 //! of it.
26
27 use std::collections::HashMap;
28 use std::fmt::Write as _;
29
30 use makeover_layout as layout;
31 // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
32 // than from a match here, so a tone added upstream cannot be named two ways.
33 use makeover_layout::Intent as _;
34 use makeover_webview::figure::figure_html;
35 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
36 // `class`, `option_class` and the two part-class mappings below are makeover's,
37 // not copies of it. They were copies until makeover-webview 0.27.0 made them
38 // public: the prefix helper was byte-identical, and the row and cell part names
39 // were a second spelling of a list whose own doc comment carries an obligation
40 // to be grepped on upgrade. A second spelling is a second place to forget, and
41 // the selector names had already drifted.
42 use makeover_webview::{Emit, class, option_class};
43 // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
44 // is the described one. Aliased rather than qualified at the call site, so the
45 // two never read as the same type.
46 use makeover_webview::list::{
47 Cell as Emitted, cell_part_class, cells_html, column_classes, part_class,
48 };
49 use makeover_webview::meter::meter_html;
50 use makeover_webview::placeholder::placeholder_html;
51 use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag};
52 use quasi_router::{Action, Method, Params};
53
54 /// Write a `class="..."` attribute, prefixed.
55 fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
56 out.push_str(" class=\"");
57 for (i, name) in names.iter().enumerate() {
58 if i > 0 {
59 out.push(' ');
60 }
61 out.push_str(&escape(&class(name, opts)));
62 }
63 out.push('"');
64 }
65
66 /// Write the attribute naming a tone, if the tone is worth naming.
67 ///
68 /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an
69 /// attribute meaning "nothing unusual" is an attribute on every element in the
70 /// document.
71 ///
72 /// An attribute and not a class, which is the correction. This emitted
73 /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and
74 /// makeover has never defined one of them: its whole vocabulary keys tone off
75 /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a
76 /// figure's value. So every toned thing a description produced arrived with a
77 /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's
78 /// Remove button came out the same colour as everything else.
79 fn tone_attr(tone: layout::Tone, out: &mut String) {
80 if matches!(tone, layout::Tone::Neutral) {
81 return;
82 }
83 out.push_str(" data-tone=\"");
84 out.push_str(tone.token());
85 out.push('"');
86 }
87
88 /// What goes back in the box, for a form being offered again after a refusal.
89 ///
90 /// `1c4a66a4`. The description carries the value as a string, because that is
91 /// what came off the wire; the kind is what says how to read it. A checkbox is
92 /// carried by presence the way HTML submits one, so any value means ticked and
93 /// nothing means not.
94 ///
95 /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is
96 /// the third refusal of the same thing and none of the three is redundant:
97 /// `Field::value` will not store one, `makeover_webview::form` will not write
98 /// one into an `<input type="password">`, and this one stands between them
99 /// because `Field::value` is a public field that a struct literal reaches past.
100 fn refill(field: &Field) -> Value<'_> {
101 if field.kind == layout::FieldKind::Secret {
102 return Value::Absent;
103 }
104 match field.value.as_deref() {
105 None => Value::Absent,
106 Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true),
107 Some(value) => Value::Text(value),
108 }
109 }
110
111 /// One field, with whatever `1c4a66a4` and `14612ed8` added around it.
112 ///
113 /// The field's own markup is makeover-webview's, unchanged. A second field
114 /// emitter here is the divergence phase A existed to end, and it would be the
115 /// same anatomy with a different escaping story.
116 ///
117 /// A [`Field::changes`] is a wrapper rather than attributes on the control,
118 /// because the control is emitted by makeover-webview and there is no seam to
119 /// put them through. That turns out to be the better shape anyway: the `change`
120 /// event bubbles, so one element around the group catches it whichever of the
121 /// input, select or textarea forms the field took, and `hx-include` finds the
122 /// control back without this having to know which it was.
123 fn field_group_html(field: &Field, morphs: bool, opts: &Emit, out: &mut String) {
124 let filling = Filling::of(refill(field));
125 let writes = field.changes.as_ref();
126
127 if let Some(action) = writes {
128 out.push_str("<div");
129 class_attr(&["field-writes"], opts, out);
130 action_attrs(action, Fires::ChangeInside, None, morphs, None, out);
131 out.push('>');
132 }
133
134 field.with_layout(|borrowed| {
135 out.push_str(&field_html(&borrowed, &filling, opts));
136 });
137
138 if writes.is_some() {
139 out.push_str("</div>");
140 }
141 }
142
143 /// Markdown source into markup, for [`Node::Rich`].
144 ///
145 /// The strict preset, which is a deliberate difference from the
146 /// `render_standard` goingson's own JS calls: standard lets sanitised raw HTML
147 /// through, and a shared renderer taking text a user typed should be the safer
148 /// of the two by default. What it costs is angle brackets in a description
149 /// rendering as text rather than as markup, which is the outcome
150 /// [`Node::Text`] would have given anyway.
151 ///
152 /// Unconditional. This sat behind a default-on `rich` feature until markdown
153 /// was made standard, and turning the feature off did not remove a cost so much
154 /// as produce a renderer that draws `**bold**` at the user. Markdown is what
155 /// these descriptions are made of, so rendering it is part of being a renderer
156 /// rather than an extra somebody opts into.
157 fn rich_html(source: &str) -> String {
158 docengine::render_strict(source)
159 }
160
161 /// JSON-encode a string, for an `hx-vals` payload.
162 ///
163 /// Small enough to own. Pulling in a JSON crate to write object literals of
164 /// strings would be the larger decision, and the encoder a renderer needs is
165 /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the
166 /// rest of the C0 range. The result is then HTML-escaped by the caller, because
167 /// it lands in an attribute.
168 fn json_string(text: &str, out: &mut String) {
169 out.push('"');
170 for ch in text.chars() {
171 match ch {
172 '"' => out.push_str("\\\""),
173 '\\' => out.push_str("\\\\"),
174 '\n' => out.push_str("\\n"),
175 '\r' => out.push_str("\\r"),
176 '\t' => out.push_str("\\t"),
177 c if (c as u32) < 0x20 => {
178 let _ = write!(out, "\\u{:04x}", c as u32);
179 }
180 c => out.push(c),
181 }
182 }
183 out.push('"');
184 }
185
186 /// The params as an `hx-vals` object.
187 fn json_object(params: &Params) -> String {
188 let mut json = String::from("{");
189 for (i, (name, value)) in params.iter().enumerate() {
190 if i > 0 {
191 json.push(',');
192 }
193 json_string(name, &mut json);
194 json.push(':');
195 json_string(value, &mut json);
196 }
197 json.push('}');
198 json
199 }
200
201 /// What makes a control call its action.
202 ///
203 /// Here rather than at the call sites because every `hx-` attribute this crate
204 /// emits has to come out of one function, which is decision 13's claim that the
205 /// transport is replaceable and is asserted by a test.
206 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
207 pub(crate) enum Fires<'a> {
208 /// The user activating the control. htmx's default for a button or a link.
209 Click,
210 /// The control's own value changing. What a checkbox that is itself the
211 /// write does.
212 Change,
213 /// The value of a control *inside* this element changing.
214 ///
215 /// A field group, whose control is emitted by makeover-webview and has no
216 /// seam to hang attributes on. The `change` event bubbles, so the wrapper
217 /// catches it whichever of input, select or textarea the field turned out to
218 /// be, and the value is found back rather than assumed.
219 ChangeInside,
220 /// A key pressed anywhere in the document.
221 ///
222 /// What a [`Chrome`](quasi_router::Chrome) binding is: it belongs to no
223 /// element, so it listens on the body rather than on itself. The string is
224 /// the filter over `KeyboardEvent`, built by `crate::chrome` because
225 /// reading a key name is the host's job.
226 Key(&'a str),
227 /// The user clicking the element, but not a control inside it.
228 ///
229 /// `022f0c59`. A table row carries its `activate` on the row itself, unlike
230 /// a list row, which hangs it on the primary text and so has never had this
231 /// problem. Once a cell can hold a control, a click on that control bubbles
232 /// to the row and htmx fires both: pressing Remove would delete the key and
233 /// open it. The filter is on the row rather than a `stopPropagation` on the
234 /// button because the row is the element making the wrong assumption, and a
235 /// button that swallows events breaks anything else listening above it.
236 ClickBeside,
237 }
238
239 /// The transport attributes for one action.
240 ///
241 /// # The two bags land in two places, and that is the point
242 ///
243 /// An action carries what the control sends (`params`) and the view it was
244 /// offered under (`carried`), and htmx has a slot for each: the address takes
245 /// the view, `hx-vals` takes the payload. So a write to a filtered list emits
246 /// `hx-post="/problems/{id}/status?status=Open"` with
247 /// `hx-vals='{"status":"Dismissed"}'`, and the two `status` values never meet.
248 ///
249 /// Until 2026-08-10 both bags were one and both went through `hx-vals`, which
250 /// could hold neither. `hx-vals` is a JSON object literal, so two entries under
251 /// one name emitted a duplicate key and every parser kept the last — inverting
252 /// [`Params::get`]'s first-wins rule the moment a value crossed the wire, and
253 /// silently dropping every repeat that [`Params::get_all`] exists to carry.
254 /// Folding the view into the address fixes both: a query string repeats a name
255 /// happily, and it is the half that wanted to be in the URL anyway.
256 ///
257 /// Nothing here concatenates a `?`. [`quasi_http::route_url`] does that, in one
258 /// place, with a real encoder, because hand-built query strings are where
259 /// escaping bugs live.
260 pub(crate) fn action_attrs(
261 action: &Action,
262 fires: Fires,
263 confirm: Option<&str>,
264 morphs: bool,
265 gathers: Option<&str>,
266 out: &mut String,
267 ) {
268 // An external destination is not htmx's business: nothing swaps, no route
269 // is called, and the browser follows a normal link. `rel` rather than
270 // trust: a new tab with `window.opener` left intact hands the other page a
271 // handle on this one.
272 if let Destination::External(url) = &action.destination {
273 out.push_str(" href=\"");
274 out.push_str(&escape(url));
275 out.push_str("\" target=\"_blank\" rel=\"noopener noreferrer\"");
276 return;
277 }
278
279 // A read of a route this app answers is a link, and it gets the address as
280 // well as the transport. htmx uses `hx-get` and prevents the default, so
281 // the `href` is what everything else uses: middle-click, copy-link, a
282 // crawler, and the page with JS off. The parameters are folded into it
283 // because a link to a filtered list that drops the filter is a different
284 // place, and `hx-vals` below carries the same ones down htmx's path.
285 if let Destination::Route(path) = &action.destination
286 && !action.method.mutates()
287 {
288 out.push_str(" href=\"");
289 out.push_str(&escape(&quasi_http::route_url(path, &action.carried)));
290 out.push('"');
291 }
292
293 // Everything the screen's selection has ticked, gathered by the selector
294 // the caller built. `5f2b8753`: this is the whole of what the per-app JS
295 // used to do, and it is declarative because a checkbox already submits its
296 // own name and value -- all that was missing was something saying which
297 // boxes belong together.
298 //
299 // Here rather than in `act_html` because it is htmx, and htmx entering this
300 // crate anywhere else is what the architectural test forbids.
301 if let Some(selector) = gathers {
302 out.push_str(" hx-include=\"");
303 out.push_str(&escape(selector));
304 out.push('"');
305 }
306
307 // Where the answer goes, when the responder is not ours to ask. Emitted
308 // before the verb so the attributes read in the order they are reasoned
309 // about: where it lands, then what is sent.
310 if let Some(region) = &action.replaces {
311 out.push_str(" hx-target=\"#");
312 out.push_str(&escape(region));
313 out.push('"');
314 }
315
316 // The answer is a file the reader keeps, not a view. On a link the browser
317 // does the whole job from the attribute, so nothing else is needed and the
318 // control still works with JS off. On a write it cannot: a response has to
319 // be performed before it can be saved, so this is a named hook the host
320 // acts on, in the same spirit as `data-act` and for the same reason it is an
321 // attribute rather than a class. The host handles one attribute instead of
322 // a per-button behaviour named by a class and two positional arguments.
323 if let Some(filename) = &action.saves {
324 if is_link(action) {
325 out.push_str(" download=\"");
326 } else {
327 out.push_str(" data-saves=\"");
328 }
329 out.push_str(&escape(filename));
330 out.push('"');
331 }
332
333 let verb = match action.method {
334 Method::Get => " hx-get=\"",
335 Method::Post => " hx-post=\"",
336 Method::Delete => " hx-delete=\"",
337 Method::Put => " hx-put=\"",
338 };
339 out.push_str(verb);
340 out.push_str(&escape(&quasi_http::route_url(
341 action.destination.as_str(),
342 &action.carried,
343 )));
344 out.push('"');
345
346 if !action.params.is_empty() {
347 out.push_str(" hx-vals=\"");
348 out.push_str(&escape(&json_object(&action.params)));
349 out.push('"');
350 }
351
352 // Named even where it matches htmx's own default for the element, so the
353 // markup says what it does rather than resting on a default holding.
354 match fires {
355 Fires::Click => {}
356 // `data-act` and not the class, so the filter does not depend on
357 // `Emit::class_prefix` and does not break when a host sets one. Same
358 // reasoning as `data-menu` on a row's menu.
359 Fires::ClickBeside => out.push_str(concat!(
360 " hx-trigger=\"click[!event.target.closest(",
361 "&#39;[data-act]&#39;)]\""
362 )),
363 Fires::Change => out.push_str(" hx-trigger=\"change\""),
364 Fires::Key(filter) => {
365 // `from:body`, because the element is hidden and never focused: a
366 // trigger on itself would wait for a keystroke it can never
367 // receive.
368 out.push_str(" hx-trigger=\"keydown[");
369 out.push_str(&escape(filter));
370 out.push_str("] from:body\"");
371 }
372 Fires::ChangeInside => {
373 out.push_str(" hx-trigger=\"change\"");
374 out.push_str(" hx-include=\"find input, find select, find textarea\"");
375 }
376 }
377
378 // Asking before acting is transport here, same as the verb: htmx gates the
379 // request on it. That is also why it lands in this function rather than
380 // beside the label — every `hx-` attribute this crate emits comes from one
381 // place, or swapping htmx for fixi stops being a one-function change.
382 if let Some(prompt) = confirm {
383 out.push_str(" hx-confirm=\"");
384 out.push_str(&escape(prompt));
385 out.push('"');
386 }
387
388 if morphs {
389 // Decision 7's slack: a morph preserves focus, scroll and input state
390 // through a swap, so a whole-Screen answer stops being destructive.
391 out.push_str(" hx-swap=\"morph\"");
392 }
393 }
394
395 /// Whether an action is somewhere to go rather than something to do.
396 ///
397 /// Two ways to be a link. An external destination leaves. A read of a route
398 /// this app answers is also a link: it has an address, it can be visited
399 /// directly, and nothing changes because it was.
400 ///
401 /// A write is never a link however it is spelled, which is the whole of the
402 /// other side. An anchor is something a browser may prefetch and a crawler will
403 /// follow, and neither is allowed to delete a task.
404 const fn is_link(action: &Action) -> bool {
405 action.destination.is_external() || !action.method.mutates()
406 }
407
408 /// The element a control becomes.
409 ///
410 /// A link is an anchor and a write is a button, and a button that navigates is
411 /// a button lying to everything that reads the page: middle-click, copy-link,
412 /// a crawler and a screen reader included. This keyed on external-or-not until
413 /// the read case was separated out, which made every internal navigation a
414 /// control that only worked by running JavaScript first.
415 ///
416 /// Branching on the [`Destination`] variant and never on the shape of the
417 /// string is the rule `Destination`'s own docs set. "Starts with https" is how a
418 /// route named `/https-setup` ends up opening a browser.
419 const fn control_tag(action: &Action) -> (&'static str, &'static str) {
420 if is_link(action) {
421 ("<a", "</a>")
422 } else {
423 ("<button type=\"button\"", "</button>")
424 }
425 }
426
427 /// A control that calls a route.
428 pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
429 // The commit control for a staged selection gathers every tick on the
430 // screen. One selector rather than a name per box: a screen holds one set
431 // (`Screen::selection`), so the class the ticks already carry is what they
432 // have in common. Built here because it needs `Emit`, which the transport
433 // function does not take -- a host setting `class_prefix` moves the class
434 // and the selector together.
435 let gathered = class("row-select", opts);
436 let gathers = act.over.as_ref().map(|_| format!(".{gathered}"));
437 let gathers = gathers.as_deref();
438 // `button`, which is makeover's name for this and carries its whole
439 // interactive set: the raised bevel, the hover fill, the pressed inset, the
440 // focus ring and the disabled treatment. This emitted `act`, a second name
441 // for the same thing that no stylesheet in the tree defined, so a described
442 // control rendered as unstyled text. There was never a concept here that
443 // `button` was not already the word for.
444 let (open, close) = control_tag(&act.action);
445 out.push_str(open);
446 class_attr(&["button"], opts, out);
447 tone_attr(act.tone, out);
448 // Named rather than found by class, so anything binding to "this is a
449 // control" survives a host setting `Emit::class_prefix`. `Fires::ClickBeside`
450 // is the first reader; a table row uses it to tell its own click apart from
451 // a press on a button sitting inside one of its cells.
452 out.push_str(" data-act");
453
454 match act.state {
455 Some(layout::State::Disabled) => {
456 // Disabled and emitting no transport, rather than disabled and
457 // still carrying the address. A control that stops answering input
458 // should also stop being a request waiting to be re-enabled from
459 // the console.
460 //
461 // An anchor has no `disabled`, and omitting the href is what
462 // actually stops it: an `<a>` without one is not a link, so it
463 // drops out of the tab order on its own. `aria-disabled` is what
464 // says why, since a bare span-shaped anchor says nothing.
465 if is_link(&act.action) {
466 out.push_str(" aria-disabled=\"true\"");
467 } else {
468 out.push_str(" disabled");
469 }
470 }
471 // No `autofocus` arm. A description does not state focus: the browser
472 // owns reach and focus here, which is what `makeover-layout` 0.19.0
473 // settled by removing the member this used to read.
474 //
475 // `State` is `#[non_exhaustive]`, so a member added upstream lands
476 // here. Emitting the transport is the right default for anything that
477 // is not a suppression: a state this renderer has not learned yet
478 // should leave the control working, not silently inert.
479 _ => action_attrs(
480 &act.action,
481 Fires::Click,
482 act.confirm.as_deref(),
483 morphs,
484 gathers,
485 out,
486 ),
487 }
488
489 // The confirmation rides with the transport, in `action_attrs`. The key does
490 // not: `accesskey` is plain HTML and no part of htmx. It is emitted even
491 // though a browser makes little of it, because a key is the affordance in a
492 // terminal and this is the nearest honest thing a page has.
493 if let Some(key) = &act.key {
494 out.push_str(" accesskey=\"");
495 out.push_str(&escape(key));
496 out.push('"');
497 }
498
499 out.push('>');
500 out.push_str(&escape(&act.label));
501 out.push_str(close);
502 }
503
504 /// One row of a list.
505 fn row_html(row: &Row, morphs: bool, opts: &Emit, out: &mut String) {
506 let mut classes = vec!["row"];
507 if row.current {
508 classes.push("row-current");
509 }
510 if row.selected == Some(true) {
511 classes.push("row-selected");
512 }
513
514 out.push_str("<li");
515 class_attr(&classes, opts, out);
516 if row.current {
517 // `current` is what the detail side is showing: a current item within a
518 // set rather than a pressed control, and rather than anything the user
519 // ticked. That distinction is why the description carries two fields.
520 out.push_str(" aria-current=\"true\"");
521 }
522 out.push('>');
523
524 // The tick, when the row is selectable at all. A real checkbox rather than
525 // a styled span: it is the one control here the browser already gets right,
526 // including the label association, the space key and the mixed state a
527 // screen reader announces.
528 if let Some(ticked) = row.selected {
529 out.push_str("<input type=\"checkbox\"");
530 class_attr(&["row-select"], opts, out);
531 if ticked {
532 out.push_str(" checked");
533 }
534 // What the tick contributes to the screen's selection, under the one
535 // name a handler reads it back by. `5f2b8753`: the app used to bind
536 // this itself, gathering the checked boxes in JS, because nothing in
537 // the description said what the ticks were for.
538 //
539 // Named rather than left to the browser's `on`, which is what a
540 // checkbox with no value submits. `on` says a box was checked and not
541 // which one.
542 if let Some(value) = &row.value {
543 out.push_str(" name=\"");
544 out.push_str(&escape(quasi_router::Node::TICKED));
545 out.push_str("\" value=\"");
546 out.push_str(&escape(value));
547 out.push('"');
548 }
549 // A tick with no route is local state until something submits it, which
550 // is what a bulk checkbox is; the app binds those itself. A tick with
551 // one is the write, which is `14612ed8`, and htmx's own default trigger
552 // for an input is `change` — named anyway, so the markup says what it
553 // does rather than relying on a default holding.
554 if let Some(action) = &row.toggle {
555 action_attrs(action, Fires::Change, None, morphs, None, out);
556 }
557 out.push_str(" aria-label=\"Select\">");
558 }
559
560 // A span per run of consecutive parts sharing a role, rather than a fixed
561 // sequence of members. The old shape drew primary, secondary, meta, bar,
562 // tokens, actions in that order however the description was built; the run
563 // draws what it was given where it was put, and a row with a tag between
564 // two facts now says so.
565 //
566 // Consecutive same-role parts share one wrapping span for the reason a
567 // cell's tokens do: the part class carries the gap between siblings, so a
568 // span each would space two badges as though they were unrelated.
569 let mut rest = row.parts.as_slice();
570 while let Some(head) = rest.first() {
571 let role = head.role;
572 let taken = rest.iter().take_while(|part| part.role == role).count();
573 let (group, tail) = rest.split_at(taken);
574 row_part_html(row, role, group, morphs, opts, out);
575 rest = tail;
576 }
577
578 // After the run, because a menu is not on the line: it is the set of things
579 // that can be done to the row, and it renders as a container the host opens
580 // its own way. A webview hangs a context menu off it, a touch host an action
581 // sheet, a terminal a key-driven list; all three read the same acts.
582 if !row.menu.is_empty() {
583 out.push_str("<div");
584 class_attr(&["row-menu"], opts, out);
585 // Named rather than hidden by class, so the host's own menu code has
586 // something to bind to that does not depend on how it is styled.
587 out.push_str(" data-menu=\"row\" hidden>");
588 for act in &row.menu {
589 act_html(act, morphs, opts, out);
590 }
591 out.push_str("</div>");
592 }
593
594 out.push_str("</li>");
595 }
596
597 /// One run of consecutive row parts sharing a role.
598 fn row_part_html(
599 row: &Row,
600 role: layout::RowPart,
601 group: &[quasi_router::Part],
602 morphs: bool,
603 opts: &Emit,
604 out: &mut String,
605 ) {
606 // The primary is a control when selecting the row does something, and plain
607 // text when it does not. Emitting a button either way would give a screen
608 // reader an affordance that answers nothing. `activate` stayed a field
609 // through the run migration, so this is still the row's own answer rather
610 // than something recomputed from a part.
611 let activates = role == layout::RowPart::Primary && row.activate.is_some();
612
613 let close = if let (true, Some(action)) = (activates, row.activate.as_ref()) {
614 let (open, close) = control_tag(action);
615 out.push_str(open);
616 class_attr(&["row-activate"], opts, out);
617 action_attrs(action, Fires::Click, None, morphs, None, out);
618 out.push('>');
619 close
620 } else {
621 out.push_str("<span");
622 class_attr(&[part_class(role)], opts, out);
623 out.push('>');
624 "</span>"
625 };
626
627 for part in group {
628 row_inline_html(&part.node, morphs, opts, out);
629 }
630
631 out.push_str(close);
632 }
633
634 /// One leaf inside a row's run.
635 ///
636 /// Markdown is the one place a run entry is not just `node_html`. A
637 /// [`Node::Rich`] standing on its own is a block and renders as one; inside a
638 /// row it goes through docengine's `phrase` preset instead, which is markdown
639 /// with no block structure at all and no links, keeping the inline emphasis. A
640 /// heading, a list and a quote each contribute their words without claiming a
641 /// block of a row that has no room for one, and not even a paragraph survives.
642 ///
643 /// Links go because a row usually carries [`Row::activate`], so the row itself
644 /// is already a target and an anchor inside it is a second target inside the
645 /// first: ambiguous to click, worse to reach by keyboard, and pointing
646 /// somewhere a one-line summary cannot usefully send anyone. Their text stays.
647 ///
648 /// This is the renderer deciding, which is the point of the description
649 /// carrying the kind rather than a flattened string. A terminal renderer facing
650 /// the same source can emit bold instead, and one that wants neither can call
651 /// `docengine::render_plain`. None of them has to be told by the screen author
652 /// which to do.
653 fn row_inline_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
654 match node {
655 Node::Text { text, .. } => out.push_str(&escape(text)),
656 Node::Rich { source } => out.push_str(&docengine::render_phrase(source)),
657 Node::Token(tag) => tag_html(tag, morphs, opts, out),
658 Node::Act(act) => act_html(act, morphs, opts, out),
659 Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
660 // Every other leaf the model admits into a run. A link in a row and a
661 // figure in a row were the two gaps the enumeration left open and could
662 // not close without a `RowPart` variant each; here they arrive by
663 // already being leaves.
664 //
665 // No fills: a bespoke region is a block and cannot reach a run, which
666 // is what the containment bound guarantees.
667 other => node_html(other, morphs, opts, &HashMap::new(), out),
668 }
669 }
670
671 /// One cell's inline run.
672 ///
673 /// A part per inline rather than a part per cell, which is the half of the
674 /// containment model this crate had to learn. Before it, a cell was four
675 /// members and this function was a fixed sequence: the value or the link, then
676 /// the tokens strip, then the actions strip. The run says the order itself, so
677 /// a cell holding a tag between two words draws that way instead of hoisting
678 /// the tag to the end.
679 ///
680 /// Consecutive tokens and consecutive acts still share one wrapping strip.
681 /// `cell-tokens` and `cell-actions` carry the gap between siblings, so a span
682 /// each would space them as though they were unrelated, and the common case --
683 /// a status column of three badges -- is exactly the consecutive one.
684 fn cell_run_html(cell: &Cell, morphs: bool, opts: &Emit) -> String {
685 // A cell that is one piece of text says so on the container through
686 // `CellPart::Value`, so a wrapper span here would say nothing the container
687 // has not. Anything else names its parts inside, or the content colour on
688 // the cell reaches the tokens and the controls beside the text -- the drift
689 // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
690 // emitting.
691 if let [Node::Text { text, .. }] = cell.parts.as_slice() {
692 return escape(text);
693 }
694
695 let mut out = String::new();
696 let mut rest = cell.parts.as_slice();
697 while let Some((head, tail)) = rest.split_first() {
698 match head {
699 Node::Text { text, .. } => {
700 out.push_str("<span");
701 class_attr(&[cell_part_class(layout::CellPart::Value)], opts, &mut out);
702 out.push('>');
703 out.push_str(&escape(text));
704 out.push_str("</span>");
705 rest = tail;
706 }
707 // The row is a `div` and not an anchor even when it activates, so
708 // this nests nothing: the href lands on the row element as an
709 // attribute htmx reads, and the only `<a>` in the row is the one a
710 // cell asked for.
711 //
712 // `data-act` for the same reason a button carries it. The row's
713 // `ClickBeside` filter keys on that attribute, so without it a click
714 // on the title would follow the link and open the row underneath.
715 Node::Link { text, action } => {
716 let (open, close) = control_tag(action);
717 out.push_str(open);
718 class_attr(&[cell_part_class(layout::CellPart::Link)], opts, &mut out);
719 out.push_str(" data-act");
720 action_attrs(action, Fires::Click, None, morphs, None, &mut out);
721 out.push('>');
722 out.push_str(&escape(text));
723 out.push_str(close);
724 rest = tail;
725 }
726 Node::Token(_) => {
727 let run = rest.iter().take_while(|p| matches!(p, Node::Token(_)));
728 out.push_str("<span");
729 class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, &mut out);
730 out.push('>');
731 let mut taken = 0;
732 for part in run {
733 if let Node::Token(tag) = part {
734 tag_html(tag, morphs, opts, &mut out);
735 }
736 taken += 1;
737 }
738 out.push_str("</span>");
739 rest = &rest[taken..];
740 }
741 // Deliberately not `row-actions`. That was a hover-reveal rule
742 // until makeover-webview 0.23.0 retired it, and it is a list row's
743 // class besides: `cell-actions` is the table's own, and it carries
744 // no colour so a button here is not painted as text.
745 Node::Act(_) => {
746 let run = rest.iter().take_while(|p| matches!(p, Node::Act(_)));
747 out.push_str("<span");
748 class_attr(
749 &[cell_part_class(layout::CellPart::Actions)],
750 opts,
751 &mut out,
752 );
753 out.push('>');
754 let mut taken = 0;
755 for part in run {
756 if let Node::Act(act) = part {
757 act_html(act, morphs, opts, &mut out);
758 }
759 taken += 1;
760 }
761 out.push_str("</span>");
762 rest = &rest[taken..];
763 }
764 // Every other leaf the model now admits into a run. A meter and a
765 // figure in a cell were the two gaps the enumeration left open and
766 // could not close without a member each; here they arrive by
767 // already being leaves.
768 other => {
769 // No fills: a bespoke region is a block and cannot reach a run,
770 // which the containment bound is what guarantees.
771 node_html(other, morphs, opts, &HashMap::new(), &mut out);
772 rest = tail;
773 }
774 }
775 }
776 out
777 }
778
779 /// One row of a table.
780 fn cells_row_html(
781 cells: &Cells,
782 columns: &[quasi_router::screen::Column],
783 morphs: bool,
784 opts: &Emit,
785 out: &mut String,
786 ) {
787 let mut classes = vec!["table-row"];
788 if cells.current {
789 // `table-row-current`, matching `row-current` on a list row. It was
790 // `table-row-selected` while the field was, so the class said one thing
791 // and the `aria-current` two lines down said the other.
792 classes.push("table-row-current");
793 }
794
795 out.push_str("<div role=\"row\"");
796 class_attr(&classes, opts, out);
797 if cells.current {
798 out.push_str(" aria-current=\"true\"");
799 }
800 if let Some(action) = &cells.activate {
801 // Any control in any cell, which is acts and links plus the chips that
802 // answer a click. A badge is not one and does not earn the filter. The
803 // walk moved onto `Cell` with the run, so this reads the description's
804 // answer rather than recomputing it from members.
805 let carries_control = cells.values.iter().any(Cell::carries_control);
806 let fires = if carries_control {
807 Fires::ClickBeside
808 } else {
809 Fires::Click
810 };
811 action_attrs(action, fires, None, morphs, None, out);
812 }
813 out.push('>');
814
815 // The cell contents are escaped here and handed over as Markup, which is
816 // makeover-webview's contract: it owns the structure, the caller owns what
817 // goes in. Ours is text from a description plus, since `022f0c59`, whatever
818 // controls the cell carries, and `cells_html` is what knows the column
819 // classes and the narrowing.
820 let filled: Vec<String> = cells
821 .values
822 .iter()
823 .map(|cell| cell_run_html(cell, morphs, opts))
824 .collect();
825 // The container says what the cell is only when the cell is nothing but one
826 // piece of text, which is the case where a wrapper span would say nothing
827 // the container has not already said. Anything else names its parts inside
828 // -- the anchor is a `cell-link`, the strips are `cell-tokens` and
829 // `cell-actions` -- because a colour on the container would reach all of
830 // them, and that is the drift makeover-layout 0.14.0 named.
831 let parts: Vec<Option<layout::CellPart>> = cells
832 .values
833 .iter()
834 .map(|cell| {
835 matches!(cell.parts.as_slice(), [Node::Text { .. }]).then_some(layout::CellPart::Value)
836 })
837 .collect();
838 let borrowed: Vec<layout::Column<'_>> = columns
839 .iter()
840 .map(quasi_router::screen::Column::as_layout)
841 .collect();
842 let cells: Vec<Emitted<'_>> = borrowed
843 .iter()
844 .zip(filled.iter())
845 .zip(parts)
846 .map(|((column, value), part)| Emitted {
847 column: column.name,
848 part,
849 content: Markup(value),
850 })
851 .collect();
852 out.push_str(&cells_html(&borrowed, &cells, opts));
853
854 out.push_str("</div>");
855 }
856
857 /// One thing on a screen.
858 pub(crate) fn node_html(
859 node: &Node,
860 morphs: bool,
861 opts: &Emit,
862 fills: &HashMap<String, String>,
863 out: &mut String,
864 ) {
865 match node {
866 Node::Heading { level, text } => {
867 let tag = match level {
868 layout::Heading::Page => "h1",
869 layout::Heading::Section => "h2",
870 layout::Heading::Subsection => "h3",
871 };
872 let _ = write!(out, "<{tag}");
873 class_attr(&["heading"], opts, out);
874 out.push('>');
875 out.push_str(&escape(text));
876 let _ = write!(out, "</{tag}>");
877 }
878
879 Node::Text { text, tone } => {
880 out.push_str("<p");
881 class_attr(&["text"], opts, out);
882 tone_attr(*tone, out);
883 out.push('>');
884 out.push_str(&escape(text));
885 out.push_str("</p>");
886 }
887
888 Node::StandIn {
889 state,
890 message,
891 act,
892 } => {
893 // The markup is makeover-webview's, unchanged, for the reason every
894 // other emitter here defers to it: the CSS that has to match it is
895 // emitted there too. The way out arrives as `Markup` because a
896 // button is an address and no crate down there names one.
897 let mut way_out = String::new();
898 if let Some(act) = act {
899 act_html(act, morphs, opts, &mut way_out);
900 }
901 out.push_str(&placeholder_html(
902 *state,
903 message,
904 (!way_out.is_empty()).then_some(Markup(way_out.as_str())),
905 opts,
906 ));
907 }
908
909 Node::Rich { source } => {
910 out.push_str("<div");
911 class_attr(&["rich"], opts, out);
912 out.push('>');
913 out.push_str(&rich_html(source));
914 out.push_str("</div>");
915 }
916
917 Node::Act(act) => act_html(act, morphs, opts, out),
918
919 // Text that goes somewhere. `control_tag` picks the anchor or the
920 // button from the method, the same way a cell link already did, and
921 // `link` is the class makeover names it with.
922 Node::Link { text, action } => {
923 let (open, close) = control_tag(action);
924 out.push_str(open);
925 class_attr(&["link"], opts, out);
926 out.push_str(" data-act");
927 action_attrs(action, Fires::Click, None, morphs, None, out);
928 out.push('>');
929 out.push_str(&escape(text));
930 out.push_str(close);
931 }
932
933 Node::Token(tag) => tag_html(tag, morphs, opts, out),
934
935 // One figure, through the same emitter the strip uses. What differs is
936 // that nothing wraps it: the run it sits in is already the grouping.
937 Node::Figure(figure) => {
938 out.push_str(&figure_html(&figure.as_layout(), opts));
939 }
940
941 Node::Notice { kind, tone, text } => {
942 out.push_str("<div");
943 class_attr(
944 &[match kind {
945 layout::Notice::Toast => "toast",
946 layout::Notice::Banner => "banner",
947 }],
948 opts,
949 out,
950 );
951 tone_attr(*tone, out);
952 // A danger or warning notice interrupts; anything else waits for a
953 // pause. The description already says which through its tone, so
954 // the renderer does not need a second field to be told.
955 let assertive = matches!(tone, layout::Tone::Danger | layout::Tone::Warning);
956 if assertive {
957 out.push_str(" role=\"alert\"");
958 } else {
959 out.push_str(" role=\"status\" aria-live=\"polite\"");
960 }
961 out.push('>');
962 out.push_str(&escape(text));
963 out.push_str("</div>");
964 }
965
966 Node::Field(field) => field_group_html(field, morphs, opts, out),
967
968 Node::Form {
969 action,
970 submit,
971 fields,
972 } => {
973 out.push_str("<form");
974 class_attr(&["form"], opts, out);
975 action_attrs(action, Fires::Click, None, morphs, None, out);
976 out.push('>');
977 for field in fields {
978 field_group_html(field, morphs, opts, out);
979 }
980 out.push_str("<button type=\"submit\"");
981 class_attr(&["button", "act-submit"], opts, out);
982 out.push('>');
983 out.push_str(&escape(submit));
984 out.push_str("</button></form>");
985 }
986
987 Node::List { rows, more } => {
988 out.push_str("<ul");
989 class_attr(&["list"], opts, out);
990 out.push('>');
991 for row in rows {
992 row_html(row, morphs, opts, out);
993 }
994 out.push_str("</ul>");
995
996 // Outside the list, because it is not one of the things in it. A
997 // renderer that wanted numbered pages instead would put them here
998 // too; what the description said is that there is more and how to
999 // ask, and this is one host's answer to that.
1000 if let Some(rest) = more {
1001 out.push_str("<div");
1002 class_attr(&["rest"], opts, out);
1003 out.push('>');
1004
1005 let (open, close) = control_tag(&rest.action);
1006 out.push_str(open);
1007 class_attr(&["button", "rest-more"], opts, out);
1008 action_attrs(&rest.action, Fires::Click, None, morphs, None, out);
1009 out.push('>');
1010 match rest.remaining {
1011 Some(n) => {
1012 out.push_str("Show more (");
1013 out.push_str(&n.to_string());
1014 out.push_str(" remaining)");
1015 }
1016 None => out.push_str("Show more"),
1017 }
1018 out.push_str(close);
1019 out.push_str("</div>");
1020 }
1021 }
1022
1023 Node::Table { columns, rows } => {
1024 let borrowed: Vec<layout::Column<'_>> = columns
1025 .iter()
1026 .map(quasi_router::screen::Column::as_layout)
1027 .collect();
1028 // No CSS travels with the table, and none can. A described table's
1029 // columns are known here rather than at build time, so a track list
1030 // would have to be emitted per table: a `<style>` element beside it,
1031 // which needs `style-src 'unsafe-inline'`, or a head block, which a
1032 // fragment swap does not carry. makeover's stylesheet lays the table
1033 // out with `display: table` instead, aligning columns across rows
1034 // knowing nothing about how many there are, and hides a dropped
1035 // column by the drop class its cells carry. The markup owes the
1036 // cells and headings those classes and nothing more.
1037 out.push_str("<div role=\"table\"");
1038 class_attr(&["table"], opts, out);
1039 out.push('>');
1040
1041 out.push_str("<div role=\"row\"");
1042 class_attr(&["table-head"], opts, out);
1043 out.push('>');
1044 for (column, described) in borrowed.iter().zip(columns) {
1045 out.push_str("<span role=\"columnheader\"");
1046 // The heading carries the same classes its column's cells do,
1047 // or the header and the body disagree about which column just
1048 // dropped and every heading below the cut sits over the wrong
1049 // values. `column_classes` is makeover's, so the two lists
1050 // cannot be assembled differently in two places.
1051 out.push_str(" class=\"");
1052 out.push_str(&escape(&class("table-heading", opts)));
1053 out.push(' ');
1054 out.push_str(&escape(&column_classes(column, opts)));
1055 out.push('"');
1056 // `aria-sort` is what a table actually says about its order;
1057 // the caret in `state_rules` is this renderer's expression of
1058 // the same fact for everyone not using a screen reader.
1059 if let Some(sort) = column.sorted {
1060 out.push_str(" aria-sort=\"");
1061 out.push_str(sort.as_str());
1062 out.push('"');
1063 }
1064 if column.sortable {
1065 out.push_str(" data-sortable");
1066 }
1067 out.push('>');
1068 match &described.reorder {
1069 // A button inside the header cell rather than attributes on
1070 // the cell itself: `role="columnheader"` is not a control,
1071 // and a screen reader offered a press on something that
1072 // announces itself as a heading has been lied to.
1073 Some(action) => {
1074 let (open, close) = control_tag(action);
1075 out.push_str(open);
1076 class_attr(&["table-sort"], opts, out);
1077 action_attrs(action, Fires::Click, None, morphs, None, out);
1078 out.push('>');
1079 out.push_str(&escape(column.name));
1080 out.push_str(close);
1081 }
1082 None => out.push_str(&escape(column.name)),
1083 }
1084 out.push_str("</span>");
1085 }
1086 out.push_str("</div>");
1087
1088 for cells in rows {
1089 cells_row_html(cells, columns, morphs, opts, out);
1090 }
1091 out.push_str("</div>");
1092 }
1093
1094 Node::Select {
1095 kind,
1096 options,
1097 chosen,
1098 action,
1099 } => select_html(
1100 *kind,
1101 options,
1102 chosen.as_deref(),
1103 action.as_ref(),
1104 morphs,
1105 opts,
1106 out,
1107 ),
1108
1109 // The trough and its tones are makeover-webview's, unchanged, for the
1110 // same reason the field markup is: a second emitter here would be the
1111 // same anatomy with a different escaping story, and the CSS it has to
1112 // match is emitted there too.
1113 Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
1114
1115 // The strip and its tones are makeover-webview's too, and the actions
1116 // are this crate's: a figure that answers a click becomes a control
1117 // wrapped round the emitted markup rather than a second figure emitter
1118 // that knows about routes.
1119 Node::Stats { figures } => {
1120 out.push_str("<div");
1121 class_attr(&["figures"], opts, out);
1122 out.push('>');
1123 for (figure, action) in figures {
1124 match action {
1125 Some(action) => {
1126 let (open, close) = control_tag(action);
1127 out.push_str(open);
1128 class_attr(&["figure-act"], opts, out);
1129 action_attrs(action, Fires::Click, None, morphs, None, out);
1130 out.push('>');
1131 out.push_str(&figure_html(&figure.as_layout(), opts));
1132 out.push_str(close);
1133 }
1134 None => out.push_str(&figure_html(&figure.as_layout(), opts)),
1135 }
1136 }
1137 out.push_str("</div>");
1138 }
1139
1140 Node::Region(slot) => slot_html(slot, morphs, opts, fills, out),
1141 }
1142 }
1143
1144 /// A small labelled thing sitting inside something else.
1145 ///
1146 /// Took eight arguments, one per field of `Node::Token`, until the payload
1147 /// became [`Tag`] so a row could carry one. The `too_many_arguments` allow went
1148 /// with them.
1149 fn tag_html(tag: &Tag, morphs: bool, opts: &Emit, out: &mut String) {
1150 let Tag {
1151 kind,
1152 label,
1153 tone,
1154 latched,
1155 action,
1156 } = tag;
1157 let (kind, tone, latched) = (*kind, *tone, *latched);
1158 let action = action.as_ref();
1159
1160 let mut classes = vec![match kind {
1161 layout::Token::Badge => "badge",
1162 layout::Token::Chip { .. } => "chip",
1163 }];
1164 // `latched`, which is what makeover styles: `.chip.latched` is the pressed
1165 // depth a chip holds itself down with. This said `chip-latched`, a third
1166 // name for it, and a latched chip therefore looked exactly like an
1167 // unlatched one.
1168 if latched {
1169 classes.push("latched");
1170 }
1171
1172 // A badge answers no click, so it is not a button however it is styled.
1173 // The description says which through the kind, which is the whole reason
1174 // the two are separate members rather than one with a flag.
1175 let interactive = kind.interactive() && action.is_some();
1176 let close = if interactive {
1177 // An interactive tag whose destination leaves the app is an anchor, for
1178 // the reason `control_tag` gives. A tag that answers nothing stays a
1179 // span either way.
1180 let (open, close) = action.map_or(("<span", "</span>"), control_tag);
1181 out.push_str(open);
1182 close
1183 } else {
1184 out.push_str("<span");
1185 "</span>"
1186 };
1187 class_attr(&classes, opts, out);
1188 tone_attr(tone, out);
1189
1190 if interactive {
1191 // `data-act` for the same reason a button carries it: a chip that
1192 // answers a click is a control, and a table row filtering its own
1193 // trigger has to be able to tell one apart from its own text. A badge
1194 // never gets it, because a badge answers nothing.
1195 out.push_str(" data-act");
1196 if latched {
1197 // A chip standing for a filter is on or off, and its latched class
1198 // carries that fact visually through Depth::pressed either way.
1199 // Which word says it depends on what the chip turned out to be:
1200 // aria-pressed is a button's state and means nothing on an anchor,
1201 // and a link that is the view you are looking at is the one thing
1202 // aria-current exists to say.
1203 if action.is_some_and(is_link) {
1204 out.push_str(" aria-current=\"true\"");
1205 } else {
1206 out.push_str(" aria-pressed=\"true\"");
1207 }
1208 }
1209 if let Some(action) = action {
1210 action_attrs(action, Fires::Click, None, morphs, None, out);
1211 }
1212 }
1213
1214 out.push('>');
1215 out.push_str(&escape(label));
1216 if matches!(kind, layout::Token::Chip { removable: true }) {
1217 out.push_str("<span");
1218 class_attr(&["chip-remove"], opts, out);
1219 out.push_str(" aria-hidden=\"true\"></span>");
1220 }
1221 out.push_str(close);
1222 }
1223
1224 /// A control that picks between things.
1225 ///
1226 /// # Which element carries the kind
1227 ///
1228 /// The option does, because that is what makeover styles: `selector_rules`
1229 /// writes the depth, the focus ring and the chosen state for `.tab`,
1230 /// `.segment` and `.toggle`, and each of those is the thing that gets picked
1231 /// rather than the thing holding them. This emitted `tabs` / `segmented` /
1232 /// `option` and put `toggle` on the wrapping div, so every described selector
1233 /// came out flat, and a toggle group took the bevel meant for its buttons. The
1234 /// fourth instance of the gap the SSH-keys tab found three of, and the reason
1235 /// `every_class_this_renderer_emits_is_one_makeover_defines` now enumerates
1236 /// instead of remembering.
1237 ///
1238 /// The group keeps one name, `selector`, and says which kind it is in
1239 /// `data-selector`. An attribute for the same reason `data-tone` is one: the
1240 /// group is a spacing question, spacing is `makeover-geometry`'s, and a class
1241 /// there would read as the styling hook the option's class actually is.
1242 fn select_html(
1243 kind: layout::Selector,
1244 options: &[(quasi_router::screen::Choice, Option<Action>)],
1245 chosen: Option<&str>,
1246 action: Option<&Action>,
1247 morphs: bool,
1248 opts: &Emit,
1249 out: &mut String,
1250 ) {
1251 out.push_str("<div");
1252 class_attr(&["selector"], opts, out);
1253 out.push_str(" data-selector=\"");
1254 out.push_str(option_class(kind));
1255 out.push('"');
1256 // Tabs are navigation between panes, which is a tablist. The other two pick
1257 // a value and are a group of buttons.
1258 if matches!(kind, layout::Selector::Tabs) {
1259 out.push_str(" role=\"tablist\"");
1260 } else {
1261 out.push_str(" role=\"group\"");
1262 }
1263 out.push('>');
1264
1265 for (option, own) in options {
1266 let picked = chosen.is_some_and(|value| value == option.value);
1267 // `chosen` is makeover's name for this, the way `latched` is on a chip.
1268 let mut classes = vec![option_class(kind)];
1269 if picked {
1270 classes.push("chosen");
1271 }
1272
1273 // An option naming its own route is a link when that route is a read,
1274 // for the reason every other read here is: middle-click, copy-link, a
1275 // crawler and the page with JS off. A tab panel is fetched with a GET,
1276 // so a tab strip is fifteen links rather than fifteen buttons.
1277 let (open, close) = own
1278 .as_ref()
1279 .map_or(("<button type=\"button\"", "</button>"), |action| {
1280 control_tag(action)
1281 });
1282 out.push_str(open);
1283 class_attr(&classes, opts, out);
1284 if matches!(kind, layout::Selector::Tabs) {
1285 out.push_str(" role=\"tab\" aria-selected=\"");
1286 out.push_str(if picked { "true\"" } else { "false\"" });
1287 } else if picked {
1288 out.push_str(" aria-pressed=\"true\"");
1289 }
1290
1291 // The option's own route wins, and there is nothing to substitute into
1292 // it: it already names the panel. The strip's action is the fallback,
1293 // and it is the one that needs the picked value, because it is one
1294 // route standing for all the options.
1295 if let Some(own) = own {
1296 action_attrs(own, Fires::Click, None, morphs, None, out);
1297 } else if let Some(action) = action {
1298 // The picked value travels under one name, decided once in
1299 // `Node::SELECTED`, rather than agreed per screen between a
1300 // renderer and a handler.
1301 let carrying = action.clone().with(Node::SELECTED, option.value.clone());
1302 action_attrs(&carrying, Fires::Click, None, morphs, None, out);
1303 }
1304
1305 out.push('>');
1306 out.push_str(&escape(&option.label));
1307 out.push_str(close);
1308 }
1309
1310 out.push_str("</div>");
1311 }
1312
1313 /// A region the answer changed without being aimed at it.
1314 ///
1315 /// The second half of the transport, and the reason this file's htmx test
1316 /// names two functions rather than one. [`action_attrs`] says where a control
1317 /// sends and where the answer lands; this says where a piece of the answer
1318 /// lands that no control asked for. Both are the same fact — how htmx is told
1319 /// to put markup somewhere — and a transport swapped for fixi moves both, which
1320 /// is what decision 13's claim needs.
1321 ///
1322 /// # Why `innerHTML:` and not a bare `true`
1323 ///
1324 /// A bare `hx-swap-oob="true"` replaces the element carrying the matching id
1325 /// outright, and the element in the document is [`slot_html`]'s `<div>` with
1326 /// the region's classes on it. Replacing it with what is emitted here would
1327 /// strip them, so the region would keep its contents and lose its layout.
1328 /// Addressing the swap by selector instead puts the markup *inside* the slot
1329 /// and leaves the wrapper alone, which is what [`Serves::fragment`] already
1330 /// means for the targeted region. The wrapper emitted here is htmx's envelope
1331 /// and never reaches the document.
1332 ///
1333 /// [`Serves::fragment`]: quasi_http::Serves::fragment
1334 pub(crate) fn oob_html(
1335 region: &str,
1336 node: &Node,
1337 morphs: bool,
1338 opts: &Emit,
1339 fills: &HashMap<String, String>,
1340 out: &mut String,
1341 ) {
1342 out.push_str("<div hx-swap-oob=\"innerHTML:#");
1343 // Escaped as an attribute, and otherwise untouched for `slot_html`'s
1344 // reason: this has to match the id that function emitted, and a slot id is
1345 // the address the description chose.
1346 out.push_str(&escape(region));
1347 out.push_str("\">");
1348 node_html(node, morphs, opts, fills, out);
1349 out.push_str("</div>");
1350 }
1351
1352 /// A region, and everything under it.
1353 pub(crate) fn slot_html(
1354 slot: &Slot,
1355 morphs: bool,
1356 opts: &Emit,
1357 fills: &HashMap<String, String>,
1358 out: &mut String,
1359 ) {
1360 let kind = match &slot.kind {
1361 quasi_router::RegionKind::Band => "band",
1362 quasi_router::RegionKind::Sidebar => "sidebar",
1363 quasi_router::RegionKind::Pane => "pane",
1364 quasi_router::RegionKind::Split => "split",
1365 quasi_router::RegionKind::TabGroup => "tabgroup",
1366 quasi_router::RegionKind::Modal => "modal",
1367 quasi_router::RegionKind::Bespoke { .. } => "bespoke",
1368 };
1369
1370 out.push_str("<div id=\"");
1371 // The id is the fragment's address. It is escaped and otherwise untouched:
1372 // rewriting it would break the HX-Retarget the router just sent, which
1373 // names the slot's own id.
1374 out.push_str(&escape(&slot.id));
1375 out.push('"');
1376 class_attr(&["region", kind], opts, out);
1377
1378 if let quasi_router::RegionKind::Bespoke { name } = &slot.kind {
1379 // Never interpreted, per decision 4. Handed to the app under a name it
1380 // chose, which is the entire contract for a bespoke region.
1381 out.push_str(" data-bespoke=\"");
1382 out.push_str(&escape(name));
1383 out.push('"');
1384 }
1385
1386 if matches!(slot.readiness, layout::Readiness::Pending) {
1387 out.push_str(" aria-busy=\"true\"");
1388 }
1389
1390 if matches!(slot.kind, quasi_router::RegionKind::Modal) {
1391 // A modal takes input until dismissed, which is what modal means, and
1392 // saying so is the renderer's job rather than the app's.
1393 out.push_str(" role=\"dialog\" aria-modal=\"true\"");
1394 }
1395
1396 out.push('>');
1397 for node in &slot.body {
1398 node_html(node, morphs, opts, fills, out);
1399 }
1400
1401 // The host's markup for this region, after whatever the description put
1402 // here, and only for a bespoke one: a fill named against a pane is a host
1403 // reaching into a region the description already owns.
1404 //
1405 // Verbatim. See `Webview::fills` for why that is not the hole it looks
1406 // like: the string is host code's, never a description's, so the escaping
1407 // guarantee the whole vocabulary rests on is untouched.
1408 if matches!(slot.kind, quasi_router::RegionKind::Bespoke { .. })
1409 && let Some(fill) = fills.get(&slot.id)
1410 {
1411 out.push_str(fill);
1412 }
1413
1414 out.push_str("</div>");
1415 }
1416