| 1 |
|
- |
//! An ordered set of frames, said once.
|
|
1 |
+ |
//! The Askama entry point for `quasi_basics::carousel`.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! The widget tier's first consumer (`c0b63ea9`), and the gap that started
|
| 4 |
|
- |
//! look wave 2. `makeover_layout::Region::Widget` arrived at 0.20.0 to make
|
| 5 |
|
- |
//! this sayable and `makeover_layout::Image` at 0.21.0 because the first
|
| 6 |
|
- |
//! attempt found nothing named a picture.
|
|
3 |
+ |
//! The widget left for `quasi-basics` once three real pages had proved it, and
|
|
4 |
+ |
//! this is the part that stayed: glue between a template's own frame type and
|
|
5 |
+ |
//! the widget's. Called from `partials/carousel.html`, so all three call sites
|
|
6 |
+ |
//! keep the macro they already have.
|
| 7 |
7 |
|
//!
|
| 8 |
|
- |
//! # This is written to leave
|
| 9 |
|
- |
//!
|
| 10 |
|
- |
//! It lives here to be proved against three real pages before it becomes a
|
| 11 |
|
- |
//! published crate, and it is shaped so that becoming one is a move rather than
|
| 12 |
|
- |
//! a rewrite: [`frames`] and [`carousel`] name no MNW type and reach for
|
| 13 |
|
- |
//! nothing but `quasi_router` and `makeover_layout`. The only part that stays
|
| 14 |
|
- |
//! behind is [`html`], which is glue between an Askama template and a renderer.
|
| 15 |
|
- |
//!
|
| 16 |
|
- |
//! When `makeover-basics` exists, the two functions move and this module keeps
|
| 17 |
|
- |
//! [`html`] alone, calling them through the crate.
|
| 18 |
|
- |
//!
|
| 19 |
|
- |
//! # Why the body is the frames and nothing else
|
| 20 |
|
- |
//!
|
| 21 |
|
- |
//! A carousel is a set of frames, a position, prev/next and a strip of dots.
|
| 22 |
|
- |
//! Only the first of those is *content*; the rest is chrome, and chrome is what
|
| 23 |
|
- |
//! a renderer that recognises the name draws its own way. A webview draws
|
| 24 |
|
- |
//! buttons over the frame, a terminal draws a pager with a count, egui draws a
|
| 25 |
|
- |
//! selector, and none of them owes the others a carousel primitive.
|
| 26 |
|
- |
//!
|
| 27 |
|
- |
//! So the description says "an ordered set of pictures, called a carousel" and
|
| 28 |
|
- |
//! stops. That is the whole of what every host agrees on.
|
| 29 |
|
- |
//!
|
| 30 |
|
- |
//! # What a renderer that has never heard of a carousel does
|
| 31 |
|
- |
//!
|
| 32 |
|
- |
//! It walks the body and draws the pictures in order. Nothing is lost: every
|
| 33 |
|
- |
//! frame is content, in sequence, and the reader can see all of them.
|
| 34 |
|
- |
//!
|
| 35 |
|
- |
//! This is a **better** fallback than the one being replaced. The shipped
|
| 36 |
|
- |
//! partial shows the first frame and makes the other two unreachable without
|
| 37 |
|
- |
//! JS, because the controls that would reach them are the part that needs
|
| 38 |
|
- |
//! scripting. Here the unenhanced rendering is the whole gallery, and the
|
| 39 |
|
- |
//! script's job is to collapse it to one at a time rather than to unlock the
|
| 40 |
|
- |
//! rest. Progressive enhancement in the direction that degrades to *more*
|
| 41 |
|
- |
//! content instead of less.
|
|
8 |
+ |
//! Why the island wraps the region rather than being it: a custom element is
|
|
9 |
+ |
//! what the browser re-upgrades after an htmx swap, which is the whole reason
|
|
10 |
+ |
//! islands are custom elements, and the description emits a plain `<div>`
|
|
11 |
+ |
//! because it has no idea this host prefixes its tags with `mnw-`. The template
|
|
12 |
+ |
//! puts one around the other and both halves stay honest.
|
| 42 |
13 |
|
|
| 43 |
|
- |
use makeover_layout::Fit;
|
| 44 |
|
- |
use quasi_router::{Node, Picture, Slot};
|
| 45 |
|
- |
|
| 46 |
|
- |
/// What the recognising renderer keys on. Never interpreted by the description.
|
| 47 |
|
- |
pub const NAME: &str = "carousel";
|
| 48 |
|
- |
|
| 49 |
|
- |
/// One frame: a picture and what it says.
|
| 50 |
|
- |
///
|
| 51 |
|
- |
/// Deliberately not MNW's `CarouselFrame`. That type is an Askama concern and
|
| 52 |
|
- |
/// carries what the templates happened to need; this is what the widget needs,
|
| 53 |
|
- |
/// and keeping them apart is what lets this module leave without dragging the
|
| 54 |
|
- |
/// template layer with it.
|
| 55 |
|
- |
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 56 |
|
- |
pub struct Frame {
|
| 57 |
|
- |
/// Where the picture is.
|
| 58 |
|
- |
pub src: String,
|
| 59 |
|
- |
/// What the picture says, for anything not showing it.
|
| 60 |
|
- |
pub alt: String,
|
| 61 |
|
- |
/// A visible line under it, where there is one.
|
| 62 |
|
- |
pub caption: Option<String>,
|
| 63 |
|
- |
/// The picture's own dimensions, where the caller knows them.
|
| 64 |
|
- |
///
|
| 65 |
|
- |
/// What lets the renderer hold the frame's place from first paint. Without
|
| 66 |
|
- |
/// it the frame occupies nothing until the bytes land and then takes its
|
| 67 |
|
- |
/// full height at once, which measured as a 478px jump on the landing page.
|
| 68 |
|
- |
pub intrinsic: Option<(u32, u32)>,
|
| 69 |
|
- |
}
|
| 70 |
|
- |
|
| 71 |
|
- |
impl Frame {
|
| 72 |
|
- |
/// A frame at a source.
|
| 73 |
|
- |
pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
|
| 74 |
|
- |
Self {
|
| 75 |
|
- |
src: src.into(),
|
| 76 |
|
- |
alt: alt.into(),
|
| 77 |
|
- |
caption: None,
|
| 78 |
|
- |
intrinsic: None,
|
| 79 |
|
- |
}
|
| 80 |
|
- |
}
|
| 81 |
|
- |
|
| 82 |
|
- |
/// The picture's own dimensions.
|
| 83 |
|
- |
#[must_use]
|
| 84 |
|
- |
pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
|
| 85 |
|
- |
self.intrinsic = Some((width, height));
|
| 86 |
|
- |
self
|
| 87 |
|
- |
}
|
| 88 |
|
- |
|
| 89 |
|
- |
/// A visible line under it.
|
| 90 |
|
- |
#[must_use]
|
| 91 |
|
- |
pub fn caption(mut self, caption: impl Into<String>) -> Self {
|
| 92 |
|
- |
self.caption = Some(caption.into());
|
| 93 |
|
- |
self
|
| 94 |
|
- |
}
|
| 95 |
|
- |
}
|
| 96 |
|
- |
|
| 97 |
|
- |
/// The frames as description nodes.
|
| 98 |
|
- |
///
|
| 99 |
|
- |
/// Split out from [`carousel`] because a set of pictures in order is worth
|
| 100 |
|
- |
/// having on its own: a gallery that does not page is this without the widget
|
| 101 |
|
- |
/// name around it, and that is the second consumer this module expects.
|
| 102 |
|
- |
pub fn frames(frames: impl IntoIterator<Item = Frame>) -> impl Iterator<Item = Node> {
|
| 103 |
|
- |
frames.into_iter().enumerate().map(|(i, frame)| {
|
| 104 |
|
- |
// Natural, and it is the whole reason `Fit` has three members rather
|
| 105 |
|
- |
// than the one MNW uses at 15 of its 17 other sites. A screenshot
|
| 106 |
|
- |
// cropped to fill its box is a screenshot with its edges cut off, and
|
| 107 |
|
- |
// the edges of a screenshot of an interface are where the interface is.
|
| 108 |
|
- |
let mut picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural);
|
| 109 |
|
- |
if let Some((w, h)) = frame.intrinsic {
|
| 110 |
|
- |
picture = picture.intrinsic(w, h);
|
| 111 |
|
- |
}
|
| 112 |
|
- |
// The first frame is the one on screen, and the rest are not. Eager for
|
| 113 |
|
- |
// the one, lazy for the others -- which is the case that proves loading
|
| 114 |
|
- |
// cannot be a single setting the renderer picks: both answers are
|
| 115 |
|
- |
// correct, in one widget, at one moment.
|
| 116 |
|
- |
//
|
| 117 |
|
- |
// Getting this backwards is what the old partial did by lazily loading
|
| 118 |
|
- |
// all three, including the one the visitor was already looking at. That
|
| 119 |
|
- |
// delays the only picture that matters and buys nothing, because the
|
| 120 |
|
- |
// other two are display:none and were never going to be fetched early
|
| 121 |
|
- |
// anyway.
|
| 122 |
|
- |
if i > 0 {
|
| 123 |
|
- |
picture = picture.lazy();
|
| 124 |
|
- |
}
|
| 125 |
|
- |
Node::Image(match frame.caption {
|
| 126 |
|
- |
Some(caption) => picture.caption(caption),
|
| 127 |
|
- |
None => picture,
|
| 128 |
|
- |
})
|
| 129 |
|
- |
})
|
| 130 |
|
- |
}
|
| 131 |
|
- |
|
| 132 |
|
- |
/// A carousel under an address.
|
| 133 |
|
- |
///
|
| 134 |
|
- |
/// The id is the region's, which is how a fragment finds its way back to the
|
| 135 |
|
- |
/// right place, so it has to be unique on the page the way every slot id does.
|
| 136 |
|
- |
pub fn carousel(id: &str, items: impl IntoIterator<Item = Frame>) -> Slot {
|
| 137 |
|
- |
Slot::widget(id, NAME).extend(frames(items))
|
| 138 |
|
- |
}
|
|
14 |
+ |
use quasi_basics::Frame;
|
|
15 |
+ |
use quasi_router::Node;
|
| 139 |
16 |
|
|
| 140 |
17 |
|
/// The markup, for an Askama template to drop in.
|
| 141 |
|
- |
///
|
| 142 |
|
- |
/// The glue, and the one part of this module that stays behind when the rest
|
| 143 |
|
- |
/// moves to `makeover-basics`. Called from `partials/carousel.html` so all
|
| 144 |
|
- |
/// three call sites keep the macro they already have.
|
| 145 |
18 |
|
#[must_use]
|
| 146 |
19 |
|
pub fn html(id: &str, items: &[crate::templates::CarouselFrame]) -> String {
|
| 147 |
20 |
|
use quasi_axum::Serves as _;
|
| 148 |
21 |
|
|
| 149 |
|
- |
let node = Node::Region(carousel(
|
|
22 |
+ |
let node = Node::Region(quasi_basics::carousel(
|
| 150 |
23 |
|
id,
|
| 151 |
24 |
|
items.iter().map(|frame| {
|
| 152 |
25 |
|
let mut built = Frame::new(&frame.image, &frame.alt);
|
| 167 |
40 |
|
|
| 168 |
41 |
|
#[cfg(test)]
|
| 169 |
42 |
|
mod tests {
|
| 170 |
|
- |
use super::*;
|
| 171 |
|
- |
use quasi_axum::Serves as _;
|
| 172 |
|
- |
|
| 173 |
|
- |
fn render(id: &str, items: Vec<Frame>) -> String {
|
| 174 |
|
- |
quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items)))
|
| 175 |
|
- |
}
|
| 176 |
|
- |
|
| 177 |
|
- |
fn three() -> Vec<Frame> {
|
| 178 |
|
- |
vec![
|
| 179 |
|
- |
Frame::new("/a.png", "The library, mid-import").caption("Library"),
|
| 180 |
|
- |
Frame::new("/b.png", "A project page with two items"),
|
| 181 |
|
- |
Frame::new("/c.png", "The payouts table"),
|
| 182 |
|
- |
]
|
| 183 |
|
- |
}
|
|
43 |
+ |
use crate::templates::CarouselFrame;
|
| 184 |
44 |
|
|
|
45 |
+ |
/// What the widget guarantees is `quasi-basics`' to test, and it does.
|
|
46 |
+ |
/// What is MNW's is that this conversion loses nothing on the way through,
|
|
47 |
+ |
/// because it is the one place a template's frame becomes a widget's.
|
| 185 |
48 |
|
#[test]
|
| 186 |
|
- |
fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
|
| 187 |
|
- |
let html = render("landing-shots", three());
|
|
49 |
+ |
fn a_template_frame_arrives_whole() {
|
|
50 |
+ |
let html = super::html(
|
|
51 |
+ |
"landing-shots",
|
|
52 |
+ |
&[
|
|
53 |
+ |
CarouselFrame {
|
|
54 |
+ |
image: "/a.png".into(),
|
|
55 |
+ |
alt: "The library, mid-import".into(),
|
|
56 |
+ |
caption: Some("Library".into()),
|
|
57 |
+ |
intrinsic: Some((5120, 3412)),
|
|
58 |
+ |
},
|
|
59 |
+ |
CarouselFrame {
|
|
60 |
+ |
image: "/b.png".into(),
|
|
61 |
+ |
alt: "A project page".into(),
|
|
62 |
+ |
caption: None,
|
|
63 |
+ |
intrinsic: None,
|
|
64 |
+ |
},
|
|
65 |
+ |
],
|
|
66 |
+ |
);
|
|
67 |
+ |
|
| 188 |
68 |
|
assert!(html.contains(r#"data-widget="carousel""#), "{html}");
|
| 189 |
69 |
|
assert!(html.contains(r#"id="landing-shots""#), "{html}");
|
| 190 |
|
- |
}
|
| 191 |
|
- |
|
| 192 |
|
- |
#[test]
|
| 193 |
|
- |
fn every_frame_is_in_the_markup_and_not_only_the_first() {
|
| 194 |
|
- |
// The fallback this replaces showed frame one and made the rest
|
| 195 |
|
- |
// unreachable without JS. The whole gallery is here, in order.
|
| 196 |
|
- |
let html = render("g", three());
|
| 197 |
|
- |
for src in ["/a.png", "/b.png", "/c.png"] {
|
| 198 |
|
- |
assert!(html.contains(src), "{src} missing from {html}");
|
| 199 |
|
- |
}
|
| 200 |
|
- |
let first = html.find("/a.png").unwrap();
|
| 201 |
|
- |
let second = html.find("/b.png").unwrap();
|
| 202 |
|
- |
let third = html.find("/c.png").unwrap();
|
| 203 |
|
- |
assert!(first < second && second < third, "frames out of order");
|
| 204 |
|
- |
}
|
| 205 |
|
- |
|
| 206 |
|
- |
#[test]
|
| 207 |
|
- |
fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
|
| 208 |
|
- |
let html = render("g", three());
|
| 209 |
70 |
|
assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
|
| 210 |
|
- |
assert!(html.contains(r#"alt="The payouts table""#), "{html}");
|
| 211 |
|
- |
}
|
| 212 |
|
- |
|
| 213 |
|
- |
#[test]
|
| 214 |
|
- |
fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
|
| 215 |
|
- |
let html = render("g", three());
|
| 216 |
|
- |
// One caption across the three, so one figure element.
|
| 217 |
|
- |
assert_eq!(html.matches("<figure").count(), 1, "{html}");
|
| 218 |
71 |
|
assert!(html.contains("Library</figcaption>"), "{html}");
|
| 219 |
|
- |
}
|
| 220 |
|
- |
|
| 221 |
|
- |
#[test]
|
| 222 |
|
- |
fn a_frame_that_knows_its_size_reserves_its_space() {
|
| 223 |
|
- |
// The 478px jump this exists to stop: without width/height the browser
|
| 224 |
|
- |
// gives the picture no room until the bytes land.
|
| 225 |
|
- |
let html = render(
|
| 226 |
|
- |
"g",
|
| 227 |
|
- |
vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)],
|
| 228 |
|
- |
);
|
| 229 |
72 |
|
assert!(html.contains(r#"width="5120" height="3412""#), "{html}");
|
| 230 |
|
- |
}
|
| 231 |
|
- |
|
| 232 |
|
- |
#[test]
|
| 233 |
|
- |
fn a_frame_that_does_not_know_its_size_says_nothing() {
|
| 234 |
|
- |
// A creator upload. Reserving the wrong room is worse than none, so an
|
| 235 |
|
- |
// absent size must not become a guessed one.
|
| 236 |
|
- |
let html = render("g", vec![Frame::new("/a.png", "Alpha")]);
|
| 237 |
|
- |
assert!(!html.contains("width="), "{html}");
|
| 238 |
|
- |
assert!(!html.contains("height="), "{html}");
|
| 239 |
|
- |
}
|
| 240 |
|
- |
|
| 241 |
|
- |
#[test]
|
| 242 |
|
- |
fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() {
|
| 243 |
|
- |
// Both answers in one widget at one moment, which is why loading is the
|
| 244 |
|
- |
// description's to say rather than a renderer-wide setting.
|
| 245 |
|
- |
let html = render("g", three());
|
| 246 |
|
- |
assert_eq!(
|
| 247 |
|
- |
html.matches("loading=\"lazy\"").count(),
|
| 248 |
|
- |
2,
|
| 249 |
|
- |
"expected the two offscreen frames only: {html}"
|
| 250 |
|
- |
);
|
| 251 |
|
- |
// Everything before the second frame's source is the first frame, so
|
| 252 |
|
- |
// nothing in that span may defer: it is the picture already on screen.
|
| 253 |
|
- |
let upto_second = &html[..html.find("/b.png").unwrap()];
|
| 254 |
|
- |
assert!(
|
| 255 |
|
- |
!upto_second.contains("loading=\"lazy\""),
|
| 256 |
|
- |
"the visible frame must not be deferred: {html}"
|
| 257 |
|
- |
);
|
| 258 |
|
- |
}
|
| 259 |
|
- |
|
| 260 |
|
- |
#[test]
|
| 261 |
|
- |
fn a_screenshot_keeps_its_own_shape() {
|
| 262 |
|
- |
// Natural is the default and emits no attribute, so the assertion is
|
| 263 |
|
- |
// that nothing asked for a crop. A cropped screenshot loses its edges,
|
| 264 |
|
- |
// which is where the interface is.
|
| 265 |
|
- |
let html = render("g", three());
|
| 266 |
|
- |
assert!(!html.contains("data-fit"), "{html}");
|
| 267 |
|
- |
}
|
| 268 |
|
- |
|
| 269 |
|
- |
#[test]
|
| 270 |
|
- |
fn a_hostile_source_cannot_break_out_of_the_attribute() {
|
| 271 |
|
- |
let html = render(
|
| 272 |
|
- |
"g",
|
| 273 |
|
- |
vec![Frame::new(r#"x" onerror="alert(1)"#, "</title><script>")],
|
| 274 |
|
- |
);
|
| 275 |
|
- |
assert!(!html.contains("onerror=\"alert"), "{html}");
|
| 276 |
|
- |
assert!(!html.contains("<script>"), "{html}");
|
| 277 |
|
- |
}
|
| 278 |
|
- |
|
| 279 |
|
- |
#[test]
|
| 280 |
|
- |
fn the_empty_case_is_a_region_with_nothing_in_it() {
|
| 281 |
|
- |
// Three pages call this and one of them has an empty gallery whenever
|
| 282 |
|
- |
// the creator uploaded nothing. It has to be a carousel with no frames
|
| 283 |
|
- |
// rather than a panic or a stray element.
|
| 284 |
|
- |
let html = render("g", Vec::new());
|
| 285 |
|
- |
assert!(html.contains(r#"data-widget="carousel""#), "{html}");
|
| 286 |
|
- |
assert!(!html.contains("<img"), "{html}");
|
|
73 |
+ |
assert!(html.contains("/b.png"), "{html}");
|
| 287 |
74 |
|
}
|
| 288 |
75 |
|
}
|