Skip to main content

zeyn/ui/
content.rs

1//! Content components for reading pages: [`Heading`], [`Paragraph`], [`MetaLine`],
2//! [`PrevNext`], [`Avatar`], [`Spoiler`] and the [`AutoGrid`] layout. They are plain server
3//! rendered HTML with no browser script; their styles live in `content.css`.
4
5use super::layout::into_node;
6use super::render_child;
7use super::widgets::screen_href;
8use crate::i18n::Text;
9use crate::render::{Render, escape};
10use crate::screen::{Component, Node, ScreenDef};
11use serde_json::{Value as Json, json};
12
13into_node!(
14    Heading, Paragraph, MetaLine, PrevNext, AutoGrid, Avatar, Spoiler
15);
16
17/// A section heading with an explicit level, `h2` to `h6`. The screen title is the page's only
18/// `h1`, so there is no `h1` here; pick the level that follows the outline instead of the size
19/// you want.
20///
21/// ```
22/// use zeyn::ui::Heading;
23/// let h = Heading::h2("Recent articles");
24/// let sub = Heading::h3("This week").id("this-week");
25/// ```
26///
27/// The screen title owns `h1`:
28///
29/// ```compile_fail
30/// let h = zeyn::ui::Heading::h1("Title");
31/// ```
32pub struct Heading {
33    level: u8,
34    text: Text,
35    id: Option<String>,
36}
37
38impl Heading {
39    fn level(level: u8, text: impl Into<Text>) -> Self {
40        Heading {
41            level,
42            text: text.into(),
43            id: None,
44        }
45    }
46
47    /// A heading of a section of the screen.
48    pub fn h2(text: impl Into<Text>) -> Self {
49        Self::level(2, text)
50    }
51
52    /// A heading inside an `h2` section.
53    pub fn h3(text: impl Into<Text>) -> Self {
54        Self::level(3, text)
55    }
56
57    pub fn h4(text: impl Into<Text>) -> Self {
58        Self::level(4, text)
59    }
60
61    pub fn h5(text: impl Into<Text>) -> Self {
62        Self::level(5, text)
63    }
64
65    pub fn h6(text: impl Into<Text>) -> Self {
66        Self::level(6, text)
67    }
68
69    /// An anchor so links can point at the heading (`#id`). Letters, digits, `-` and `_` are
70    /// kept; anything else is dropped.
71    pub fn id(mut self, id: &str) -> Self {
72        let clean: String = id
73            .chars()
74            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
75            .take(64)
76            .collect();
77        self.id = (!clean.is_empty()).then_some(clean);
78        self
79    }
80}
81
82impl Component for Heading {
83    fn render(&self, out: &mut String, r: &Render) {
84        let id = self
85            .id
86            .as_ref()
87            .map(|i| format!(r#" id="{i}""#))
88            .unwrap_or_default();
89        out.push_str(&format!(
90            r#"<h{0} class="z-heading z-heading-{0}"{id}>{1}</h{0}>"#,
91            self.level,
92            r.text(&self.text)
93        ));
94    }
95
96    fn schema(&self) -> Json {
97        json!({ "type": "heading", "level": self.level, "text": self.text, "id": self.id })
98    }
99}
100
101/// A paragraph of text, escaped. Long words wrap. `.muted()` and `.small()` make it a quieter
102/// note.
103///
104/// ```
105/// use zeyn::ui::Paragraph;
106/// let intro = Paragraph::new("Every product ships within two days.");
107/// let note = Paragraph::new("Prices include tax.").muted().small();
108/// ```
109pub struct Paragraph {
110    text: Text,
111    muted: bool,
112    small: bool,
113}
114
115impl Paragraph {
116    pub fn new(text: impl Into<Text>) -> Self {
117        Paragraph {
118            text: text.into(),
119            muted: false,
120            small: false,
121        }
122    }
123
124    /// Secondary text color.
125    pub fn muted(mut self) -> Self {
126        self.muted = true;
127        self
128    }
129
130    /// The small font size.
131    pub fn small(mut self) -> Self {
132        self.small = true;
133        self
134    }
135}
136
137impl Component for Paragraph {
138    fn render(&self, out: &mut String, r: &Render) {
139        let mut class = String::from("z-paragraph");
140        if self.muted {
141            class.push_str(" z-muted");
142        }
143        if self.small {
144            class.push_str(" z-small");
145        }
146        out.push_str(&format!(r#"<p class="{class}">{}</p>"#, r.text(&self.text)));
147    }
148
149    fn schema(&self) -> Json {
150        json!({ "type": "paragraph", "text": self.text, "muted": self.muted, "small": self.small })
151    }
152}
153
154/// Short facts on one line, such as a date, a reading time and a category, joined by a
155/// separator. Screen readers skip the separator and hear the parts one after the other.
156///
157/// ```
158/// use zeyn::ui::MetaLine;
159/// let meta = MetaLine::new().part("12 May 2026").part("5 min read").part("Guides");
160/// let slash = MetaLine::new().part("Shop").part("Tea").separator("/");
161/// ```
162pub struct MetaLine {
163    parts: Vec<Text>,
164    separator: &'static str,
165}
166
167impl Default for MetaLine {
168    fn default() -> Self {
169        MetaLine {
170            parts: Vec::new(),
171            separator: "\u{00b7}",
172        }
173    }
174}
175
176impl MetaLine {
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Adds a part. Empty parts are left out when rendering.
182    pub fn part(mut self, text: impl Into<Text>) -> Self {
183        self.parts.push(text.into());
184        self
185    }
186
187    /// The separator shown between parts. A middle dot unless set.
188    pub fn separator(mut self, separator: &'static str) -> Self {
189        self.separator = separator;
190        self
191    }
192}
193
194impl Component for MetaLine {
195    fn render(&self, out: &mut String, r: &Render) {
196        out.push_str(r#"<p class="z-meta">"#);
197        let sep = crate::render::escape(self.separator);
198        let mut first = true;
199        for part in &self.parts {
200            let text = r.text(part);
201            if text.is_empty() {
202                continue;
203            }
204            if !first {
205                out.push_str(&format!(
206                    r#"<span class="z-meta-sep" aria-hidden="true">{sep}</span>"#
207                ));
208            }
209            first = false;
210            out.push_str(&format!(r#"<bdi class="z-meta-part">{text}</bdi>"#));
211        }
212        out.push_str("</p>");
213    }
214
215    fn schema(&self) -> Json {
216        json!({ "type": "meta_line", "parts": self.parts, "separator": self.separator })
217    }
218}
219
220struct Step {
221    screen: &'static str,
222    params: Json,
223    title: Option<Text>,
224    shortcut: Option<String>,
225}
226
227/// A pair of links to the previous and the next item, such as the article before and after this
228/// one. The links get `rel="prev"` and `rel="next"`, open without a page reload, and their arrows
229/// turn around in right to left languages. Either side may be missing.
230///
231/// ```
232/// use zeyn::prelude::*;
233/// use zeyn::ui::PrevNext;
234///
235/// #[params]
236/// pub struct ArticleParams {
237///     pub id: i64,
238/// }
239///
240/// #[screen(route = "/articles/{id}", public)]
241/// pub fn article(p: ArticleParams) -> Screen {
242///     Screen::new("Article").body(
243///         PrevNext::new()
244///             .prev(article, ArticleParams { id: p.id - 1 })
245///             .prev_title("Getting started")
246///             .shortcut("Alt+Shift+B")
247///             .next(article, ArticleParams { id: p.id + 1 })
248///             .shortcut("Alt+Shift+N"),
249///     )
250/// }
251/// ```
252#[derive(Default)]
253pub struct PrevNext {
254    prev: Option<Step>,
255    next: Option<Step>,
256    next_last: bool,
257}
258
259impl PrevNext {
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// The previous item. Params of the wrong type do not compile.
265    pub fn prev<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
266        self.prev = Some(Step {
267            screen: S::META.name,
268            params: serde_json::to_value(params).unwrap_or(Json::Null),
269            title: None,
270            shortcut: None,
271        });
272        self.next_last = false;
273        self
274    }
275
276    /// The next item. Params of the wrong type do not compile.
277    pub fn next<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
278        self.next = Some(Step {
279            screen: S::META.name,
280            params: serde_json::to_value(params).unwrap_or(Json::Null),
281            title: None,
282            shortcut: None,
283        });
284        self.next_last = true;
285        self
286    }
287
288    /// The title of the previous item, shown under the word "Previous".
289    pub fn prev_title(mut self, title: impl Into<Text>) -> Self {
290        if let Some(s) = &mut self.prev {
291            s.title = Some(title.into());
292        }
293        self
294    }
295
296    /// The title of the next item, shown under the word "Next".
297    pub fn next_title(mut self, title: impl Into<Text>) -> Self {
298        if let Some(s) = &mut self.next {
299            s.title = Some(title.into());
300        }
301        self
302    }
303
304    /// A keyboard shortcut such as `"Alt+Shift+N"` for the side set last (`prev` or
305    /// `next`). It works like [`super::Chips::shortcut`]: pressing it follows the link through
306    /// `nav.js` unless focus is in a text field, and the link carries `aria-keyshortcuts` and a
307    /// `<kbd>` hint.
308    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
309        let side = if self.next_last {
310            &mut self.next
311        } else {
312            &mut self.prev
313        };
314        if let Some(s) = side {
315            s.shortcut = Some(keys.into());
316        }
317        self
318    }
319}
320
321impl Component for PrevNext {
322    fn render(&self, out: &mut String, r: &Render) {
323        let t = |key: &str| r.text(&Text::key(format!("zeyn.ui.prevnext.{key}")));
324        out.push_str(&format!(
325            r#"<nav class="z-prevnext" aria-label="{}">"#,
326            t("label")
327        ));
328        for (step, rel, arrow) in [
329            (&self.prev, "prev", "&#8592;"),
330            (&self.next, "next", "&#8594;"),
331        ] {
332            let Some(s) = step else {
333                out.push_str(r#"<span class="z-prevnext-gap"></span>"#);
334                continue;
335            };
336            let word = t(if rel == "prev" { "previous" } else { "next" });
337            let title = s
338                .title
339                .as_ref()
340                .map(|title| format!(r#"<span class="z-prevnext-title">{}</span>"#, r.text(title)))
341                .unwrap_or_default();
342            let (keys, hint) = crate::command::link_shortcut(s.shortcut.as_deref());
343            out.push_str(&format!(
344                r#"<a class="z-prevnext-link z-prevnext-{rel}" href="{}" rel="{rel}" data-zeyn-base-href{keys}><span class="z-prevnext-arrow" aria-hidden="true">{arrow}</span><span class="z-prevnext-text"><span class="z-prevnext-word">{word}{hint}</span>{title}</span></a>"#,
345                screen_href(r, s.screen, &s.params)
346            ));
347        }
348        out.push_str("</nav>");
349    }
350
351    fn schema(&self) -> Json {
352        let side = |s: &Option<Step>| {
353            s.as_ref()
354                .map(|s| json!({ "screen": s.screen, "params": s.params, "title": s.title, "shortcut": s.shortcut }))
355        };
356        json!({ "type": "prev_next", "prev": side(&self.prev), "next": side(&self.next) })
357    }
358
359    fn shortcuts(&self) -> Vec<&str> {
360        [&self.prev, &self.next]
361            .into_iter()
362            .flatten()
363            .filter_map(|s| s.shortcut.as_deref())
364            .collect()
365    }
366}
367
368/// Children in a grid whose number of columns follows the available width: as many columns of
369/// at least `min` pixels as fit, sharing the rest of the width, and one column when even one
370/// does not fit. The browser does the layout (`repeat(auto-fill, minmax(min(Npx, 100%), 1fr))`),
371/// so it needs no script and no breakpoints. Not to be mixed up with a DataGrid, which shows
372/// rows of data.
373///
374/// ```
375/// use zeyn::prelude::*;
376/// use zeyn::ui::AutoGrid;
377/// let tiles = AutoGrid::new()
378///     .min(220)
379///     .add(Card::new().title("Orders"))
380///     .add(Card::new().title("Stock"))
381///     .add(Card::new().title("People"));
382/// ```
383pub struct AutoGrid {
384    nodes: Vec<Node>,
385    min: u16,
386    gap: u8,
387}
388
389impl Default for AutoGrid {
390    fn default() -> Self {
391        AutoGrid {
392            nodes: Vec::new(),
393            min: 240,
394            gap: 4,
395        }
396    }
397}
398
399impl AutoGrid {
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    #[allow(
405        clippy::should_implement_trait,
406        reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
407    )]
408    pub fn add(mut self, node: impl Into<Node>) -> Self {
409        self.nodes.push(node.into());
410        self
411    }
412
413    /// The narrowest a column may get, in CSS pixels, 80 to 1200. 240 unless set.
414    pub fn min(mut self, px: u16) -> Self {
415        self.min = px.clamp(80, 1200);
416        self
417    }
418
419    /// Gap in spacing units (4 px at regular density), 0 to 12. 4 unless set.
420    pub fn gap(mut self, units: u8) -> Self {
421        self.gap = units.min(12);
422        self
423    }
424}
425
426impl Component for AutoGrid {
427    fn render(&self, out: &mut String, r: &Render) {
428        out.push_str(&format!(
429            r#"<div class="z-autogrid" style="--z-autogrid-min:{}px;--z-autogrid-gap:{}">"#,
430            self.min, self.gap
431        ));
432        for (i, node) in self.nodes.iter().enumerate() {
433            render_child(out, r, i, node);
434        }
435        out.push_str("</div>");
436    }
437
438    fn schema(&self) -> Json {
439        json!({
440            "type": "auto_grid",
441            "min": self.min,
442            "gap": self.gap,
443            "children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>(),
444        })
445    }
446
447    fn children(&self) -> &[Node] {
448        &self.nodes
449    }
450}
451
452/// How large an [`Avatar`] is: 32, 40 or 64 CSS pixels.
453#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
454#[serde(rename_all = "snake_case")]
455pub enum AvatarSize {
456    Small,
457    #[default]
458    Medium,
459    Large,
460}
461
462impl AvatarSize {
463    fn px(self) -> u32 {
464        match self {
465            AvatarSize::Small => 32,
466            AvatarSize::Medium => 40,
467            AvatarSize::Large => 64,
468        }
469    }
470}
471
472/// A round picture of a person or a team, or their initials when there is no picture. The name
473/// is required: it is the image's alternative text and the source of the initials. When the
474/// name is written right next to the avatar, call `.decorative()` so screen readers do not hear
475/// it twice.
476///
477/// The picture follows the [`super::Image`] rules: a path of the app or an `https` address on a
478/// host of the app's `img-src`; any other picture is left out and the initials are shown.
479///
480/// ```
481/// use zeyn::ui::{Avatar, AvatarSize};
482/// let ada = Avatar::new("Ada Lovelace").image("/img/people/ada.jpg").size(AvatarSize::Large);
483/// let team = Avatar::new("Support team");
484/// let beside_name = Avatar::new("Alan Turing").size(AvatarSize::Small).decorative();
485/// ```
486pub struct Avatar {
487    name: Text,
488    image: Option<String>,
489    size: AvatarSize,
490    decorative: bool,
491}
492
493impl Avatar {
494    pub fn new(name: impl Into<Text>) -> Self {
495        Avatar {
496            name: name.into(),
497            image: None,
498            size: AvatarSize::Medium,
499            decorative: false,
500        }
501    }
502
503    /// The picture. Without one, or when it is not allowed, the initials are shown.
504    pub fn image(mut self, src: impl Into<String>) -> Self {
505        self.image = Some(src.into());
506        self
507    }
508
509    pub fn size(mut self, size: AvatarSize) -> Self {
510        self.size = size;
511        self
512    }
513
514    /// Hides the avatar from screen readers, for when the name is written next to it.
515    pub fn decorative(mut self) -> Self {
516        self.decorative = true;
517        self
518    }
519}
520
521/// Up to two initials of a name, in capitals of the locale: the first letter of the first word
522/// and of the last word. Turkish `i` becomes `İ`.
523fn initials(name: &str, locale: &str) -> String {
524    let words: Vec<&str> = name.split_whitespace().collect();
525    let firsts = match words.as_slice() {
526        [] => Vec::new(),
527        [one] => vec![one],
528        [first, .., last] => vec![first, last],
529    };
530    let turkish = locale.starts_with("tr") || locale.starts_with("az");
531    firsts
532        .iter()
533        .filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
534        .flat_map(|c| match c {
535            'i' if turkish => "İ".chars().collect::<Vec<_>>(),
536            'ı' if turkish => vec!['I'],
537            c => c.to_uppercase().collect(),
538        })
539        .collect()
540}
541
542impl Component for Avatar {
543    fn render(&self, out: &mut String, r: &Render) {
544        let px = self.size.px();
545        let name = r.i18n.text(r.locale, &self.name);
546        let tone = name.bytes().fold(0u32, |h, b| {
547            h.wrapping_mul(31)
548                .wrapping_add(u32::from(b))
549        }) % 5;
550        let class = format!("z-avatar z-avatar-{} z-avatar-tone-{tone}", px);
551        let hidden = if self.decorative {
552            r#" aria-hidden="true""#
553        } else {
554            ""
555        };
556        let image = self
557            .image
558            .as_deref()
559            .filter(|src| super::embed::policy(r).allows_image(src));
560        match image {
561            Some(src) => out.push_str(&format!(
562                r#"<img class="{class}" src="{}" alt="{}" width="{px}" height="{px}" loading="lazy" decoding="async"{hidden}>"#,
563                escape(src),
564                if self.decorative { String::new() } else { escape(&name) }
565            )),
566            None => {
567                let label = if self.decorative { String::new() } else { format!(r#" role="img" aria-label="{}""#, escape(&name)) };
568                out.push_str(&format!(
569                    r#"<span class="{class}"{label}{hidden}><span aria-hidden="true">{}</span></span>"#,
570                    escape(&initials(&name, r.locale))
571                ));
572            }
573        }
574    }
575
576    fn schema(&self) -> Json {
577        json!({ "type": "avatar", "name": self.name, "image": self.image, "size": self.size, "decorative": self.decorative })
578    }
579}
580
581/// Content hidden until the reader opens it, such as the answer to a quiz or the end of a
582/// story. It is a native disclosure (`<details>` and `<summary>`): the label is a button for
583/// keyboards and screen readers, and the content stays out of the reading order until it is
584/// opened. The label comes from the app.
585///
586/// ```
587/// use zeyn::prelude::*;
588/// use zeyn::ui::Spoiler;
589/// let answer = Spoiler::new("Show the answer").add(Paragraph::new("Forty two."));
590/// ```
591pub struct Spoiler {
592    label: Text,
593    nodes: Vec<Node>,
594}
595
596impl Spoiler {
597    pub fn new(label: impl Into<Text>) -> Self {
598        Spoiler {
599            label: label.into(),
600            nodes: Vec::new(),
601        }
602    }
603
604    #[allow(
605        clippy::should_implement_trait,
606        reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
607    )]
608    pub fn add(mut self, node: impl Into<Node>) -> Self {
609        self.nodes.push(node.into());
610        self
611    }
612}
613
614impl Component for Spoiler {
615    fn render(&self, out: &mut String, r: &Render) {
616        out.push_str(&format!(
617            r#"<details class="z-spoiler"><summary class="z-spoiler-label">{}</summary><div class="z-spoiler-body">"#,
618            r.text(&self.label)
619        ));
620        for (i, node) in self.nodes.iter().enumerate() {
621            render_child(out, r, i, node);
622        }
623        out.push_str("</div></details>");
624    }
625
626    fn schema(&self) -> Json {
627        json!({ "type": "spoiler", "label": self.label, "children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>() })
628    }
629
630    fn children(&self) -> &[Node] {
631        &self.nodes
632    }
633}