Skip to main content

max / quasi

31.1 KB · 751 lines History Blame Raw
1 //! The app's own affordances, emitted once per document.
2 //!
3 //! [`Chrome`] names keys that work from every screen and what they call. This
4 //! is the webview's answer to them: one hidden element per binding, carrying
5 //! the same transport attributes any control gets, fired by a key event on the
6 //! body rather than by a click on itself. No custom JS, and no per-app copy of
7 //! the palette's plumbing.
8 //!
9 //! Plus [`Chrome::panels`], the things the app keeps on screen whatever screen
10 //! is showing: one element each, carrying the panel's id and holding its node
11 //! drawn the way any node is drawn. The id is what an answer aims at, so a route
12 //! that has changed what a panel says reaches it through
13 //! [`Response::also`](quasi_router::Response::also) with no second mechanism.
14 //! Where each sits is left to the stylesheet, which is this host's answer to the
15 //! placement question the description declines: a browser has a stylesheet, and
16 //! the class is the hook.
17 //!
18 //! Since `71aa29b4` there can be several, and each carries a
19 //! [`Role`](quasi_router::Role). The role rides out as `data-role`, beside the
20 //! shared `chrome-panel` class rather than instead of it: the class is what a
21 //! stylesheet written before roles existed selects on, and an app with one panel
22 //! gets the same element it always did plus one attribute.
23 //!
24 //! Plus [`Chrome::nav`], the places the app has. Drawn as a `nav` of links, one
25 //! level of nesting for the places inside a place, and marked with
26 //! `aria-current="page"` where the screen said it is. That is the same attribute
27 //! [`Row::current`](quasi_router::screen::Row::current) already emits, because
28 //! it is the same claim: this is the one showing.
29 //!
30 //! Plus the overlay container, which is where an
31 //! [`Outcome::Over`](quasi_router::Outcome::Over) lands. It is emitted empty
32 //! and stays empty until something is drawn into it, so a document with chrome
33 //! and no overlay open is a document with one spare `div` in it. That spare
34 //! `div` is the rule and not an edge case: any app declaring chrome gets one,
35 //! because a route can answer `Over` without a binding to open it, and
36 //! [`overlay_target`](quasi_http::Serves::overlay_target) promises this
37 //! renderer has a container to aim at whatever the app declared.
38 //!
39 //! # Interpreting a key name is this crate's job, not the description's
40 //!
41 //! [`Binding::key`](quasi_router::Binding::key) is text — "ctrl+k", "?" —
42 //! because the vocabulary of keys is the host's. This is a host, so here is
43 //! where the text becomes something concrete: an htmx trigger filter over
44 //! `KeyboardEvent`. A name this renderer cannot parse is ignored, which is the
45 //! rule `Act::key` already states for a key one host wants and another has
46 //! never heard of.
47
48 use makeover_webview::Emit;
49 use makeover_webview::form::escape_into;
50 use quasi_router::{Band, Binding, Brand, Chrome, Disclose, Panel, Place};
51
52 use crate::node::{Doc, Fires, action_attrs, class_into, node_html};
53
54 /// The element an overlay is drawn into.
55 ///
56 /// A fixed id rather than a configurable one: the router names the outcome and
57 /// the renderer names the place, and a host that could rename it is a host that
58 /// can rename it to something the retarget header does not point at.
59 pub const OVERLAY_ID: &str = "quasi-overlay";
60
61 /// The bindings, the overlay container and the panel, for the end of a
62 /// document's body.
63 ///
64 /// Empty when the app declares no chrome. An app that declares none gets a
65 /// document byte-for-byte the same as before chrome existed, which is what
66 /// makes this additive.
67 pub(crate) fn chrome_html(chrome: &Chrome, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
68 for binding in &chrome.bindings {
69 binding_html(binding, out);
70 }
71 // Emitted whenever the app declares any chrome, because a route can answer
72 // `Outcome::Over` without a binding being involved: a control on a screen
73 // calls a route, and the route answers with an overlay. Bindings were the
74 // wrong condition. They were the only way an overlay was opened when this
75 // was written, and goingson opens one from a row's control with no binding
76 // anywhere in its chrome, so the guard dropped the container out of exactly
77 // the document that needed it.
78 //
79 // Not unconditional, so an app declaring no chrome at all still gets the
80 // byte-for-byte document above.
81 //
82 // The comment rides in front rather than inside: an overlay swap replaces
83 // the container's contents, so a comment within it would be destroyed by
84 // the first `Over` and gone by the time anyone inspected the element.
85 if !chrome.bindings.is_empty() || !chrome.panels.is_empty() || !chrome.nav.is_empty() {
86 out.push_str("<!-- quasi: overlay container; an Outcome::Over lands here -->");
87 out.push_str("<div id=\"");
88 out.push_str(OVERLAY_ID);
89 out.push_str("\" data-chrome=\"overlay\"></div>");
90 }
91 for panel in &chrome.panels {
92 panel_html(panel, opts, doc, out);
93 }
94 }
95
96 /// The id of the checkbox a disclosed nav is opened by.
97 ///
98 /// A fixed id for [`OVERLAY_ID`]'s reason: the label points at it by name, and
99 /// a host that could rename it is a host that can rename it to something the
100 /// label does not reach.
101 pub const DISCLOSE_ID: &str = "quasi-disclose";
102
103 /// The header band: the brand, the disclosure, the search box and the nav, as
104 /// one element before the content.
105 ///
106 /// One element is the whole point of the member. A stylesheet cannot make a bar
107 /// out of siblings that are not siblings, and a narrow-viewport menu written as
108 /// a checkbox styling what follows it matches nothing when the checkbox and the
109 /// nav are in different parents. So this emits `<header>` and the nav goes
110 /// inside it, rather than [`nav_html`] running on its own.
111 ///
112 /// # The order, and why it is not the description's
113 ///
114 /// Brand, checkbox, label, search, nav. The checkbox comes before everything it
115 /// is meant to disclose, because `~` reaches forward and only forward: a
116 /// toggle written after the search box could never style it. That is a fact
117 /// about CSS rather than about the app, which is exactly the kind of thing a
118 /// description should not be carrying.
119 ///
120 /// # No JavaScript
121 ///
122 /// The checkbox is the mechanism, the same one the hand-written headers this
123 /// replaces already used. A disclosure that needed a script would be a
124 /// disclosure that does not work while the bundle is loading, which is when a
125 /// reader on a phone first reaches for the menu.
126 /// The header a document opens with: the band when the app declared one, the
127 /// bare nav when it did not, and nothing when it has neither.
128 ///
129 /// Public because a host that assembles its own body needs it and cannot reach
130 /// what [`Parts`](crate::Parts) carries: `Parts::body_chrome` is the *end* of
131 /// the body -- the bindings, the overlay container and the panels -- and a
132 /// header goes before the content that host is about to write. Without this a
133 /// host with Askama pages beside its described ones has to hand-write the
134 /// header for the templated half, which is exactly the divergence
135 /// [`Chrome::band`] exists to end.
136 ///
137 /// `at` is the [`Place::key`] of the place showing, or `None` where the host
138 /// has no screen to ask. Nothing is marked in that case, rather than the last
139 /// place staying lit.
140 #[must_use]
141 pub fn header_html(chrome: &Chrome, at: Option<&str>, opts: &Emit) -> String {
142 let mut out = String::with_capacity(512);
143 match &chrome.band {
144 Some(band) => band_html(band, &chrome.nav, at, opts, &Doc::bare(), &mut out),
145 None => nav_html(&chrome.nav, at, opts, &mut out),
146 }
147 out
148 }
149
150 pub(crate) fn band_html(
151 band: &Band,
152 places: &[Place],
153 at: Option<&str>,
154 opts: &Emit,
155 doc: &Doc<'_>,
156 out: &mut String,
157 ) {
158 out.push_str("<header class=\"");
159 class_into("chrome-band", opts, out);
160 out.push_str("\" role=\"banner\" data-chrome=\"band\">");
161
162 if let Some(brand) = &band.brand {
163 brand_html(brand, opts, out);
164 }
165
166 if band.disclose == Disclose::Narrow {
167 disclose_html(opts, out);
168 }
169
170 if let Some(search) = &band.search {
171 out.push_str("<div class=\"");
172 class_into("chrome-search", opts, out);
173 out.push_str("\" role=\"search\">");
174 // The same field emitter every question goes through. A search box in
175 // the header is not a second kind of box, and this crate has never had
176 // a second field emitter to give it one.
177 node_html(
178 &quasi_router::Node::Field(Box::new(search.clone())),
179 opts,
180 doc,
181 out,
182 );
183 out.push_str("</div>");
184 }
185
186 nav_html(places, at, opts, out);
187
188 out.push_str("</header>");
189 }
190
191 /// The wordmark: the name, with its one marked run drawn as the mark.
192 ///
193 /// A link, because it goes somewhere, and for [`place_html`]'s reasons: a
194 /// middle click opens it in a tab and the status bar says where it goes.
195 ///
196 /// The mark is not hidden from a screen reader. `Makenot.work` is a domain and
197 /// hearing "Makenot dot work" is hearing the name; the hand-written header this
198 /// replaces marked the dot `aria-hidden`, which left the accessible name as
199 /// "Makenotwork", something the app is not called.
200 fn brand_html(brand: &Brand, opts: &Emit, out: &mut String) {
201 let (before, mark, after) = brand.parts();
202 out.push_str("<a class=\"");
203 class_into("chrome-brand", opts, out);
204 out.push('"');
205 action_attrs(&brand.action, Fires::Click, None, None, false, out);
206 out.push('>');
207 escape_into(before, out);
208 if !mark.is_empty() {
209 out.push_str("<span class=\"");
210 class_into("chrome-brand-mark", opts, out);
211 out.push_str("\">");
212 escape_into(mark, out);
213 out.push_str("</span>");
214 }
215 escape_into(after, out);
216 out.push_str("</a>");
217 }
218
219 /// The checkbox and its label, for a nav that hides when there is no room.
220 ///
221 /// A `<label>` and not a button: the checkbox holds the state, and a button
222 /// would need a script to change it. The three spans are the bars of the
223 /// control, left to the stylesheet to draw, which is this renderer's standing
224 /// answer to a placement question a description declines.
225 fn disclose_html(opts: &Emit, out: &mut String) {
226 out.push_str("<input type=\"checkbox\" id=\"");
227 out.push_str(DISCLOSE_ID);
228 out.push_str("\" class=\"");
229 class_into("chrome-disclose-state", opts, out);
230 out.push_str("\" aria-hidden=\"true\"><label for=\"");
231 out.push_str(DISCLOSE_ID);
232 out.push_str("\" class=\"");
233 class_into("chrome-disclose", opts, out);
234 out.push_str("\" aria-label=\"Toggle navigation menu\">");
235 out.push_str("<span></span><span></span><span></span></label>");
236 }
237
238 /// The nav: the app's places, as links, with the current one marked.
239 ///
240 /// Emitted before the screen rather than with the rest of the chrome, and that
241 /// is the one placement decision this renderer makes. It makes it in document
242 /// order and not in pixels: a navigation that comes after the content is a
243 /// landmark a screen reader reaches last and a block a stylesheet has to lift
244 /// with `position: fixed` and then pay for in padding. Where it lands visually
245 /// is still the stylesheet's, the same as a panel's.
246 ///
247 /// A `nav` element and not a list of buttons. These are addresses, so they are
248 /// links: middle-click opens one in a tab, the status bar shows where it goes,
249 /// and a screen reader lands on a navigation landmark. None of that is
250 /// available to a button that calls a route.
251 ///
252 /// One level of nesting, which is the depth [`Place::within`] describes. The
253 /// inner list is emitted inside its place's item rather than beside it, so the
254 /// structure a description stated is the structure the document has and a
255 /// stylesheet can draw either level whichever way it likes.
256 pub(crate) fn nav_html(places: &[Place], at: Option<&str>, opts: &Emit, out: &mut String) {
257 if places.is_empty() {
258 return;
259 }
260 out.push_str("<nav class=\"");
261 class_into("chrome-nav", opts, out);
262 out.push_str("\" data-chrome=\"nav\">");
263 place_list(places, at, opts, out);
264 out.push_str("</nav>");
265 }
266
267 /// One level of places, as a list.
268 fn place_list(places: &[Place], at: Option<&str>, opts: &Emit, out: &mut String) {
269 out.push_str("<ul>");
270 for place in places {
271 out.push_str("<li>");
272 place_html(place, at, opts, out);
273 if !place.within.is_empty() {
274 place_list(&place.within, at, opts, out);
275 }
276 out.push_str("</li>");
277 }
278 out.push_str("</ul>");
279 }
280
281 /// One place, as the link that goes there.
282 ///
283 /// A place holding others is marked when the screen is any of them, which is
284 /// what [`Place::holds`] answers. Asked of the nav rather than worked out here,
285 /// so the three renderers cannot disagree about how deep the search goes.
286 fn place_html(place: &Place, at: Option<&str>, opts: &Emit, out: &mut String) {
287 let current = at.is_some_and(|key| place.holds(key));
288 out.push_str("<a class=\"");
289 class_into("chrome-place", opts, out);
290 out.push('"');
291 // The key, so a host script can find a place it cares about. `Place::key`
292 // is an identifier the app chose and never display text, which is exactly
293 // what a selector wants; without it a script looking for one place has only
294 // the label, which is renamed, translated and reworded to fit.
295 //
296 // A data attribute rather than an id: two places may carry one key -- the
297 // vocabulary says so, and calls it an app pointing at itself twice -- and
298 // two elements under one id is markup a browser resolves by picking the
299 // first.
300 out.push_str(" data-place=\"");
301 escape_into(&place.key, out);
302 out.push('"');
303 if current {
304 // The same attribute a current row emits. A CSS hook and an
305 // accessibility fact in one, rather than a class the stylesheet knows
306 // and a screen reader does not.
307 out.push_str(" aria-current=\"page\"");
308 }
309 action_attrs(&place.action, Fires::Click, None, None, false, out);
310 out.push('>');
311 escape_into(&place.label, out);
312 out.push_str("</a>");
313 }
314
315 /// The panel: its id, its class, and its node inside.
316 ///
317 /// The id is the address rather than decoration. An out-of-band swap aimed at
318 /// the panel writes `innerHTML:#<id>`, so the element survives the swap and
319 /// keeps the class the stylesheet places it by, which is the same arrangement
320 /// every region already has.
321 fn panel_html(panel: &Panel, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
322 out.push_str("<div id=\"");
323 escape_into(&panel.id, out);
324 out.push_str("\" class=\"");
325 class_into("chrome-panel", opts, out);
326 // Beside the class rather than instead of it. A stylesheet written when
327 // there was one panel selects `.chrome-panel` and still finds this one.
328 out.push_str("\" data-role=\"");
329 out.push_str(role_name(panel.role));
330 out.push_str("\">");
331 node_html(&panel.content, opts, doc, out);
332 out.push_str("</div>");
333 }
334
335 /// What a role is called in the document.
336 ///
337 /// Spelled here rather than derived, so a variant renamed in the vocabulary is
338 /// a compile error at this match instead of a silently different attribute.
339 fn role_name(role: quasi_router::Role) -> &'static str {
340 match role {
341 quasi_router::Role::Activity => "activity",
342 quasi_router::Role::Status => "status",
343 }
344 }
345
346 /// One binding: the transport of a control, the trigger of a keystroke.
347 fn binding_html(binding: &Binding, out: &mut String) {
348 let Some(filter) = trigger_filter(&binding.key) else {
349 // A key name this renderer does not understand. Ignored rather than
350 // guessed at, and ignored silently for the same reason a webview
351 // ignores a key a terminal wanted: it is not this host's keyboard.
352 return;
353 };
354
355 out.push_str("<button type=\"button\" hidden data-chrome aria-label=\"");
356 escape_into(&binding.label, out);
357 out.push('"');
358
359 // The answer goes into the overlay container. A binding whose route
360 // answers with something other than `Over` overrides this by naming its
361 // own target on the action, which `action_attrs` emits after this.
362 //
363 // Unless nothing is coming back. A binding that goes back makes no request,
364 // so a target would say an answer lands in the overlay when there is no
365 // answer -- the same lie the `Local` branch of `action_attrs` refuses to
366 // write an address for.
367 if !binding.action.destination.is_back() {
368 out.push_str(" hx-target=\"#");
369 out.push_str(OVERLAY_ID);
370 out.push('"');
371 }
372
373 action_attrs(&binding.action, Fires::Key(&filter), None, None, false, out);
374 out.push_str("></button>");
375 }
376
377 /// A key name as an htmx trigger filter, or `None` if it is not one.
378 ///
379 /// `"ctrl+k"` becomes `key=='k'&&ctrlKey&&!altKey&&!metaKey`. The negatives are
380 /// stated rather than left open: without them `ctrl+k` also fires on
381 /// `ctrl+alt+k`, and an app that bound both would fire both.
382 fn trigger_filter(key: &str) -> Option<String> {
383 let mut ctrl = false;
384 let mut alt = false;
385 let mut shift = false;
386 let mut meta = false;
387 let mut base = None;
388
389 for part in key.split('+') {
390 let part = part.trim();
391 if part.is_empty() {
392 return None;
393 }
394 match part.to_ascii_lowercase().as_str() {
395 "ctrl" | "control" => ctrl = true,
396 "alt" | "option" => alt = true,
397 "shift" => shift = true,
398 "meta" | "cmd" | "super" => meta = true,
399 // The last non-modifier wins nothing: two of them is a name this
400 // renderer does not understand, not a chord it can guess at.
401 _ if base.is_some() => return None,
402 _ => base = Some(part.to_string()),
403 }
404 }
405
406 let base = base?;
407 // A single printable character, or a name the DOM already uses for a key
408 // that prints nothing. `KeyboardEvent.key` is what both are compared
409 // against, and its names are capitalised.
410 let value = if base.chars().count() == 1 {
411 base
412 } else {
413 named_key(&base)?
414 };
415
416 let mut filter = format!("key=='{}'", js_string(&value));
417 for (held, name) in [
418 (ctrl, "ctrlKey"),
419 (alt, "altKey"),
420 (meta, "metaKey"),
421 (shift, "shiftKey"),
422 ] {
423 // Shift is asserted when asked for and never denied: a printable key
424 // that needs shift to type reports it held, so `?` on a US layout
425 // arrives as shift+/ and denying shift would make it unreachable.
426 if held {
427 filter.push_str("&&");
428 filter.push_str(name);
429 } else if name != "shiftKey" {
430 filter.push_str("&&!");
431 filter.push_str(name);
432 }
433 }
434 Some(filter)
435 }
436
437 /// The `KeyboardEvent.key` name for a key that prints nothing.
438 ///
439 /// A short list rather than every name in the spec: these are the ones a
440 /// description plausibly binds, and a name absent here is ignored rather than
441 /// passed through. Passing an unknown name through would emit a filter that
442 /// silently never matches, which is worse than not emitting one.
443 fn named_key(name: &str) -> Option<String> {
444 let named = match name {
445 "escape" | "esc" => "Escape",
446 "enter" | "return" => "Enter",
447 "tab" => "Tab",
448 "space" => " ",
449 "backspace" => "Backspace",
450 "delete" | "del" => "Delete",
451 "up" | "arrowup" => "ArrowUp",
452 "down" | "arrowdown" => "ArrowDown",
453 "left" | "arrowleft" => "ArrowLeft",
454 "right" | "arrowright" => "ArrowRight",
455 "home" => "Home",
456 "end" => "End",
457 "pageup" => "PageUp",
458 "pagedown" => "PageDown",
459 _ => return None,
460 };
461 Some(named.to_string())
462 }
463
464 /// A key value as the inside of a single-quoted JS string.
465 ///
466 /// The value reaches the browser inside an attribute inside a filter, so it is
467 /// escaped twice by two different rules: this one, then HTML escaping by
468 /// `action_attrs`. A key of `'` is the case that needs it.
469 fn js_string(value: &str) -> String {
470 value.replace('\\', "\\\\").replace('\'', "\\'")
471 }
472
473 #[cfg(test)]
474 mod tests {
475 use super::*;
476
477 #[test]
478 fn a_modifier_key_names_what_is_held_and_what_is_not() {
479 let filter = trigger_filter("ctrl+k").expect("parsed");
480 assert!(filter.contains("key=='k'"));
481 assert!(filter.contains("&&ctrlKey"));
482 // Stated, so ctrl+alt+k does not also fire a ctrl+k binding.
483 assert!(filter.contains("&&!altKey"));
484 assert!(filter.contains("&&!metaKey"));
485 }
486
487 #[test]
488 fn shift_is_asserted_when_asked_for_and_never_denied() {
489 // A printable key that needs shift to type reports it held, so denying
490 // it would make `?` unreachable on a US layout.
491 let plain = trigger_filter("?").expect("parsed");
492 assert!(!plain.contains("shiftKey"));
493 let held = trigger_filter("shift+a").expect("parsed");
494 assert!(held.contains("&&shiftKey"));
495 assert!(!held.contains("!shiftKey"));
496 }
497
498 #[test]
499 fn a_key_that_prints_nothing_is_named_the_way_the_dom_names_it() {
500 assert!(
501 trigger_filter("escape")
502 .expect("parsed")
503 .contains("'Escape'")
504 );
505 assert!(
506 trigger_filter("arrowup")
507 .expect("parsed")
508 .contains("'ArrowUp'")
509 );
510 }
511
512 #[test]
513 fn a_name_this_renderer_does_not_understand_is_ignored() {
514 // Not guessed at: an unknown name passed through would emit a filter
515 // that silently never matches.
516 assert!(trigger_filter("dpad-left").is_none());
517 assert!(trigger_filter("ctrl+j+k").is_none());
518 assert!(trigger_filter("ctrl+").is_none());
519 assert!(trigger_filter("").is_none());
520 }
521
522 #[test]
523 fn a_quote_in_a_key_cannot_close_the_filter_it_sits_in() {
524 let filter = trigger_filter("'").expect("parsed");
525 assert!(filter.contains("\\'"), "{filter}");
526 }
527
528 #[test]
529 fn a_bound_key_that_goes_back_listens_for_that_key_and_calls_no_route() {
530 // `33c27e81`. The whole point of one destination rather than
531 // `Chrome::back(Action)`: a binding and a visible Close cannot disagree
532 // about where back goes, because neither of them names it.
533 //
534 // The gesture has to be in the program. This button is `hidden` and
535 // never focused, so it is never clicked, and a program that only
536 // listened for a click would leave every bound key silently doing
537 // nothing.
538 let mut out = String::new();
539 chrome_html(
540 &Chrome::new().bind("escape", "Back", quasi_router::Action::back()),
541 &Emit::default(),
542 &Doc::bare(),
543 &mut out,
544 );
545
546 assert!(out.contains("call history.back()"), "{out}");
547 assert!(out.contains("keydown[key==&#39;Escape&#39;"), "{out}");
548 assert!(out.contains("from window"), "{out}");
549 // No route is asked for, and above all no empty address: an `hx-get`
550 // with none is what htmx reads as "ask the page you are on". No target
551 // either, because nothing is coming back to put anywhere.
552 assert!(!out.contains("hx-get"), "{out}");
553 assert!(!out.contains("hx-trigger"), "{out}");
554 assert!(!out.contains("hx-target"), "{out}");
555 }
556
557 #[test]
558 fn an_app_with_no_chrome_emits_nothing_at_all() {
559 let mut out = String::new();
560 chrome_html(&Chrome::new(), &Emit::default(), &Doc::bare(), &mut out);
561 assert!(out.is_empty());
562 }
563
564 #[test]
565 fn a_panel_carries_the_address_an_answer_aims_at() {
566 use quasi_router::Node;
567
568 let chrome = Chrome::new().presenting(
569 "timer",
570 quasi_router::Role::Activity,
571 Node::text("00:12:04"),
572 );
573 let mut out = String::new();
574 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
575 assert!(out.contains("id=\"timer\""), "{out}");
576 assert!(out.contains("chrome-panel"), "{out}");
577 assert!(out.contains("00:12:04"), "{out}");
578 // A panel and no bindings, which is goingson's chrome, and the case
579 // that used to lose the container: its Focus control calls a route
580 // that answers `Over`, and the retarget aimed at an element the
581 // document did not have.
582 assert!(out.contains(OVERLAY_ID), "{out}");
583 }
584
585 #[test]
586 fn the_overlay_container_says_what_it_is_and_says_it_outside_itself() {
587 use quasi_router::Node;
588
589 let chrome = Chrome::new().presenting(
590 "timer",
591 quasi_router::Role::Activity,
592 Node::text("00:12:04"),
593 );
594 let mut out = String::new();
595 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
596 // The annotation is for whoever finds an empty div in devtools, so it
597 // has to survive the swap that fills the div. Before the element, not
598 // within it.
599 let comment = out.find("<!-- quasi: overlay").expect("annotated");
600 let container = out.find("<div id=\"quasi-overlay\"").expect("emitted");
601 assert!(comment < container, "{out}");
602 assert!(out.contains("data-chrome=\"overlay\""), "{out}");
603 }
604
605 #[test]
606 fn two_panels_each_say_what_they_are_for_and_keep_the_class_a_stylesheet_knows() {
607 use quasi_router::{Node, Role};
608
609 let chrome = Chrome::new()
610 .presenting("timer", Role::Activity, Node::text("00:12:04"))
611 .presenting("sync", Role::Status, Node::text("Synced"));
612 let mut out = String::new();
613 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
614
615 assert!(out.contains("id=\"timer\""), "{out}");
616 assert!(out.contains("id=\"sync\""), "{out}");
617 assert!(out.contains("data-role=\"activity\""), "{out}");
618 assert!(out.contains("data-role=\"status\""), "{out}");
619 // Beside the class rather than instead of it: a stylesheet written when
620 // there was one panel still finds both.
621 assert_eq!(out.matches("chrome-panel").count(), 2, "{out}");
622 }
623
624 #[test]
625 fn the_nav_is_links_because_the_places_are_addresses() {
626 use quasi_router::{Action, Place};
627
628 let places = [Place::new("time", "Time", Action::get("/day"))];
629 let mut out = String::new();
630 nav_html(&places, None, &Emit::default(), &mut out);
631
632 // A link and not a button: middle-click opens a tab, the status bar
633 // shows where it goes, and a screen reader lands on a landmark.
634 assert!(out.contains("<nav"), "{out}");
635 assert!(out.contains("data-chrome=\"nav\""), "{out}");
636 assert!(out.contains("<a "), "{out}");
637 assert!(out.contains("Time"), "{out}");
638 }
639
640 #[test]
641 fn the_place_the_screen_names_is_the_one_marked_current() {
642 use quasi_router::{Action, Place};
643
644 let places = [
645 Place::new("work", "Work", Action::get("/tasks")).within([
646 Place::new("tasks", "Tasks", Action::get("/tasks")),
647 Place::new("board", "Board", Action::get("/board")),
648 ]),
649 Place::new("time", "Time", Action::get("/day")),
650 ];
651
652 let mut out = String::new();
653 nav_html(&places, Some("board"), &Emit::default(), &mut out);
654
655 // The place itself, and the place holding it. Both are where the user
656 // is, and a tab bar that lit the pill and not the tab would be lying
657 // about the tab.
658 assert_eq!(out.matches("aria-current=\"page\"").count(), 2, "{out}");
659 let work = out.find("Work").expect("drawn");
660 let time = out.find("Time").expect("drawn");
661 assert!(out[..work].contains("aria-current"), "the tab is marked");
662 assert!(!out[time - 60..time].contains("aria-current"), "{out}");
663 }
664
665 #[test]
666 fn a_screen_that_is_not_a_place_marks_nothing() {
667 use quasi_router::{Action, Place};
668
669 // A confirmation drawn over one, a detail reached from a row. The last
670 // place staying lit would be the nav claiming the user is somewhere
671 // they left.
672 let places = [Place::new("time", "Time", Action::get("/day"))];
673 let mut out = String::new();
674 nav_html(&places, None, &Emit::default(), &mut out);
675 assert!(!out.contains("aria-current"), "{out}");
676
677 // And a key no place carries is the same: nothing marked, no error. The
678 // nav is the app's and so is the key, and a renderer is the wrong place
679 // to discover an app disagreeing with itself.
680 let mut other = String::new();
681 nav_html(&places, Some("nowhere"), &Emit::default(), &mut other);
682 assert!(!other.contains("aria-current"), "{other}");
683 }
684
685 #[test]
686 fn the_nav_comes_before_the_content_it_navigates() {
687 use quasi_http::Serves as _;
688 use quasi_router::{Action, Node, Place, Screen, Slot};
689
690 // Document order, which is the one placement decision this renderer
691 // makes. A navigation after the content is a landmark reached last and
692 // a block the stylesheet has to lift with `position: fixed` and then
693 // pay for in padding.
694 let webview =
695 crate::Webview::new().with_shell(crate::Shell::default().with_chrome(
696 Chrome::new().offering(Place::new("time", "Time", Action::get("/day"))),
697 ));
698 let screen = Screen::list_detail("Day", false)
699 .at_place("time")
700 .with(Slot::new("body", quasi_router::RegionKind::Pane).with(Node::text("the day")));
701
702 let out = webview.screen(&screen);
703 let nav = out.find("data-chrome=\"nav\"").expect("drawn");
704 let main = out.find("<main").expect("drawn");
705 assert!(nav < main, "{out}");
706 assert!(out.contains("aria-current=\"page\""), "{out}");
707 }
708
709 #[test]
710 fn a_nav_alone_still_gets_the_overlay_container() {
711 use quasi_router::{Action, Place};
712
713 // Same rule the panel case fixed: a route can answer `Over` with no
714 // binding involved, so any app declaring chrome gets a container.
715 let chrome = Chrome::new().offering(Place::new("time", "Time", Action::get("/day")));
716 let mut out = String::new();
717 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
718 assert!(out.contains(OVERLAY_ID), "{out}");
719 }
720
721 #[test]
722 fn a_binding_carries_the_transport_and_the_overlay_lands_in_the_container() {
723 use quasi_router::Action;
724
725 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
726 let mut out = String::new();
727 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
728 assert!(out.contains("hx-get=\"/palette\""), "{out}");
729 assert!(out.contains("hx-target=\"#quasi-overlay\""), "{out}");
730 assert!(out.contains("from:body"), "{out}");
731 assert!(out.contains("aria-label=\"Search\""), "{out}");
732 assert!(
733 out.contains("<div id=\"quasi-overlay\" data-chrome=\"overlay\"></div>"),
734 "{out}"
735 );
736 }
737
738 #[test]
739 fn a_binding_this_renderer_cannot_read_leaves_the_rest_working() {
740 use quasi_router::Action;
741
742 let chrome = Chrome::new()
743 .bind("dpad-left", "Nope", Action::get("/nope"))
744 .bind("ctrl+k", "Search", Action::get("/palette"));
745 let mut out = String::new();
746 chrome_html(&chrome, &Emit::default(), &Doc::bare(), &mut out);
747 assert!(!out.contains("/nope"), "{out}");
748 assert!(out.contains("/palette"), "{out}");
749 }
750 }
751