1use crate::form::escape_attr;
7use crate::html::Markup;
8use crate::Shell;
9use laterite_core::Text;
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum Style {
15 Primary,
16 #[default]
17 Secondary,
18 Danger,
19 Ghost,
20}
21
22impl Style {
23 fn class(self) -> &'static str {
24 match self {
25 Style::Primary => "lat-btn--primary",
26 Style::Secondary => "lat-btn--secondary",
27 Style::Danger => "lat-btn--danger",
28 Style::Ghost => "lat-btn--ghost",
29 }
30 }
31}
32
33#[derive(Clone, Debug)]
36enum Action {
37 Link(String),
38 Post(String),
39}
40
41#[derive(Clone, Debug)]
43pub struct Button {
44 label: Text,
45 action: Action,
46 style: Style,
47 small: bool,
48 icon: Option<String>,
49 confirm: Option<Text>,
50}
51
52impl Button {
53 pub fn link(label: impl Into<Text>, href: impl Into<String>) -> Self {
55 Self {
56 label: label.into(),
57 action: Action::Link(href.into()),
58 style: Style::Secondary,
59 small: false,
60 icon: None,
61 confirm: None,
62 }
63 }
64
65 pub fn post(label: impl Into<Text>, action: impl Into<String>) -> Self {
67 Self {
68 label: label.into(),
69 action: Action::Post(action.into()),
70 style: Style::Secondary,
71 small: false,
72 icon: None,
73 confirm: None,
74 }
75 }
76
77 pub fn style(mut self, style: Style) -> Self {
78 self.style = style;
79 self
80 }
81
82 pub fn primary(self) -> Self {
83 self.style(Style::Primary)
84 }
85
86 pub fn danger(self) -> Self {
87 self.style(Style::Danger)
88 }
89
90 pub fn ghost(self) -> Self {
91 self.style(Style::Ghost)
92 }
93
94 pub fn small(mut self) -> Self {
96 self.small = true;
97 self
98 }
99
100 pub fn icon(mut self, name: impl Into<String>) -> Self {
102 self.icon = Some(name.into());
103 self
104 }
105
106 pub fn confirm(mut self, question: impl Into<Text>) -> Self {
108 self.confirm = Some(question.into());
109 self
110 }
111
112 pub fn render(&self, shell: &Shell) -> Markup {
113 let mut class = format!("lat-btn {}", self.style.class());
114 if self.small {
115 class.push_str(" lat-btn--sm");
116 }
117 let icon = self
118 .icon
119 .as_deref()
120 .map(|name| format!(r#"<span class="lat-btn__icon">{}</span>"#, shell.icon(name)))
121 .unwrap_or_default();
122 let label = escape_attr(&shell.tt(&self.label));
123 let html = match &self.action {
124 Action::Link(href) => format!(
125 r#"<a class="{class}" href="{}">{icon}{label}</a>"#,
126 escape_attr(href)
127 ),
128 Action::Post(action) => {
129 let confirm = self
130 .confirm
131 .as_ref()
132 .map(|q| format!(r#" data-lat-confirm="{}""#, escape_attr(&shell.tt(q))))
133 .unwrap_or_default();
134 format!(
135 r#"<form method="post" action="{}"><input type="hidden" name="_csrf" value="{}"><button class="{class}" type="submit"{confirm}>{icon}{label}</button></form>"#,
136 escape_attr(action),
137 escape_attr(&shell.csrf_token)
138 )
139 }
140 };
141 Markup::from_override(html)
142 }
143}
144
145#[derive(Clone, Debug, Default)]
147pub struct Toolbar {
148 title: Option<Text>,
149 buttons: Vec<Button>,
150 section: bool,
151}
152
153impl Toolbar {
154 pub fn new(title: impl Into<Text>) -> Self {
156 Self {
157 title: Some(title.into()),
158 buttons: Vec::new(),
159 section: false,
160 }
161 }
162
163 pub fn section(title: impl Into<Text>) -> Self {
165 Self {
166 title: Some(title.into()),
167 buttons: Vec::new(),
168 section: true,
169 }
170 }
171
172 pub fn button(mut self, button: Button) -> Self {
173 self.buttons.push(button);
174 self
175 }
176
177 pub fn render(&self, shell: &Shell) -> Markup {
178 let class = if self.section {
179 "lat-toolbar lat-toolbar--section"
180 } else {
181 "lat-toolbar"
182 };
183 let title = match &self.title {
184 Some(title) if self.section => format!("<h2>{}</h2>", escape_attr(&shell.tt(title))),
185 Some(title) => format!("<h1>{}</h1>", escape_attr(&shell.tt(title))),
186 None => String::new(),
187 };
188 let actions = if self.buttons.is_empty() {
189 String::new()
190 } else {
191 let inner: String = self
192 .buttons
193 .iter()
194 .map(|b| b.render(shell).into_string())
195 .collect();
196 format!(r#"<div class="lat-toolbar__actions">{inner}</div>"#)
197 };
198 Markup::from_override(format!(r#"<div class="{class}">{title}{actions}</div>"#))
199 }
200}
201
202#[derive(Clone, Debug)]
204pub struct Badge {
205 label: Text,
206}
207
208impl Badge {
209 pub fn new(label: impl Into<Text>) -> Self {
210 Self {
211 label: label.into(),
212 }
213 }
214
215 pub fn render(&self, shell: &Shell) -> Markup {
216 Markup::from_override(format!(
217 r#"<span class="lat-badge">{}</span>"#,
218 escape_attr(&shell.tt(&self.label))
219 ))
220 }
221}
222
223#[derive(Clone, Debug)]
225pub struct Card {
226 body: Markup,
227}
228
229impl Card {
230 pub fn new(body: Markup) -> Self {
231 Self { body }
232 }
233
234 pub fn render(&self) -> Markup {
235 Markup::from_override(format!(
236 r#"<div class="lat-card">{}</div>"#,
237 self.body.clone().into_string()
238 ))
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn a_link_button_carries_its_style_size_and_icon() {
248 let shell = Shell::test();
249 let html = Button::link("New post", "/admin/posts/new")
250 .primary()
251 .small()
252 .icon("plus")
253 .render(&shell)
254 .into_string();
255 assert!(
256 html.starts_with(
257 r#"<a class="lat-btn lat-btn--primary lat-btn--sm" href="/admin/posts/new">"#
258 ),
259 "{html}"
260 );
261 assert!(
262 html.contains("lat-btn__icon") && html.ends_with("New post</a>"),
263 "{html}"
264 );
265 }
266
267 #[test]
268 fn a_post_button_is_a_form_with_the_token_and_asks_first() {
269 let shell = Shell::test();
270 let html = Button::post("Rebuild", "/admin/reports/rebuild")
271 .danger()
272 .confirm("Rebuild every report?")
273 .render(&shell)
274 .into_string();
275 assert!(
276 html.starts_with(r#"<form method="post" action="/admin/reports/rebuild">"#),
277 "{html}"
278 );
279 assert!(
280 html.contains(r#"name="_csrf""#)
281 && html.contains(r#"data-lat-confirm="Rebuild every report?""#),
282 "{html}"
283 );
284 assert!(
285 html.contains("lat-btn--danger") && !html.contains("lat-btn--sm"),
286 "{html}"
287 );
288 }
289
290 #[test]
291 fn a_section_toolbar_heads_its_buttons_and_text_is_escaped() {
292 let shell = Shell::test();
293 let html = Toolbar::section("Notes <b>")
294 .button(Button::link("Add", "/x"))
295 .render(&shell)
296 .into_string();
297 assert!(html.starts_with(r#"<div class="lat-toolbar lat-toolbar--section"><h2>Notes <b></h2><div class="lat-toolbar__actions">"#), "{html}");
298 assert_eq!(
299 Badge::new("Stale").render(&shell).into_string(),
300 r#"<span class="lat-badge">Stale</span>"#
301 );
302 assert_eq!(
303 Card::new(Markup::from_override("<p>x</p>".to_string()))
304 .render()
305 .into_string(),
306 r#"<div class="lat-card"><p>x</p></div>"#
307 );
308 }
309}