1use askama::Template;
2use std::fmt::{self, Write as _};
3
4#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5pub struct HtmlAttr<'a> {
6 pub name: &'a str,
7 pub value: &'a str,
8}
9
10impl<'a> HtmlAttr<'a> {
11 pub const fn new(name: &'a str, value: &'a str) -> Self {
12 Self { name, value }
13 }
14
15 pub const fn hx_get(value: &'a str) -> Self {
16 Self::new("hx-get", value)
17 }
18
19 pub const fn hx_post(value: &'a str) -> Self {
20 Self::new("hx-post", value)
21 }
22
23 pub const fn hx_put(value: &'a str) -> Self {
24 Self::new("hx-put", value)
25 }
26
27 pub const fn hx_patch(value: &'a str) -> Self {
28 Self::new("hx-patch", value)
29 }
30
31 pub const fn hx_delete(value: &'a str) -> Self {
32 Self::new("hx-delete", value)
33 }
34
35 pub const fn hx_target(value: &'a str) -> Self {
36 Self::new("hx-target", value)
37 }
38
39 pub const fn hx_swap(value: &'a str) -> Self {
40 Self::new("hx-swap", value)
41 }
42
43 pub const fn hx_trigger(value: &'a str) -> Self {
44 Self::new("hx-trigger", value)
45 }
46
47 pub const fn hx_confirm(value: &'a str) -> Self {
48 Self::new("hx-confirm", value)
49 }
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct TrustedHtml<'a> {
54 html: &'a str,
55}
56
57impl<'a> TrustedHtml<'a> {
58 pub const fn new(html: &'a str) -> Self {
59 Self { html }
60 }
61
62 pub const fn as_str(self) -> &'a str {
63 self.html
64 }
65}
66
67impl fmt::Display for TrustedHtml<'_> {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str(self.html)
70 }
71}
72
73impl askama::FastWritable for TrustedHtml<'_> {
74 #[inline]
75 fn write_into(&self, dest: &mut dyn fmt::Write, _: &dyn askama::Values) -> askama::Result<()> {
76 Ok(dest.write_str(self.html)?)
77 }
78}
79
80impl askama::filters::HtmlSafe for TrustedHtml<'_> {}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum ButtonVariant {
84 Default,
85 Primary,
86 Ghost,
87 Danger,
88}
89
90impl ButtonVariant {
91 fn class(self) -> &'static str {
92 match self {
93 Self::Default => "",
94 Self::Primary => " primary",
95 Self::Ghost => " ghost",
96 Self::Danger => " danger",
97 }
98 }
99}
100
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum ButtonSize {
103 Default,
104 Small,
105 Large,
106}
107
108impl ButtonSize {
109 fn class(self) -> &'static str {
110 match self {
111 Self::Default => "",
112 Self::Small => " sm",
113 Self::Large => " lg",
114 }
115 }
116}
117
118#[derive(Debug, Template)]
119#[non_exhaustive]
120#[template(path = "components/button.html")]
121pub struct Button<'a> {
122 pub label: &'a str,
123 pub href: Option<&'a str>,
124 pub variant: ButtonVariant,
125 pub size: ButtonSize,
126 pub attrs: &'a [HtmlAttr<'a>],
127 pub disabled: bool,
128 pub button_type: &'a str,
129}
130
131impl<'a> Button<'a> {
132 pub const fn new(label: &'a str) -> Self {
133 Self {
134 label,
135 href: None,
136 variant: ButtonVariant::Default,
137 size: ButtonSize::Default,
138 attrs: &[],
139 disabled: false,
140 button_type: "button",
141 }
142 }
143
144 pub const fn primary(label: &'a str) -> Self {
145 Self {
146 variant: ButtonVariant::Primary,
147 ..Self::new(label)
148 }
149 }
150
151 pub const fn link(label: &'a str, href: &'a str) -> Self {
152 Self {
153 href: Some(href),
154 ..Self::new(label)
155 }
156 }
157
158 pub const fn with_href(mut self, href: &'a str) -> Self {
159 self.href = Some(href);
160 self
161 }
162
163 pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
164 self.variant = variant;
165 self
166 }
167
168 pub const fn with_size(mut self, size: ButtonSize) -> Self {
169 self.size = size;
170 self
171 }
172
173 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
174 self.attrs = attrs;
175 self
176 }
177
178 pub const fn disabled(mut self) -> Self {
179 self.disabled = true;
180 self
181 }
182
183 pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
184 self.button_type = button_type;
185 self
186 }
187
188 pub fn class_name(&self) -> String {
189 format!("wf-btn{}{}", self.variant.class(), self.size.class())
190 }
191}
192
193impl<'a> askama::filters::HtmlSafe for Button<'a> {}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196pub enum FeedbackKind {
197 Info,
198 Ok,
199 Warn,
200 Error,
201}
202
203impl FeedbackKind {
204 fn class(self) -> &'static str {
205 match self {
206 Self::Info => "info",
207 Self::Ok => "ok",
208 Self::Warn => "warn",
209 Self::Error => "err",
210 }
211 }
212}
213
214#[derive(Debug, Template)]
215#[non_exhaustive]
216#[template(path = "components/alert.html")]
217pub struct Alert<'a> {
218 pub kind: FeedbackKind,
219 pub title: Option<&'a str>,
220 pub message: &'a str,
221}
222
223impl<'a> Alert<'a> {
224 pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
225 Self {
226 kind,
227 title: None,
228 message,
229 }
230 }
231
232 pub const fn with_title(mut self, title: &'a str) -> Self {
233 self.title = Some(title);
234 self
235 }
236
237 pub fn class_name(&self) -> String {
238 format!("wf-alert {}", self.kind.class())
239 }
240}
241
242impl<'a> askama::filters::HtmlSafe for Alert<'a> {}
243
244#[derive(Debug, Template)]
245#[non_exhaustive]
246#[template(path = "components/tag.html")]
247pub struct Tag<'a> {
248 pub kind: Option<FeedbackKind>,
249 pub label: &'a str,
250 pub dot: bool,
251}
252
253impl<'a> Tag<'a> {
254 pub const fn new(label: &'a str) -> Self {
255 Self {
256 kind: None,
257 label,
258 dot: false,
259 }
260 }
261
262 pub const fn status(kind: FeedbackKind, label: &'a str) -> Self {
263 Self {
264 kind: Some(kind),
265 label,
266 dot: true,
267 }
268 }
269
270 pub const fn with_kind(mut self, kind: FeedbackKind) -> Self {
271 self.kind = Some(kind);
272 self
273 }
274
275 pub const fn with_dot(mut self) -> Self {
276 self.dot = true;
277 self
278 }
279
280 pub fn class_name(&self) -> String {
281 match self.kind {
282 Some(kind) => format!("wf-tag {}", kind.class()),
283 None => "wf-tag".to_owned(),
284 }
285 }
286}
287
288impl<'a> askama::filters::HtmlSafe for Tag<'a> {}
289
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub enum FieldState {
292 Default,
293 Error,
294 Success,
295}
296
297impl FieldState {
298 fn class(self) -> &'static str {
299 match self {
300 Self::Default => "",
301 Self::Error => " is-error",
302 Self::Success => " is-success",
303 }
304 }
305}
306
307#[derive(Debug, Template)]
308#[non_exhaustive]
309#[template(path = "components/field.html")]
310pub struct Field<'a> {
311 pub label: &'a str,
312 pub control_html: TrustedHtml<'a>,
313 pub hint: Option<&'a str>,
314 pub state: FieldState,
315}
316
317impl<'a> Field<'a> {
318 pub const fn new(label: &'a str, control_html: TrustedHtml<'a>) -> Self {
319 Self {
320 label,
321 control_html,
322 hint: None,
323 state: FieldState::Default,
324 }
325 }
326
327 pub const fn with_hint(mut self, hint: &'a str) -> Self {
328 self.hint = Some(hint);
329 self
330 }
331
332 pub const fn with_state(mut self, state: FieldState) -> Self {
333 self.state = state;
334 self
335 }
336
337 pub fn class_name(&self) -> String {
338 format!("wf-field{}", self.state.class())
339 }
340}
341
342impl<'a> askama::filters::HtmlSafe for Field<'a> {}
343
344#[derive(Debug, Template)]
345#[non_exhaustive]
346#[template(path = "components/form.html")]
347pub struct Form<'a> {
348 pub body_html: TrustedHtml<'a>,
349 pub action: Option<&'a str>,
350 pub method: &'a str,
351 pub enctype: Option<&'a str>,
352 pub attrs: &'a [HtmlAttr<'a>],
353}
354
355impl<'a> Form<'a> {
356 pub const fn new(body_html: TrustedHtml<'a>) -> Self {
357 Self {
358 body_html,
359 action: None,
360 method: "post",
361 enctype: None,
362 attrs: &[],
363 }
364 }
365
366 pub const fn with_action(mut self, action: &'a str) -> Self {
367 self.action = Some(action);
368 self
369 }
370
371 pub const fn with_method(mut self, method: &'a str) -> Self {
372 self.method = method;
373 self
374 }
375
376 pub const fn with_enctype(mut self, enctype: &'a str) -> Self {
377 self.enctype = Some(enctype);
378 self
379 }
380
381 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
382 self.attrs = attrs;
383 self
384 }
385}
386
387impl<'a> askama::filters::HtmlSafe for Form<'a> {}
388
389#[derive(Debug, Template)]
390#[non_exhaustive]
391#[template(path = "components/form_section.html")]
392pub struct FormSection<'a> {
393 pub title: &'a str,
394 pub body_html: TrustedHtml<'a>,
395 pub description: Option<&'a str>,
396 pub actions_html: Option<TrustedHtml<'a>>,
397}
398
399impl<'a> FormSection<'a> {
400 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
401 Self {
402 title,
403 body_html,
404 description: None,
405 actions_html: None,
406 }
407 }
408
409 pub const fn with_description(mut self, description: &'a str) -> Self {
410 self.description = Some(description);
411 self
412 }
413
414 pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
415 self.actions_html = Some(actions_html);
416 self
417 }
418}
419
420impl<'a> askama::filters::HtmlSafe for FormSection<'a> {}
421
422#[derive(Debug, Template)]
423#[non_exhaustive]
424#[template(path = "components/form_actions.html")]
425pub struct FormActions<'a> {
426 pub primary_html: TrustedHtml<'a>,
427 pub secondary_html: Option<TrustedHtml<'a>>,
428}
429
430impl<'a> FormActions<'a> {
431 pub const fn new(primary_html: TrustedHtml<'a>) -> Self {
432 Self {
433 primary_html,
434 secondary_html: None,
435 }
436 }
437
438 pub const fn with_secondary(mut self, secondary_html: TrustedHtml<'a>) -> Self {
439 self.secondary_html = Some(secondary_html);
440 self
441 }
442}
443
444impl<'a> askama::filters::HtmlSafe for FormActions<'a> {}
445
446#[derive(Debug, Template)]
447#[non_exhaustive]
448#[template(path = "components/button_group.html")]
449pub struct ButtonGroup<'a> {
450 pub buttons: &'a [Button<'a>],
451 pub attrs: &'a [HtmlAttr<'a>],
452}
453
454impl<'a> ButtonGroup<'a> {
455 pub const fn new(buttons: &'a [Button<'a>]) -> Self {
456 Self {
457 buttons,
458 attrs: &[],
459 }
460 }
461
462 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
463 self.attrs = attrs;
464 self
465 }
466}
467
468impl<'a> askama::filters::HtmlSafe for ButtonGroup<'a> {}
469
470#[derive(Debug, Template)]
471#[non_exhaustive]
472#[template(path = "components/split_button.html")]
473pub struct SplitButton<'a> {
474 pub action: Button<'a>,
475 pub menu: Button<'a>,
476 pub attrs: &'a [HtmlAttr<'a>],
477}
478
479impl<'a> SplitButton<'a> {
480 pub const fn new(action: Button<'a>, menu: Button<'a>) -> Self {
481 Self {
482 action,
483 menu,
484 attrs: &[],
485 }
486 }
487
488 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
489 self.attrs = attrs;
490 self
491 }
492}
493
494impl<'a> askama::filters::HtmlSafe for SplitButton<'a> {}
495
496#[derive(Debug, Template)]
497#[non_exhaustive]
498#[template(path = "components/icon_button.html")]
499pub struct IconButton<'a> {
500 pub icon: TrustedHtml<'a>,
501 pub label: &'a str,
502 pub href: Option<&'a str>,
503 pub variant: ButtonVariant,
504 pub attrs: &'a [HtmlAttr<'a>],
505 pub disabled: bool,
506 pub button_type: &'a str,
507}
508
509impl<'a> IconButton<'a> {
510 pub const fn new(icon: TrustedHtml<'a>, label: &'a str) -> Self {
511 Self {
512 icon,
513 label,
514 href: None,
515 variant: ButtonVariant::Default,
516 attrs: &[],
517 disabled: false,
518 button_type: "button",
519 }
520 }
521
522 pub const fn with_href(mut self, href: &'a str) -> Self {
523 self.href = Some(href);
524 self
525 }
526
527 pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
528 self.variant = variant;
529 self
530 }
531
532 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
533 self.attrs = attrs;
534 self
535 }
536
537 pub const fn disabled(mut self) -> Self {
538 self.disabled = true;
539 self
540 }
541
542 pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
543 self.button_type = button_type;
544 self
545 }
546
547 pub fn class_name(&self) -> String {
548 format!("wf-icon-btn{}", self.variant.class())
549 }
550}
551
552impl<'a> askama::filters::HtmlSafe for IconButton<'a> {}
553
554#[derive(Clone, Copy, Debug, Eq, PartialEq)]
555pub enum ControlSize {
556 Default,
557 Small,
558}
559
560impl ControlSize {
561 fn class(self) -> &'static str {
562 match self {
563 Self::Default => "",
564 Self::Small => " sm",
565 }
566 }
567}
568
569#[derive(Debug, Template)]
570#[non_exhaustive]
571#[template(path = "components/input.html")]
572pub struct Input<'a> {
573 pub name: &'a str,
574 pub input_type: &'a str,
575 pub value: Option<&'a str>,
576 pub placeholder: Option<&'a str>,
577 pub size: ControlSize,
578 pub attrs: &'a [HtmlAttr<'a>],
579 pub disabled: bool,
580 pub required: bool,
581}
582
583impl<'a> Input<'a> {
584 pub const fn new(name: &'a str) -> Self {
585 Self {
586 name,
587 input_type: "text",
588 value: None,
589 placeholder: None,
590 size: ControlSize::Default,
591 attrs: &[],
592 disabled: false,
593 required: false,
594 }
595 }
596
597 pub const fn email(name: &'a str) -> Self {
598 Self {
599 input_type: "email",
600 ..Self::new(name)
601 }
602 }
603
604 pub const fn url(name: &'a str) -> Self {
605 Self {
606 input_type: "url",
607 ..Self::new(name)
608 }
609 }
610
611 pub const fn with_type(mut self, input_type: &'a str) -> Self {
612 self.input_type = input_type;
613 self
614 }
615
616 pub const fn with_value(mut self, value: &'a str) -> Self {
617 self.value = Some(value);
618 self
619 }
620
621 pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
622 self.placeholder = Some(placeholder);
623 self
624 }
625
626 pub const fn with_size(mut self, size: ControlSize) -> Self {
627 self.size = size;
628 self
629 }
630
631 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
632 self.attrs = attrs;
633 self
634 }
635
636 pub const fn disabled(mut self) -> Self {
637 self.disabled = true;
638 self
639 }
640
641 pub const fn required(mut self) -> Self {
642 self.required = true;
643 self
644 }
645
646 pub fn class_name(&self) -> String {
647 format!("wf-input{}", self.size.class())
648 }
649}
650
651impl<'a> askama::filters::HtmlSafe for Input<'a> {}
652
653#[derive(Debug, Template)]
654#[non_exhaustive]
655#[template(path = "components/textarea.html")]
656pub struct Textarea<'a> {
657 pub name: &'a str,
658 pub value: Option<&'a str>,
659 pub placeholder: Option<&'a str>,
660 pub rows: Option<u16>,
661 pub attrs: &'a [HtmlAttr<'a>],
662 pub disabled: bool,
663 pub required: bool,
664}
665
666impl<'a> Textarea<'a> {
667 pub const fn new(name: &'a str) -> Self {
668 Self {
669 name,
670 value: None,
671 placeholder: None,
672 rows: None,
673 attrs: &[],
674 disabled: false,
675 required: false,
676 }
677 }
678
679 pub const fn with_value(mut self, value: &'a str) -> Self {
680 self.value = Some(value);
681 self
682 }
683
684 pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
685 self.placeholder = Some(placeholder);
686 self
687 }
688
689 pub const fn with_rows(mut self, rows: u16) -> Self {
690 self.rows = Some(rows);
691 self
692 }
693
694 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
695 self.attrs = attrs;
696 self
697 }
698
699 pub const fn disabled(mut self) -> Self {
700 self.disabled = true;
701 self
702 }
703
704 pub const fn required(mut self) -> Self {
705 self.required = true;
706 self
707 }
708}
709
710impl<'a> askama::filters::HtmlSafe for Textarea<'a> {}
711
712#[derive(Clone, Copy, Debug, Eq, PartialEq)]
713pub struct SelectOption<'a> {
714 pub value: &'a str,
715 pub label: &'a str,
716 pub selected: bool,
717 pub disabled: bool,
718}
719
720impl<'a> SelectOption<'a> {
721 pub const fn new(value: &'a str, label: &'a str) -> Self {
722 Self {
723 value,
724 label,
725 selected: false,
726 disabled: false,
727 }
728 }
729
730 pub const fn selected(mut self) -> Self {
731 self.selected = true;
732 self
733 }
734
735 pub const fn disabled(mut self) -> Self {
736 self.disabled = true;
737 self
738 }
739}
740
741#[derive(Debug, Template)]
742#[non_exhaustive]
743#[template(path = "components/select.html")]
744pub struct Select<'a> {
745 pub name: &'a str,
746 pub options: &'a [SelectOption<'a>],
747 pub size: ControlSize,
748 pub attrs: &'a [HtmlAttr<'a>],
749 pub disabled: bool,
750 pub required: bool,
751}
752
753impl<'a> Select<'a> {
754 pub const fn new(name: &'a str, options: &'a [SelectOption<'a>]) -> Self {
755 Self {
756 name,
757 options,
758 size: ControlSize::Default,
759 attrs: &[],
760 disabled: false,
761 required: false,
762 }
763 }
764
765 pub const fn with_size(mut self, size: ControlSize) -> Self {
766 self.size = size;
767 self
768 }
769
770 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
771 self.attrs = attrs;
772 self
773 }
774
775 pub const fn disabled(mut self) -> Self {
776 self.disabled = true;
777 self
778 }
779
780 pub const fn required(mut self) -> Self {
781 self.required = true;
782 self
783 }
784
785 pub fn class_name(&self) -> String {
786 format!("wf-select{}", self.size.class())
787 }
788}
789
790impl<'a> askama::filters::HtmlSafe for Select<'a> {}
791
792#[derive(Debug, Template)]
793#[non_exhaustive]
794#[template(path = "components/input_group.html")]
795pub struct InputGroup<'a> {
796 pub control_html: TrustedHtml<'a>,
797 pub prefix: Option<&'a str>,
798 pub suffix: Option<&'a str>,
799 pub attrs: &'a [HtmlAttr<'a>],
800}
801
802impl<'a> InputGroup<'a> {
803 pub const fn new(control_html: TrustedHtml<'a>) -> Self {
804 Self {
805 control_html,
806 prefix: None,
807 suffix: None,
808 attrs: &[],
809 }
810 }
811
812 pub const fn with_prefix(mut self, prefix: &'a str) -> Self {
813 self.prefix = Some(prefix);
814 self
815 }
816
817 pub const fn with_suffix(mut self, suffix: &'a str) -> Self {
818 self.suffix = Some(suffix);
819 self
820 }
821
822 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
823 self.attrs = attrs;
824 self
825 }
826}
827
828impl<'a> askama::filters::HtmlSafe for InputGroup<'a> {}
829
830#[derive(Clone, Copy, Debug, Eq, PartialEq)]
831pub enum CheckKind {
832 Checkbox,
833 Radio,
834}
835
836impl CheckKind {
837 fn input_type(self) -> &'static str {
838 match self {
839 Self::Checkbox => "checkbox",
840 Self::Radio => "radio",
841 }
842 }
843}
844
845#[derive(Debug, Template)]
846#[non_exhaustive]
847#[template(path = "components/check_row.html")]
848pub struct CheckRow<'a> {
849 pub kind: CheckKind,
850 pub name: &'a str,
851 pub value: &'a str,
852 pub label: &'a str,
853 pub attrs: &'a [HtmlAttr<'a>],
854 pub checked: bool,
855 pub disabled: bool,
856}
857
858impl<'a> CheckRow<'a> {
859 pub const fn checkbox(name: &'a str, value: &'a str, label: &'a str) -> Self {
860 Self {
861 kind: CheckKind::Checkbox,
862 name,
863 value,
864 label,
865 attrs: &[],
866 checked: false,
867 disabled: false,
868 }
869 }
870
871 pub const fn radio(name: &'a str, value: &'a str, label: &'a str) -> Self {
872 Self {
873 kind: CheckKind::Radio,
874 ..Self::checkbox(name, value, label)
875 }
876 }
877
878 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
879 self.attrs = attrs;
880 self
881 }
882
883 pub const fn checked(mut self) -> Self {
884 self.checked = true;
885 self
886 }
887
888 pub const fn disabled(mut self) -> Self {
889 self.disabled = true;
890 self
891 }
892
893 pub fn input_type(&self) -> &'static str {
894 self.kind.input_type()
895 }
896}
897
898impl<'a> askama::filters::HtmlSafe for CheckRow<'a> {}
899
900#[derive(Debug, Template)]
901#[non_exhaustive]
902#[template(path = "components/switch.html")]
903pub struct Switch<'a> {
904 pub name: &'a str,
905 pub value: &'a str,
906 pub attrs: &'a [HtmlAttr<'a>],
907 pub checked: bool,
908 pub disabled: bool,
909}
910
911impl<'a> Switch<'a> {
912 pub const fn new(name: &'a str) -> Self {
913 Self {
914 name,
915 value: "on",
916 attrs: &[],
917 checked: false,
918 disabled: false,
919 }
920 }
921
922 pub const fn with_value(mut self, value: &'a str) -> Self {
923 self.value = value;
924 self
925 }
926
927 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
928 self.attrs = attrs;
929 self
930 }
931
932 pub const fn checked(mut self) -> Self {
933 self.checked = true;
934 self
935 }
936
937 pub const fn disabled(mut self) -> Self {
938 self.disabled = true;
939 self
940 }
941}
942
943impl<'a> askama::filters::HtmlSafe for Switch<'a> {}
944
945#[derive(Debug, Template)]
946#[non_exhaustive]
947#[template(path = "components/range.html")]
948pub struct Range<'a> {
949 pub name: &'a str,
950 pub value: Option<&'a str>,
951 pub min: Option<&'a str>,
952 pub max: Option<&'a str>,
953 pub step: Option<&'a str>,
954 pub attrs: &'a [HtmlAttr<'a>],
955 pub disabled: bool,
956}
957
958impl<'a> Range<'a> {
959 pub const fn new(name: &'a str) -> Self {
960 Self {
961 name,
962 value: None,
963 min: None,
964 max: None,
965 step: None,
966 attrs: &[],
967 disabled: false,
968 }
969 }
970
971 pub const fn with_value(mut self, value: &'a str) -> Self {
972 self.value = Some(value);
973 self
974 }
975
976 pub const fn with_bounds(mut self, min: &'a str, max: &'a str) -> Self {
977 self.min = Some(min);
978 self.max = Some(max);
979 self
980 }
981
982 pub const fn with_step(mut self, step: &'a str) -> Self {
983 self.step = Some(step);
984 self
985 }
986
987 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
988 self.attrs = attrs;
989 self
990 }
991
992 pub const fn disabled(mut self) -> Self {
993 self.disabled = true;
994 self
995 }
996}
997
998impl<'a> askama::filters::HtmlSafe for Range<'a> {}
999
1000#[derive(Debug, Template)]
1001#[non_exhaustive]
1002#[template(path = "components/dropzone.html")]
1003pub struct Dropzone<'a> {
1004 pub name: &'a str,
1005 pub title: &'a str,
1006 pub hint: Option<&'a str>,
1007 pub accept: Option<&'a str>,
1008 pub attrs: &'a [HtmlAttr<'a>],
1009 pub multiple: bool,
1010 pub disabled: bool,
1011 pub dragover: bool,
1012}
1013
1014impl<'a> Dropzone<'a> {
1015 pub const fn new(name: &'a str) -> Self {
1016 Self {
1017 name,
1018 title: "Drop files or click",
1019 hint: None,
1020 accept: None,
1021 attrs: &[],
1022 multiple: false,
1023 disabled: false,
1024 dragover: false,
1025 }
1026 }
1027
1028 pub const fn with_title(mut self, title: &'a str) -> Self {
1029 self.title = title;
1030 self
1031 }
1032
1033 pub const fn with_hint(mut self, hint: &'a str) -> Self {
1034 self.hint = Some(hint);
1035 self
1036 }
1037
1038 pub const fn with_accept(mut self, accept: &'a str) -> Self {
1039 self.accept = Some(accept);
1040 self
1041 }
1042
1043 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1044 self.attrs = attrs;
1045 self
1046 }
1047
1048 pub const fn multiple(mut self) -> Self {
1049 self.multiple = true;
1050 self
1051 }
1052
1053 pub const fn disabled(mut self) -> Self {
1054 self.disabled = true;
1055 self
1056 }
1057
1058 pub const fn dragover(mut self) -> Self {
1059 self.dragover = true;
1060 self
1061 }
1062
1063 pub fn class_name(&self) -> String {
1064 let dragover = if self.dragover { " is-dragover" } else { "" };
1065 let disabled = if self.disabled { " is-disabled" } else { "" };
1066 format!("wf-dropzone{dragover}{disabled}")
1067 }
1068}
1069
1070impl<'a> askama::filters::HtmlSafe for Dropzone<'a> {}
1071
1072#[derive(Debug, Template)]
1073#[non_exhaustive]
1074#[template(path = "components/panel.html")]
1075pub struct Panel<'a> {
1076 pub title: &'a str,
1077 pub body_html: TrustedHtml<'a>,
1078 pub action_html: Option<TrustedHtml<'a>>,
1079 pub danger: bool,
1080 pub attrs: &'a [HtmlAttr<'a>],
1081}
1082
1083impl<'a> Panel<'a> {
1084 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1085 Self {
1086 title,
1087 body_html,
1088 action_html: None,
1089 danger: false,
1090 attrs: &[],
1091 }
1092 }
1093
1094 pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
1095 self.action_html = Some(action_html);
1096 self
1097 }
1098
1099 pub const fn danger(mut self) -> Self {
1100 self.danger = true;
1101 self
1102 }
1103
1104 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1105 self.attrs = attrs;
1106 self
1107 }
1108
1109 pub fn class_name(&self) -> &'static str {
1110 if self.danger {
1111 "wf-panel is-danger"
1112 } else {
1113 "wf-panel"
1114 }
1115 }
1116}
1117
1118impl<'a> askama::filters::HtmlSafe for Panel<'a> {}
1119
1120#[derive(Debug, Template)]
1121#[non_exhaustive]
1122#[template(path = "components/card.html")]
1123pub struct Card<'a> {
1124 pub title: &'a str,
1125 pub body_html: TrustedHtml<'a>,
1126 pub kicker: Option<&'a str>,
1127 pub foot_html: Option<TrustedHtml<'a>>,
1128 pub raised: bool,
1129 pub attrs: &'a [HtmlAttr<'a>],
1130}
1131
1132impl<'a> Card<'a> {
1133 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1134 Self {
1135 title,
1136 body_html,
1137 kicker: None,
1138 foot_html: None,
1139 raised: false,
1140 attrs: &[],
1141 }
1142 }
1143
1144 pub const fn with_kicker(mut self, kicker: &'a str) -> Self {
1145 self.kicker = Some(kicker);
1146 self
1147 }
1148
1149 pub const fn with_foot(mut self, foot_html: TrustedHtml<'a>) -> Self {
1150 self.foot_html = Some(foot_html);
1151 self
1152 }
1153
1154 pub const fn raised(mut self) -> Self {
1155 self.raised = true;
1156 self
1157 }
1158
1159 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1160 self.attrs = attrs;
1161 self
1162 }
1163
1164 pub fn class_name(&self) -> &'static str {
1165 if self.raised {
1166 "wf-card is-raised"
1167 } else {
1168 "wf-card"
1169 }
1170 }
1171}
1172
1173impl<'a> askama::filters::HtmlSafe for Card<'a> {}
1174
1175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1176pub enum BadgeKind {
1177 Default,
1178 Muted,
1179 Error,
1180}
1181
1182impl BadgeKind {
1183 fn class(self) -> &'static str {
1184 match self {
1185 Self::Default => "",
1186 Self::Muted => " muted",
1187 Self::Error => " err",
1188 }
1189 }
1190}
1191
1192#[derive(Debug, Template)]
1193#[non_exhaustive]
1194#[template(path = "components/badge.html")]
1195pub struct Badge<'a> {
1196 pub label: &'a str,
1197 pub kind: BadgeKind,
1198}
1199
1200impl<'a> Badge<'a> {
1201 pub const fn new(label: &'a str) -> Self {
1202 Self {
1203 label,
1204 kind: BadgeKind::Default,
1205 }
1206 }
1207
1208 pub const fn muted(label: &'a str) -> Self {
1209 Self {
1210 kind: BadgeKind::Muted,
1211 ..Self::new(label)
1212 }
1213 }
1214
1215 pub const fn error(label: &'a str) -> Self {
1216 Self {
1217 kind: BadgeKind::Error,
1218 ..Self::new(label)
1219 }
1220 }
1221
1222 pub fn class_name(&self) -> String {
1223 format!("wf-badge{}", self.kind.class())
1224 }
1225}
1226
1227impl<'a> askama::filters::HtmlSafe for Badge<'a> {}
1228
1229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1230pub enum AvatarSize {
1231 Default,
1232 Small,
1233 Large,
1234 ExtraLarge,
1235}
1236
1237impl AvatarSize {
1238 fn class(self) -> &'static str {
1239 match self {
1240 Self::Default => "",
1241 Self::Small => " sm",
1242 Self::Large => " lg",
1243 Self::ExtraLarge => " xl",
1244 }
1245 }
1246}
1247
1248#[derive(Debug, Template)]
1249#[non_exhaustive]
1250#[template(path = "components/avatar.html")]
1251pub struct Avatar<'a> {
1252 pub initials: &'a str,
1253 pub image_src: Option<&'a str>,
1254 pub size: AvatarSize,
1255 pub accent: bool,
1256}
1257
1258impl<'a> Avatar<'a> {
1259 pub const fn new(initials: &'a str) -> Self {
1260 Self {
1261 initials,
1262 image_src: None,
1263 size: AvatarSize::Default,
1264 accent: false,
1265 }
1266 }
1267
1268 pub const fn with_image(mut self, image_src: &'a str) -> Self {
1269 self.image_src = Some(image_src);
1270 self
1271 }
1272
1273 pub const fn with_size(mut self, size: AvatarSize) -> Self {
1274 self.size = size;
1275 self
1276 }
1277
1278 pub const fn accent(mut self) -> Self {
1279 self.accent = true;
1280 self
1281 }
1282
1283 pub fn class_name(&self) -> String {
1284 let accent = if self.accent { " accent" } else { "" };
1285 format!("wf-avatar{}{}", self.size.class(), accent)
1286 }
1287}
1288
1289impl<'a> askama::filters::HtmlSafe for Avatar<'a> {}
1290
1291#[derive(Debug, Template)]
1292#[non_exhaustive]
1293#[template(path = "components/avatar_group.html")]
1294pub struct AvatarGroup<'a> {
1295 pub avatars: &'a [Avatar<'a>],
1296}
1297
1298impl<'a> AvatarGroup<'a> {
1299 pub const fn new(avatars: &'a [Avatar<'a>]) -> Self {
1300 Self { avatars }
1301 }
1302}
1303
1304impl<'a> askama::filters::HtmlSafe for AvatarGroup<'a> {}
1305
1306#[derive(Debug, Template)]
1307#[non_exhaustive]
1308#[template(path = "components/user_button.html")]
1309pub struct UserButton<'a> {
1310 pub name: &'a str,
1311 pub email: &'a str,
1312 pub avatar: Avatar<'a>,
1313 pub compact: bool,
1314 pub attrs: &'a [HtmlAttr<'a>],
1315}
1316
1317impl<'a> UserButton<'a> {
1318 pub const fn new(name: &'a str, email: &'a str, avatar: Avatar<'a>) -> Self {
1319 Self {
1320 name,
1321 email,
1322 avatar,
1323 compact: false,
1324 attrs: &[],
1325 }
1326 }
1327
1328 pub const fn compact(mut self) -> Self {
1329 self.compact = true;
1330 self
1331 }
1332
1333 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1334 self.attrs = attrs;
1335 self
1336 }
1337
1338 pub fn class_name(&self) -> &'static str {
1339 if self.compact {
1340 "wf-user compact"
1341 } else {
1342 "wf-user"
1343 }
1344 }
1345}
1346
1347impl<'a> askama::filters::HtmlSafe for UserButton<'a> {}
1348
1349#[derive(Debug, Template)]
1350#[non_exhaustive]
1351#[template(path = "components/wordmark.html")]
1352pub struct Wordmark<'a> {
1353 pub name: &'a str,
1354 pub mark_html: Option<TrustedHtml<'a>>,
1355}
1356
1357impl<'a> Wordmark<'a> {
1358 pub const fn new(name: &'a str) -> Self {
1359 Self {
1360 name,
1361 mark_html: None,
1362 }
1363 }
1364
1365 pub const fn with_mark(mut self, mark_html: TrustedHtml<'a>) -> Self {
1366 self.mark_html = Some(mark_html);
1367 self
1368 }
1369}
1370
1371impl<'a> askama::filters::HtmlSafe for Wordmark<'a> {}
1372
1373#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1374pub enum DeltaKind {
1375 Neutral,
1376 Up,
1377 Down,
1378}
1379
1380impl DeltaKind {
1381 fn class(self) -> &'static str {
1382 match self {
1383 Self::Neutral => "",
1384 Self::Up => " up",
1385 Self::Down => " down",
1386 }
1387 }
1388}
1389
1390#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1391pub struct Stat<'a> {
1392 pub label: &'a str,
1393 pub value: &'a str,
1394 pub unit: Option<&'a str>,
1395 pub delta: Option<&'a str>,
1396 pub delta_kind: DeltaKind,
1397 pub foot: Option<&'a str>,
1398}
1399
1400impl<'a> Stat<'a> {
1401 pub const fn new(label: &'a str, value: &'a str) -> Self {
1402 Self {
1403 label,
1404 value,
1405 unit: None,
1406 delta: None,
1407 delta_kind: DeltaKind::Neutral,
1408 foot: None,
1409 }
1410 }
1411
1412 pub const fn with_unit(mut self, unit: &'a str) -> Self {
1413 self.unit = Some(unit);
1414 self
1415 }
1416
1417 pub const fn with_delta(mut self, delta: &'a str, kind: DeltaKind) -> Self {
1418 self.delta = Some(delta);
1419 self.delta_kind = kind;
1420 self
1421 }
1422
1423 pub const fn with_foot(mut self, foot: &'a str) -> Self {
1424 self.foot = Some(foot);
1425 self
1426 }
1427
1428 pub fn delta_class(&self) -> String {
1429 format!("wf-stat-delta{}", self.delta_kind.class())
1430 }
1431}
1432
1433#[derive(Debug, Template)]
1434#[non_exhaustive]
1435#[template(path = "components/stat_row.html")]
1436pub struct StatRow<'a> {
1437 pub stats: &'a [Stat<'a>],
1438}
1439
1440impl<'a> StatRow<'a> {
1441 pub const fn new(stats: &'a [Stat<'a>]) -> Self {
1442 Self { stats }
1443 }
1444}
1445
1446impl<'a> askama::filters::HtmlSafe for StatRow<'a> {}
1447
1448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1449pub struct BreadcrumbItem<'a> {
1450 pub label: &'a str,
1451 pub href: Option<&'a str>,
1452 pub current: bool,
1453}
1454
1455impl<'a> BreadcrumbItem<'a> {
1456 pub const fn link(label: &'a str, href: &'a str) -> Self {
1457 Self {
1458 label,
1459 href: Some(href),
1460 current: false,
1461 }
1462 }
1463
1464 pub const fn current(label: &'a str) -> Self {
1465 Self {
1466 label,
1467 href: None,
1468 current: true,
1469 }
1470 }
1471}
1472
1473#[derive(Debug, Template)]
1474#[non_exhaustive]
1475#[template(path = "components/breadcrumbs.html")]
1476pub struct Breadcrumbs<'a> {
1477 pub items: &'a [BreadcrumbItem<'a>],
1478}
1479
1480impl<'a> Breadcrumbs<'a> {
1481 pub const fn new(items: &'a [BreadcrumbItem<'a>]) -> Self {
1482 Self { items }
1483 }
1484}
1485
1486impl<'a> askama::filters::HtmlSafe for Breadcrumbs<'a> {}
1487
1488#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1489pub struct TabItem<'a> {
1490 pub label: &'a str,
1491 pub href: &'a str,
1492 pub active: bool,
1493}
1494
1495impl<'a> TabItem<'a> {
1496 pub const fn link(label: &'a str, href: &'a str) -> Self {
1497 Self {
1498 label,
1499 href,
1500 active: false,
1501 }
1502 }
1503
1504 pub const fn active(mut self) -> Self {
1505 self.active = true;
1506 self
1507 }
1508}
1509
1510#[derive(Debug, Template)]
1511#[non_exhaustive]
1512#[template(path = "components/tabs.html")]
1513pub struct Tabs<'a> {
1514 pub items: &'a [TabItem<'a>],
1515}
1516
1517impl<'a> Tabs<'a> {
1518 pub const fn new(items: &'a [TabItem<'a>]) -> Self {
1519 Self { items }
1520 }
1521}
1522
1523impl<'a> askama::filters::HtmlSafe for Tabs<'a> {}
1524
1525#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1526pub struct SegmentOption<'a> {
1527 pub label: &'a str,
1528 pub value: &'a str,
1529 pub active: bool,
1530}
1531
1532impl<'a> SegmentOption<'a> {
1533 pub const fn new(label: &'a str, value: &'a str) -> Self {
1534 Self {
1535 label,
1536 value,
1537 active: false,
1538 }
1539 }
1540
1541 pub const fn active(mut self) -> Self {
1542 self.active = true;
1543 self
1544 }
1545}
1546
1547#[derive(Debug, Template)]
1548#[non_exhaustive]
1549#[template(path = "components/segmented_control.html")]
1550pub struct SegmentedControl<'a> {
1551 pub options: &'a [SegmentOption<'a>],
1552 pub small: bool,
1553}
1554
1555impl<'a> SegmentedControl<'a> {
1556 pub const fn new(options: &'a [SegmentOption<'a>]) -> Self {
1557 Self {
1558 options,
1559 small: false,
1560 }
1561 }
1562
1563 pub const fn small(mut self) -> Self {
1564 self.small = true;
1565 self
1566 }
1567
1568 pub fn class_name(&self) -> &'static str {
1569 if self.small { "wf-seg sm" } else { "wf-seg" }
1570 }
1571}
1572
1573impl<'a> askama::filters::HtmlSafe for SegmentedControl<'a> {}
1574
1575#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1576pub struct PageLink<'a> {
1577 pub label: &'a str,
1578 pub href: Option<&'a str>,
1579 pub active: bool,
1580 pub disabled: bool,
1581 pub ellipsis: bool,
1582}
1583
1584impl<'a> PageLink<'a> {
1585 pub const fn link(label: &'a str, href: &'a str) -> Self {
1586 Self {
1587 label,
1588 href: Some(href),
1589 active: false,
1590 disabled: false,
1591 ellipsis: false,
1592 }
1593 }
1594
1595 pub const fn disabled(label: &'a str) -> Self {
1596 Self {
1597 label,
1598 href: None,
1599 active: false,
1600 disabled: true,
1601 ellipsis: false,
1602 }
1603 }
1604
1605 pub const fn ellipsis() -> Self {
1606 Self {
1607 label: "...",
1608 href: None,
1609 active: false,
1610 disabled: false,
1611 ellipsis: true,
1612 }
1613 }
1614
1615 pub const fn active(mut self) -> Self {
1616 self.active = true;
1617 self
1618 }
1619}
1620
1621#[derive(Debug, Template)]
1622#[non_exhaustive]
1623#[template(path = "components/pagination.html")]
1624pub struct Pagination<'a> {
1625 pub pages: &'a [PageLink<'a>],
1626}
1627
1628impl<'a> Pagination<'a> {
1629 pub const fn new(pages: &'a [PageLink<'a>]) -> Self {
1630 Self { pages }
1631 }
1632}
1633
1634impl<'a> askama::filters::HtmlSafe for Pagination<'a> {}
1635
1636#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1637pub enum StepState {
1638 Upcoming,
1639 Active,
1640 Done,
1641}
1642
1643#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1644pub struct StepItem<'a> {
1645 pub label: &'a str,
1646 pub href: Option<&'a str>,
1647 pub state: StepState,
1648}
1649
1650impl<'a> StepItem<'a> {
1651 pub const fn new(label: &'a str) -> Self {
1652 Self {
1653 label,
1654 href: None,
1655 state: StepState::Upcoming,
1656 }
1657 }
1658
1659 pub const fn with_href(mut self, href: &'a str) -> Self {
1660 self.href = Some(href);
1661 self
1662 }
1663
1664 pub const fn active(mut self) -> Self {
1665 self.state = StepState::Active;
1666 self
1667 }
1668
1669 pub const fn done(mut self) -> Self {
1670 self.state = StepState::Done;
1671 self
1672 }
1673
1674 pub fn class_name(&self) -> &'static str {
1675 match self.state {
1676 StepState::Upcoming => "wf-step",
1677 StepState::Active => "wf-step is-active",
1678 StepState::Done => "wf-step is-done",
1679 }
1680 }
1681
1682 pub fn is_active(&self) -> bool {
1683 self.state == StepState::Active
1684 }
1685}
1686
1687#[derive(Debug, Template)]
1688#[non_exhaustive]
1689#[template(path = "components/stepper.html")]
1690pub struct Stepper<'a> {
1691 pub steps: &'a [StepItem<'a>],
1692}
1693
1694impl<'a> Stepper<'a> {
1695 pub const fn new(steps: &'a [StepItem<'a>]) -> Self {
1696 Self { steps }
1697 }
1698}
1699
1700impl<'a> askama::filters::HtmlSafe for Stepper<'a> {}
1701
1702#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1703pub struct AccordionItem<'a> {
1704 pub title: &'a str,
1705 pub body_html: TrustedHtml<'a>,
1706 pub open: bool,
1707}
1708
1709impl<'a> AccordionItem<'a> {
1710 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1711 Self {
1712 title,
1713 body_html,
1714 open: false,
1715 }
1716 }
1717
1718 pub const fn open(mut self) -> Self {
1719 self.open = true;
1720 self
1721 }
1722}
1723
1724#[derive(Debug, Template)]
1725#[non_exhaustive]
1726#[template(path = "components/accordion.html")]
1727pub struct Accordion<'a> {
1728 pub items: &'a [AccordionItem<'a>],
1729}
1730
1731impl<'a> Accordion<'a> {
1732 pub const fn new(items: &'a [AccordionItem<'a>]) -> Self {
1733 Self { items }
1734 }
1735}
1736
1737impl<'a> askama::filters::HtmlSafe for Accordion<'a> {}
1738
1739#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1740pub struct FaqItem<'a> {
1741 pub question: &'a str,
1742 pub answer_html: TrustedHtml<'a>,
1743}
1744
1745impl<'a> FaqItem<'a> {
1746 pub const fn new(question: &'a str, answer_html: TrustedHtml<'a>) -> Self {
1747 Self {
1748 question,
1749 answer_html,
1750 }
1751 }
1752}
1753
1754#[derive(Debug, Template)]
1755#[non_exhaustive]
1756#[template(path = "components/faq.html")]
1757pub struct Faq<'a> {
1758 pub items: &'a [FaqItem<'a>],
1759}
1760
1761impl<'a> Faq<'a> {
1762 pub const fn new(items: &'a [FaqItem<'a>]) -> Self {
1763 Self { items }
1764 }
1765}
1766
1767impl<'a> askama::filters::HtmlSafe for Faq<'a> {}
1768
1769#[derive(Debug, Template)]
1770#[non_exhaustive]
1771#[template(path = "components/nav_section.html")]
1772pub struct NavSection<'a> {
1773 pub label: &'a str,
1774}
1775
1776impl<'a> NavSection<'a> {
1777 pub const fn new(label: &'a str) -> Self {
1778 Self { label }
1779 }
1780}
1781
1782impl<'a> askama::filters::HtmlSafe for NavSection<'a> {}
1783
1784#[derive(Debug, Template)]
1785#[non_exhaustive]
1786#[template(path = "components/nav_item.html")]
1787pub struct NavItem<'a> {
1788 pub label: &'a str,
1789 pub href: &'a str,
1790 pub count: Option<&'a str>,
1791 pub active: bool,
1792}
1793
1794impl<'a> NavItem<'a> {
1795 pub const fn new(label: &'a str, href: &'a str) -> Self {
1796 Self {
1797 label,
1798 href,
1799 count: None,
1800 active: false,
1801 }
1802 }
1803
1804 pub const fn active(mut self) -> Self {
1805 self.active = true;
1806 self
1807 }
1808
1809 pub const fn with_count(mut self, count: &'a str) -> Self {
1810 self.count = Some(count);
1811 self
1812 }
1813
1814 pub fn class_name(&self) -> &'static str {
1815 if self.active {
1816 "wf-nav-item is-active"
1817 } else {
1818 "wf-nav-item"
1819 }
1820 }
1821}
1822
1823impl<'a> askama::filters::HtmlSafe for NavItem<'a> {}
1824
1825#[derive(Debug, Template)]
1826#[non_exhaustive]
1827#[template(path = "components/topbar.html")]
1828pub struct Topbar<'a> {
1829 pub breadcrumbs_html: TrustedHtml<'a>,
1830 pub actions_html: TrustedHtml<'a>,
1831}
1832
1833impl<'a> Topbar<'a> {
1834 pub const fn new(breadcrumbs_html: TrustedHtml<'a>, actions_html: TrustedHtml<'a>) -> Self {
1835 Self {
1836 breadcrumbs_html,
1837 actions_html,
1838 }
1839 }
1840}
1841
1842impl<'a> askama::filters::HtmlSafe for Topbar<'a> {}
1843
1844#[derive(Debug, Template)]
1845#[non_exhaustive]
1846#[template(path = "components/statusbar.html")]
1847pub struct Statusbar<'a> {
1848 pub left: &'a str,
1849 pub right: &'a str,
1850}
1851
1852impl<'a> Statusbar<'a> {
1853 pub const fn new(left: &'a str, right: &'a str) -> Self {
1854 Self { left, right }
1855 }
1856}
1857
1858impl<'a> askama::filters::HtmlSafe for Statusbar<'a> {}
1859
1860#[derive(Debug, Template)]
1861#[non_exhaustive]
1862#[template(path = "components/empty_state.html")]
1863pub struct EmptyState<'a> {
1864 pub title: &'a str,
1865 pub body: &'a str,
1866 pub glyph_html: Option<TrustedHtml<'a>>,
1867 pub actions_html: Option<TrustedHtml<'a>>,
1868 pub bordered: bool,
1869 pub dense: bool,
1870}
1871
1872impl<'a> EmptyState<'a> {
1873 pub const fn new(title: &'a str, body: &'a str) -> Self {
1874 Self {
1875 title,
1876 body,
1877 glyph_html: None,
1878 actions_html: None,
1879 bordered: false,
1880 dense: false,
1881 }
1882 }
1883
1884 pub const fn with_glyph(mut self, glyph_html: TrustedHtml<'a>) -> Self {
1885 self.glyph_html = Some(glyph_html);
1886 self
1887 }
1888
1889 pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
1890 self.actions_html = Some(actions_html);
1891 self
1892 }
1893
1894 pub const fn bordered(mut self) -> Self {
1895 self.bordered = true;
1896 self
1897 }
1898
1899 pub const fn dense(mut self) -> Self {
1900 self.dense = true;
1901 self
1902 }
1903
1904 pub fn class_name(&self) -> String {
1905 let bordered = if self.bordered { " bordered" } else { "" };
1906 let dense = if self.dense { " dense" } else { "" };
1907 format!("wf-empty{bordered}{dense}")
1908 }
1909}
1910
1911impl<'a> askama::filters::HtmlSafe for EmptyState<'a> {}
1912
1913#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1914pub enum SortDirection {
1915 Ascending,
1916 Descending,
1917}
1918
1919impl SortDirection {
1920 fn arrow(self) -> &'static str {
1921 match self {
1922 Self::Ascending => "^",
1923 Self::Descending => "v",
1924 }
1925 }
1926}
1927
1928#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1929pub enum TableColumnWidth {
1930 Auto,
1931 ExtraSmall,
1932 Small,
1933 Medium,
1934 Large,
1935 ExtraLarge,
1936 Id,
1937 Checkbox,
1938 Action,
1939}
1940
1941#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1942pub struct TableHeader<'a> {
1943 pub label: &'a str,
1944 pub numeric: bool,
1945}
1946
1947impl<'a> TableHeader<'a> {
1948 pub const fn new(label: &'a str) -> Self {
1949 Self {
1950 label,
1951 numeric: false,
1952 }
1953 }
1954
1955 pub const fn numeric(label: &'a str) -> Self {
1956 Self {
1957 label,
1958 numeric: true,
1959 }
1960 }
1961
1962 pub fn class_name(&self) -> &'static str {
1963 if self.numeric { "num" } else { "" }
1964 }
1965}
1966
1967#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1968pub struct TableCell<'a> {
1969 pub text: &'a str,
1970 pub numeric: bool,
1971 pub strong: bool,
1972 pub muted: bool,
1973}
1974
1975impl<'a> TableCell<'a> {
1976 pub const fn new(text: &'a str) -> Self {
1977 Self {
1978 text,
1979 numeric: false,
1980 strong: false,
1981 muted: false,
1982 }
1983 }
1984
1985 pub const fn numeric(text: &'a str) -> Self {
1986 Self {
1987 numeric: true,
1988 ..Self::new(text)
1989 }
1990 }
1991
1992 pub const fn strong(text: &'a str) -> Self {
1993 Self {
1994 strong: true,
1995 ..Self::new(text)
1996 }
1997 }
1998
1999 pub const fn muted(text: &'a str) -> Self {
2000 Self {
2001 muted: true,
2002 ..Self::new(text)
2003 }
2004 }
2005
2006 pub fn class_name(&self) -> &'static str {
2007 match (self.numeric, self.strong, self.muted) {
2008 (false, false, false) => "",
2009 (true, false, false) => "num",
2010 (false, true, false) => "strong",
2011 (false, false, true) => "muted",
2012 (true, true, false) => "num strong",
2013 (true, false, true) => "num muted",
2014 (false, true, true) => "strong muted",
2015 (true, true, true) => "num strong muted",
2016 }
2017 }
2018}
2019
2020#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2021pub struct TableRow<'a> {
2022 pub cells: &'a [TableCell<'a>],
2023 pub selected: bool,
2024}
2025
2026impl<'a> TableRow<'a> {
2027 pub const fn new(cells: &'a [TableCell<'a>]) -> Self {
2028 Self {
2029 cells,
2030 selected: false,
2031 }
2032 }
2033
2034 pub const fn selected(mut self) -> Self {
2035 self.selected = true;
2036 self
2037 }
2038}
2039
2040#[derive(Debug, Template)]
2041#[non_exhaustive]
2042#[template(path = "components/table.html")]
2043pub struct Table<'a> {
2044 pub headers: &'a [TableHeader<'a>],
2045 pub rows: &'a [TableRow<'a>],
2046 pub flush: bool,
2047 pub interactive: bool,
2048 pub sticky: bool,
2049 pub pin_last: bool,
2050}
2051
2052impl<'a> Table<'a> {
2053 pub const fn new(headers: &'a [TableHeader<'a>], rows: &'a [TableRow<'a>]) -> Self {
2054 Self {
2055 headers,
2056 rows,
2057 flush: false,
2058 interactive: false,
2059 sticky: false,
2060 pin_last: false,
2061 }
2062 }
2063
2064 pub const fn flush(mut self) -> Self {
2065 self.flush = true;
2066 self
2067 }
2068
2069 pub const fn interactive(mut self) -> Self {
2070 self.interactive = true;
2071 self
2072 }
2073
2074 pub const fn sticky(mut self) -> Self {
2075 self.sticky = true;
2076 self
2077 }
2078
2079 pub const fn pin_last(mut self) -> Self {
2080 self.pin_last = true;
2081 self
2082 }
2083
2084 pub fn class_name(&self) -> String {
2085 let flush = if self.flush { " flush" } else { "" };
2086 let interactive = if self.interactive {
2087 " is-interactive"
2088 } else {
2089 ""
2090 };
2091 let sticky = if self.sticky { " sticky" } else { "" };
2092 let pin_last = if self.pin_last { " pin-last" } else { "" };
2093 format!("wf-table{flush}{interactive}{sticky}{pin_last}")
2094 }
2095}
2096
2097impl<'a> askama::filters::HtmlSafe for Table<'a> {}
2098
2099#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2100pub struct DataTableHeader<'a> {
2101 pub label: &'a str,
2102 pub numeric: bool,
2103 pub sort_key: Option<&'a str>,
2104 pub sort_direction: Option<SortDirection>,
2105 pub width: TableColumnWidth,
2106}
2107
2108impl<'a> DataTableHeader<'a> {
2109 pub const fn new(label: &'a str) -> Self {
2110 Self {
2111 label,
2112 numeric: false,
2113 sort_key: None,
2114 sort_direction: None,
2115 width: TableColumnWidth::Auto,
2116 }
2117 }
2118
2119 pub const fn numeric(label: &'a str) -> Self {
2120 Self {
2121 numeric: true,
2122 ..Self::new(label)
2123 }
2124 }
2125
2126 pub const fn sortable(mut self, sort_key: &'a str, direction: SortDirection) -> Self {
2127 self.sort_key = Some(sort_key);
2128 self.sort_direction = Some(direction);
2129 self
2130 }
2131
2132 pub const fn with_width(mut self, width: TableColumnWidth) -> Self {
2133 self.width = width;
2134 self
2135 }
2136
2137 pub const fn action_column(mut self) -> Self {
2138 self.width = TableColumnWidth::Action;
2139 self
2140 }
2141
2142 pub fn class_name(&self) -> &'static str {
2143 match (self.width, self.numeric) {
2144 (TableColumnWidth::Auto, false) => "",
2145 (TableColumnWidth::Auto, true) => "num",
2146 (TableColumnWidth::ExtraSmall, false) => "wf-col-xs",
2147 (TableColumnWidth::ExtraSmall, true) => "wf-col-xs num",
2148 (TableColumnWidth::Small, false) => "wf-col-sm",
2149 (TableColumnWidth::Small, true) => "wf-col-sm num",
2150 (TableColumnWidth::Medium, false) => "wf-col-md",
2151 (TableColumnWidth::Medium, true) => "wf-col-md num",
2152 (TableColumnWidth::Large, false) => "wf-col-lg",
2153 (TableColumnWidth::Large, true) => "wf-col-lg num",
2154 (TableColumnWidth::ExtraLarge, false) => "wf-col-xl",
2155 (TableColumnWidth::ExtraLarge, true) => "wf-col-xl num",
2156 (TableColumnWidth::Id, false) => "wf-col-id",
2157 (TableColumnWidth::Id, true) => "wf-col-id num",
2158 (TableColumnWidth::Checkbox, false) => "wf-col-chk",
2159 (TableColumnWidth::Checkbox, true) => "wf-col-chk num",
2160 (TableColumnWidth::Action, false) => "wf-col-act",
2161 (TableColumnWidth::Action, true) => "wf-col-act num",
2162 }
2163 }
2164
2165 pub fn sort_arrow(&self) -> &'static str {
2166 self.sort_direction.map(SortDirection::arrow).unwrap_or("-")
2167 }
2168}
2169
2170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2171pub struct DataTableCell<'a> {
2172 pub text: &'a str,
2173 pub html: Option<TrustedHtml<'a>>,
2174 pub numeric: bool,
2175 pub strong: bool,
2176 pub muted: bool,
2177}
2178
2179impl<'a> DataTableCell<'a> {
2180 pub const fn new(text: &'a str) -> Self {
2181 Self {
2182 text,
2183 html: None,
2184 numeric: false,
2185 strong: false,
2186 muted: false,
2187 }
2188 }
2189
2190 pub const fn numeric(text: &'a str) -> Self {
2191 Self {
2192 numeric: true,
2193 ..Self::new(text)
2194 }
2195 }
2196
2197 pub const fn strong(text: &'a str) -> Self {
2198 Self {
2199 strong: true,
2200 ..Self::new(text)
2201 }
2202 }
2203
2204 pub const fn muted(text: &'a str) -> Self {
2205 Self {
2206 muted: true,
2207 ..Self::new(text)
2208 }
2209 }
2210
2211 pub const fn html(html: TrustedHtml<'a>) -> Self {
2212 Self {
2213 text: "",
2214 html: Some(html),
2215 numeric: false,
2216 strong: false,
2217 muted: false,
2218 }
2219 }
2220
2221 pub fn class_name(&self) -> &'static str {
2222 match (self.numeric, self.strong, self.muted) {
2223 (false, false, false) => "",
2224 (true, false, false) => "num",
2225 (false, true, false) => "strong",
2226 (false, false, true) => "muted",
2227 (true, true, false) => "num strong",
2228 (true, false, true) => "num muted",
2229 (false, true, true) => "strong muted",
2230 (true, true, true) => "num strong muted",
2231 }
2232 }
2233}
2234
2235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2236pub struct DataTableRow<'a> {
2237 pub cells: &'a [DataTableCell<'a>],
2238 pub selected: bool,
2239}
2240
2241impl<'a> DataTableRow<'a> {
2242 pub const fn new(cells: &'a [DataTableCell<'a>]) -> Self {
2243 Self {
2244 cells,
2245 selected: false,
2246 }
2247 }
2248
2249 pub const fn selected(mut self) -> Self {
2250 self.selected = true;
2251 self
2252 }
2253}
2254
2255#[derive(Debug, Template)]
2256#[non_exhaustive]
2257#[template(path = "components/data_table.html")]
2258pub struct DataTable<'a> {
2259 pub headers: &'a [DataTableHeader<'a>],
2260 pub rows: &'a [DataTableRow<'a>],
2261 pub flush: bool,
2262 pub interactive: bool,
2263 pub sticky: bool,
2264 pub pin_last: bool,
2265}
2266
2267impl<'a> DataTable<'a> {
2268 pub const fn new(headers: &'a [DataTableHeader<'a>], rows: &'a [DataTableRow<'a>]) -> Self {
2269 Self {
2270 headers,
2271 rows,
2272 flush: false,
2273 interactive: false,
2274 sticky: false,
2275 pin_last: false,
2276 }
2277 }
2278
2279 pub const fn flush(mut self) -> Self {
2280 self.flush = true;
2281 self
2282 }
2283
2284 pub const fn interactive(mut self) -> Self {
2285 self.interactive = true;
2286 self
2287 }
2288
2289 pub const fn sticky(mut self) -> Self {
2290 self.sticky = true;
2291 self
2292 }
2293
2294 pub const fn pin_last(mut self) -> Self {
2295 self.pin_last = true;
2296 self
2297 }
2298
2299 pub fn class_name(&self) -> String {
2300 let flush = if self.flush { " flush" } else { "" };
2301 let interactive = if self.interactive {
2302 " is-interactive"
2303 } else {
2304 ""
2305 };
2306 let sticky = if self.sticky { " sticky" } else { "" };
2307 let pin_last = if self.pin_last { " pin-last" } else { "" };
2308 format!("wf-table{flush}{interactive}{sticky}{pin_last}")
2309 }
2310}
2311
2312impl<'a> askama::filters::HtmlSafe for DataTable<'a> {}
2313
2314#[derive(Debug, Template)]
2315#[non_exhaustive]
2316#[template(path = "components/table_wrap.html")]
2317pub struct TableWrap<'a> {
2318 pub table_html: TrustedHtml<'a>,
2319 pub filterbar_html: Option<TrustedHtml<'a>>,
2320 pub bulk_count: Option<&'a str>,
2321 pub bulk_actions_html: Option<TrustedHtml<'a>>,
2322 pub footer_html: Option<TrustedHtml<'a>>,
2323}
2324
2325impl<'a> TableWrap<'a> {
2326 pub const fn new(table_html: TrustedHtml<'a>) -> Self {
2327 Self {
2328 table_html,
2329 filterbar_html: None,
2330 bulk_count: None,
2331 bulk_actions_html: None,
2332 footer_html: None,
2333 }
2334 }
2335
2336 pub const fn with_filterbar(mut self, filterbar_html: TrustedHtml<'a>) -> Self {
2337 self.filterbar_html = Some(filterbar_html);
2338 self
2339 }
2340
2341 pub const fn with_bulkbar(
2342 mut self,
2343 bulk_count: &'a str,
2344 bulk_actions_html: TrustedHtml<'a>,
2345 ) -> Self {
2346 self.bulk_count = Some(bulk_count);
2347 self.bulk_actions_html = Some(bulk_actions_html);
2348 self
2349 }
2350
2351 pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
2352 self.footer_html = Some(footer_html);
2353 self
2354 }
2355}
2356
2357impl<'a> askama::filters::HtmlSafe for TableWrap<'a> {}
2358
2359#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2360pub struct DefinitionItem<'a> {
2361 pub term: &'a str,
2362 pub description: &'a str,
2363}
2364
2365impl<'a> DefinitionItem<'a> {
2366 pub const fn new(term: &'a str, description: &'a str) -> Self {
2367 Self { term, description }
2368 }
2369}
2370
2371#[derive(Debug, Template)]
2372#[non_exhaustive]
2373#[template(path = "components/definition_list.html")]
2374pub struct DefinitionList<'a> {
2375 pub items: &'a [DefinitionItem<'a>],
2376 pub flush: bool,
2377}
2378
2379impl<'a> DefinitionList<'a> {
2380 pub const fn new(items: &'a [DefinitionItem<'a>]) -> Self {
2381 Self {
2382 items,
2383 flush: false,
2384 }
2385 }
2386
2387 pub const fn flush(mut self) -> Self {
2388 self.flush = true;
2389 self
2390 }
2391
2392 pub fn class_name(&self) -> &'static str {
2393 if self.flush { "wf-dl flush" } else { "wf-dl" }
2394 }
2395}
2396
2397impl<'a> askama::filters::HtmlSafe for DefinitionList<'a> {}
2398
2399#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2400pub struct RankRow<'a> {
2401 pub label: &'a str,
2402 pub value: &'a str,
2403 pub percent: u8,
2404}
2405
2406impl<'a> RankRow<'a> {
2407 pub const fn new(label: &'a str, value: &'a str, percent: u8) -> Self {
2408 Self {
2409 label,
2410 value,
2411 percent,
2412 }
2413 }
2414
2415 pub fn bounded_percent(&self) -> u8 {
2416 self.percent.min(100)
2417 }
2418}
2419
2420#[derive(Debug, Template)]
2421#[non_exhaustive]
2422#[template(path = "components/rank_list.html")]
2423pub struct RankList<'a> {
2424 pub rows: &'a [RankRow<'a>],
2425}
2426
2427impl<'a> RankList<'a> {
2428 pub const fn new(rows: &'a [RankRow<'a>]) -> Self {
2429 Self { rows }
2430 }
2431}
2432
2433impl<'a> askama::filters::HtmlSafe for RankList<'a> {}
2434
2435#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2436pub struct FeedRow<'a> {
2437 pub time: &'a str,
2438 pub kicker: &'a str,
2439 pub text: &'a str,
2440}
2441
2442impl<'a> FeedRow<'a> {
2443 pub const fn new(time: &'a str, kicker: &'a str, text: &'a str) -> Self {
2444 Self { time, kicker, text }
2445 }
2446}
2447
2448#[derive(Debug, Template)]
2449#[non_exhaustive]
2450#[template(path = "components/feed.html")]
2451pub struct Feed<'a> {
2452 pub rows: &'a [FeedRow<'a>],
2453}
2454
2455impl<'a> Feed<'a> {
2456 pub const fn new(rows: &'a [FeedRow<'a>]) -> Self {
2457 Self { rows }
2458 }
2459}
2460
2461impl<'a> askama::filters::HtmlSafe for Feed<'a> {}
2462
2463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2464pub struct TimelineItem<'a> {
2465 pub time: &'a str,
2466 pub title: &'a str,
2467 pub body_html: TrustedHtml<'a>,
2468 pub active: bool,
2469}
2470
2471impl<'a> TimelineItem<'a> {
2472 pub const fn new(time: &'a str, title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2473 Self {
2474 time,
2475 title,
2476 body_html,
2477 active: false,
2478 }
2479 }
2480
2481 pub const fn active(mut self) -> Self {
2482 self.active = true;
2483 self
2484 }
2485
2486 pub fn class_name(&self) -> &'static str {
2487 if self.active {
2488 "wf-timeline-item is-active"
2489 } else {
2490 "wf-timeline-item"
2491 }
2492 }
2493}
2494
2495#[derive(Debug, Template)]
2496#[non_exhaustive]
2497#[template(path = "components/timeline.html")]
2498pub struct Timeline<'a> {
2499 pub items: &'a [TimelineItem<'a>],
2500}
2501
2502impl<'a> Timeline<'a> {
2503 pub const fn new(items: &'a [TimelineItem<'a>]) -> Self {
2504 Self { items }
2505 }
2506}
2507
2508impl<'a> askama::filters::HtmlSafe for Timeline<'a> {}
2509
2510#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2511pub enum TreeItemKind {
2512 Folder,
2513 File,
2514}
2515
2516#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2517pub struct TreeItem<'a> {
2518 pub kind: TreeItemKind,
2519 pub label: &'a str,
2520 pub active: bool,
2521 pub collapsed: bool,
2522 pub children_html: Option<TrustedHtml<'a>>,
2523}
2524
2525impl<'a> TreeItem<'a> {
2526 pub const fn folder(label: &'a str) -> Self {
2527 Self {
2528 kind: TreeItemKind::Folder,
2529 label,
2530 active: false,
2531 collapsed: false,
2532 children_html: None,
2533 }
2534 }
2535
2536 pub const fn file(label: &'a str) -> Self {
2537 Self {
2538 kind: TreeItemKind::File,
2539 label,
2540 active: false,
2541 collapsed: false,
2542 children_html: None,
2543 }
2544 }
2545
2546 pub const fn active(mut self) -> Self {
2547 self.active = true;
2548 self
2549 }
2550
2551 pub const fn collapsed(mut self) -> Self {
2552 self.collapsed = true;
2553 self
2554 }
2555
2556 pub const fn with_children(mut self, children_html: TrustedHtml<'a>) -> Self {
2557 self.children_html = Some(children_html);
2558 self
2559 }
2560
2561 pub fn item_class(&self) -> &'static str {
2562 if self.collapsed { "is-collapsed" } else { "" }
2563 }
2564
2565 pub fn label_class(&self) -> &'static str {
2566 match (self.kind, self.active) {
2567 (TreeItemKind::Folder, _) => "tree-folder",
2568 (TreeItemKind::File, true) => "tree-file is-active",
2569 (TreeItemKind::File, false) => "tree-file",
2570 }
2571 }
2572}
2573
2574#[derive(Debug, Template)]
2575#[non_exhaustive]
2576#[template(path = "components/tree_view.html")]
2577pub struct TreeView<'a> {
2578 pub items: &'a [TreeItem<'a>],
2579 pub nested: bool,
2580}
2581
2582impl<'a> TreeView<'a> {
2583 pub const fn new(items: &'a [TreeItem<'a>]) -> Self {
2584 Self {
2585 items,
2586 nested: false,
2587 }
2588 }
2589
2590 pub const fn nested(mut self) -> Self {
2591 self.nested = true;
2592 self
2593 }
2594
2595 pub fn class_name(&self) -> &'static str {
2596 if self.nested { "" } else { "wf-tree" }
2597 }
2598}
2599
2600impl<'a> askama::filters::HtmlSafe for TreeView<'a> {}
2601
2602#[derive(Debug, Template)]
2603#[non_exhaustive]
2604#[template(path = "components/framed.html")]
2605pub struct Framed<'a> {
2606 pub content_html: TrustedHtml<'a>,
2607 pub dense: bool,
2608 pub dashed: bool,
2609}
2610
2611impl<'a> Framed<'a> {
2612 pub const fn new(content_html: TrustedHtml<'a>) -> Self {
2613 Self {
2614 content_html,
2615 dense: false,
2616 dashed: false,
2617 }
2618 }
2619
2620 pub const fn dense(mut self) -> Self {
2621 self.dense = true;
2622 self
2623 }
2624
2625 pub const fn dashed(mut self) -> Self {
2626 self.dashed = true;
2627 self
2628 }
2629
2630 pub fn class_name(&self) -> String {
2631 let dense = if self.dense { " dense" } else { "" };
2632 let dashed = if self.dashed { " dashed" } else { "" };
2633 format!("wf-framed{dense}{dashed}")
2634 }
2635}
2636
2637impl<'a> askama::filters::HtmlSafe for Framed<'a> {}
2638
2639#[derive(Debug, Template)]
2640#[non_exhaustive]
2641#[template(path = "components/grid.html")]
2642pub struct Grid<'a> {
2643 pub content_html: TrustedHtml<'a>,
2644 pub columns: u8,
2645}
2646
2647impl<'a> Grid<'a> {
2648 pub const fn new(content_html: TrustedHtml<'a>) -> Self {
2649 Self {
2650 content_html,
2651 columns: 2,
2652 }
2653 }
2654
2655 pub const fn with_columns(mut self, columns: u8) -> Self {
2656 self.columns = columns;
2657 self
2658 }
2659
2660 pub fn class_name(&self) -> String {
2661 format!("wf-grid cols-{}", self.columns)
2662 }
2663}
2664
2665impl<'a> askama::filters::HtmlSafe for Grid<'a> {}
2666
2667#[derive(Debug, Template)]
2668#[non_exhaustive]
2669#[template(path = "components/split.html")]
2670pub struct Split<'a> {
2671 pub content_html: TrustedHtml<'a>,
2672 pub vertical: bool,
2673}
2674
2675impl<'a> Split<'a> {
2676 pub const fn new(content_html: TrustedHtml<'a>) -> Self {
2677 Self {
2678 content_html,
2679 vertical: false,
2680 }
2681 }
2682
2683 pub const fn vertical(mut self) -> Self {
2684 self.vertical = true;
2685 self
2686 }
2687
2688 pub fn class_name(&self) -> &'static str {
2689 if self.vertical {
2690 "wf-split vertical"
2691 } else {
2692 "wf-split"
2693 }
2694 }
2695}
2696
2697impl<'a> askama::filters::HtmlSafe for Split<'a> {}
2698
2699#[derive(Debug, Template)]
2700#[non_exhaustive]
2701#[template(path = "components/callout.html")]
2702pub struct Callout<'a> {
2703 pub kind: FeedbackKind,
2704 pub title: Option<&'a str>,
2705 pub body_html: TrustedHtml<'a>,
2706}
2707
2708impl<'a> Callout<'a> {
2709 pub const fn new(kind: FeedbackKind, body_html: TrustedHtml<'a>) -> Self {
2710 Self {
2711 kind,
2712 title: None,
2713 body_html,
2714 }
2715 }
2716
2717 pub const fn with_title(mut self, title: &'a str) -> Self {
2718 self.title = Some(title);
2719 self
2720 }
2721
2722 pub fn class_name(&self) -> String {
2723 format!("wf-callout {}", self.kind.class())
2724 }
2725}
2726
2727impl<'a> askama::filters::HtmlSafe for Callout<'a> {}
2728
2729#[derive(Debug, Template)]
2730#[non_exhaustive]
2731#[template(path = "components/toast.html")]
2732pub struct Toast<'a> {
2733 pub kind: FeedbackKind,
2734 pub message: &'a str,
2735}
2736
2737impl<'a> Toast<'a> {
2738 pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
2739 Self { kind, message }
2740 }
2741
2742 pub fn class_name(&self) -> String {
2743 format!("wf-toast {}", self.kind.class())
2744 }
2745}
2746
2747impl<'a> askama::filters::HtmlSafe for Toast<'a> {}
2748
2749#[derive(Debug, Template)]
2750#[non_exhaustive]
2751#[template(path = "components/toast_host.html")]
2752pub struct ToastHost<'a> {
2753 pub id: &'a str,
2754}
2755
2756impl<'a> ToastHost<'a> {
2757 pub const fn new() -> Self {
2758 Self { id: "toast-host" }
2759 }
2760
2761 pub const fn with_id(mut self, id: &'a str) -> Self {
2762 self.id = id;
2763 self
2764 }
2765}
2766
2767impl<'a> Default for ToastHost<'a> {
2768 fn default() -> Self {
2769 Self::new()
2770 }
2771}
2772
2773impl<'a> askama::filters::HtmlSafe for ToastHost<'a> {}
2774
2775#[derive(Debug, Template)]
2776#[non_exhaustive]
2777#[template(path = "components/tooltip.html")]
2778pub struct Tooltip<'a> {
2779 pub tip: &'a str,
2780 pub content_html: TrustedHtml<'a>,
2781}
2782
2783impl<'a> Tooltip<'a> {
2784 pub const fn new(tip: &'a str, content_html: TrustedHtml<'a>) -> Self {
2785 Self { tip, content_html }
2786 }
2787}
2788
2789impl<'a> askama::filters::HtmlSafe for Tooltip<'a> {}
2790
2791#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2792pub enum MenuItemKind {
2793 Button,
2794 Link,
2795 Separator,
2796}
2797
2798#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2799pub struct MenuItem<'a> {
2800 pub kind: MenuItemKind,
2801 pub label: &'a str,
2802 pub href: Option<&'a str>,
2803 pub danger: bool,
2804 pub disabled: bool,
2805 pub kbd: Option<&'a str>,
2806 pub attrs: &'a [HtmlAttr<'a>],
2807}
2808
2809impl<'a> MenuItem<'a> {
2810 pub const fn button(label: &'a str) -> Self {
2811 Self {
2812 kind: MenuItemKind::Button,
2813 label,
2814 href: None,
2815 danger: false,
2816 disabled: false,
2817 kbd: None,
2818 attrs: &[],
2819 }
2820 }
2821
2822 pub const fn link(label: &'a str, href: &'a str) -> Self {
2823 Self {
2824 kind: MenuItemKind::Link,
2825 href: Some(href),
2826 ..Self::button(label)
2827 }
2828 }
2829
2830 pub const fn separator() -> Self {
2831 Self {
2832 kind: MenuItemKind::Separator,
2833 label: "",
2834 href: None,
2835 danger: false,
2836 disabled: false,
2837 kbd: None,
2838 attrs: &[],
2839 }
2840 }
2841
2842 pub const fn danger(mut self) -> Self {
2843 self.danger = true;
2844 self
2845 }
2846
2847 pub const fn disabled(mut self) -> Self {
2848 self.disabled = true;
2849 self
2850 }
2851
2852 pub const fn with_kbd(mut self, kbd: &'a str) -> Self {
2853 self.kbd = Some(kbd);
2854 self
2855 }
2856
2857 pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
2858 self.attrs = attrs;
2859 self
2860 }
2861
2862 pub fn class_name(&self) -> &'static str {
2863 if self.danger {
2864 "wf-menu-item danger"
2865 } else {
2866 "wf-menu-item"
2867 }
2868 }
2869}
2870
2871#[derive(Debug, Template)]
2872#[non_exhaustive]
2873#[template(path = "components/menu.html")]
2874pub struct Menu<'a> {
2875 pub items: &'a [MenuItem<'a>],
2876}
2877
2878impl<'a> Menu<'a> {
2879 pub const fn new(items: &'a [MenuItem<'a>]) -> Self {
2880 Self { items }
2881 }
2882}
2883
2884impl<'a> askama::filters::HtmlSafe for Menu<'a> {}
2885
2886#[derive(Debug, Template)]
2887#[non_exhaustive]
2888#[template(path = "components/popover.html")]
2889pub struct Popover<'a> {
2890 pub trigger_html: TrustedHtml<'a>,
2891 pub body_html: TrustedHtml<'a>,
2892 pub heading: Option<&'a str>,
2893 pub side: &'a str,
2894 pub open: bool,
2895}
2896
2897impl<'a> Popover<'a> {
2898 pub const fn new(trigger_html: TrustedHtml<'a>, body_html: TrustedHtml<'a>) -> Self {
2899 Self {
2900 trigger_html,
2901 body_html,
2902 heading: None,
2903 side: "bottom",
2904 open: false,
2905 }
2906 }
2907
2908 pub const fn with_heading(mut self, heading: &'a str) -> Self {
2909 self.heading = Some(heading);
2910 self
2911 }
2912
2913 pub const fn with_side(mut self, side: &'a str) -> Self {
2914 self.side = side;
2915 self
2916 }
2917
2918 pub const fn open(mut self) -> Self {
2919 self.open = true;
2920 self
2921 }
2922
2923 pub fn popover_class(&self) -> &'static str {
2924 if self.open {
2925 "wf-popover is-open"
2926 } else {
2927 "wf-popover"
2928 }
2929 }
2930}
2931
2932impl<'a> askama::filters::HtmlSafe for Popover<'a> {}
2933
2934#[derive(Debug, Template)]
2935#[non_exhaustive]
2936#[template(path = "components/modal.html")]
2937pub struct Modal<'a> {
2938 pub title: &'a str,
2939 pub body_html: TrustedHtml<'a>,
2940 pub footer_html: Option<TrustedHtml<'a>>,
2941 pub open: bool,
2942}
2943
2944impl<'a> Modal<'a> {
2945 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2946 Self {
2947 title,
2948 body_html,
2949 footer_html: None,
2950 open: false,
2951 }
2952 }
2953
2954 pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
2955 self.footer_html = Some(footer_html);
2956 self
2957 }
2958
2959 pub const fn open(mut self) -> Self {
2960 self.open = true;
2961 self
2962 }
2963
2964 pub fn overlay_class(&self) -> &'static str {
2965 if self.open {
2966 "wf-overlay is-open"
2967 } else {
2968 "wf-overlay"
2969 }
2970 }
2971
2972 pub fn modal_class(&self) -> &'static str {
2973 if self.open {
2974 "wf-modal is-open"
2975 } else {
2976 "wf-modal"
2977 }
2978 }
2979}
2980
2981impl<'a> askama::filters::HtmlSafe for Modal<'a> {}
2982
2983#[derive(Debug, Template)]
2984#[non_exhaustive]
2985#[template(path = "components/drawer.html")]
2986pub struct Drawer<'a> {
2987 pub title: &'a str,
2988 pub body_html: TrustedHtml<'a>,
2989 pub footer_html: Option<TrustedHtml<'a>>,
2990 pub open: bool,
2991 pub left: bool,
2992}
2993
2994impl<'a> Drawer<'a> {
2995 pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2996 Self {
2997 title,
2998 body_html,
2999 footer_html: None,
3000 open: false,
3001 left: false,
3002 }
3003 }
3004
3005 pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
3006 self.footer_html = Some(footer_html);
3007 self
3008 }
3009
3010 pub const fn open(mut self) -> Self {
3011 self.open = true;
3012 self
3013 }
3014
3015 pub const fn left(mut self) -> Self {
3016 self.left = true;
3017 self
3018 }
3019
3020 pub fn overlay_class(&self) -> &'static str {
3021 if self.open {
3022 "wf-overlay is-open"
3023 } else {
3024 "wf-overlay"
3025 }
3026 }
3027
3028 pub fn drawer_class(&self) -> String {
3029 let open = if self.open { " is-open" } else { "" };
3030 let left = if self.left { " left" } else { "" };
3031 format!("wf-drawer{open}{left}")
3032 }
3033}
3034
3035impl<'a> askama::filters::HtmlSafe for Drawer<'a> {}
3036
3037#[derive(Debug, Template)]
3038#[non_exhaustive]
3039#[template(path = "components/progress.html")]
3040pub struct Progress {
3041 pub value: Option<u8>,
3042}
3043
3044impl Progress {
3045 pub const fn new(value: u8) -> Self {
3046 Self { value: Some(value) }
3047 }
3048
3049 pub const fn indeterminate() -> Self {
3050 Self { value: None }
3051 }
3052
3053 pub fn class_name(&self) -> &'static str {
3054 if self.value.is_some() {
3055 "wf-progress"
3056 } else {
3057 "wf-progress indeterminate"
3058 }
3059 }
3060
3061 pub fn bounded_value(&self) -> u8 {
3062 self.value.unwrap_or(0).min(100)
3063 }
3064}
3065
3066impl askama::filters::HtmlSafe for Progress {}
3067
3068#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3069pub enum MeterColor {
3070 Accent,
3071 Ok,
3072 Warn,
3073 Error,
3074 Info,
3075}
3076
3077impl MeterColor {
3078 fn css_var(self) -> &'static str {
3079 match self {
3080 Self::Accent => "var(--accent)",
3081 Self::Ok => "var(--ok)",
3082 Self::Warn => "var(--warn)",
3083 Self::Error => "var(--err)",
3084 Self::Info => "var(--info)",
3085 }
3086 }
3087}
3088
3089#[derive(Debug, Template)]
3090#[non_exhaustive]
3091#[template(path = "components/meter.html")]
3092pub struct Meter {
3093 pub value: u8,
3094 pub width_px: Option<u16>,
3095 pub height_px: Option<u16>,
3096 pub color: Option<MeterColor>,
3097}
3098
3099impl Meter {
3100 pub const fn new(value: u8) -> Self {
3101 Self {
3102 value,
3103 width_px: None,
3104 height_px: None,
3105 color: None,
3106 }
3107 }
3108
3109 pub const fn with_size_px(mut self, width_px: u16, height_px: u16) -> Self {
3110 self.width_px = Some(width_px);
3111 self.height_px = Some(height_px);
3112 self
3113 }
3114
3115 pub const fn with_color(mut self, color: MeterColor) -> Self {
3116 self.color = Some(color);
3117 self
3118 }
3119
3120 pub fn style(&self) -> String {
3121 let mut style = String::with_capacity(72);
3122 let _ = write!(&mut style, "--meter: {}%", self.value.min(100));
3123 if let Some(width) = self.width_px {
3124 let _ = write!(&mut style, "; --meter-w: {width}px");
3125 }
3126 if let Some(height) = self.height_px {
3127 let _ = write!(&mut style, "; --meter-h: {height}px");
3128 }
3129 if let Some(color) = self.color {
3130 style.push_str("; --meter-c: ");
3131 style.push_str(color.css_var());
3132 }
3133 style
3134 }
3135}
3136
3137impl askama::filters::HtmlSafe for Meter {}
3138
3139#[derive(Debug, Template)]
3140#[non_exhaustive]
3141#[template(path = "components/kbd.html")]
3142pub struct Kbd<'a> {
3143 pub label: &'a str,
3144}
3145
3146impl<'a> Kbd<'a> {
3147 pub const fn new(label: &'a str) -> Self {
3148 Self { label }
3149 }
3150}
3151
3152impl<'a> askama::filters::HtmlSafe for Kbd<'a> {}
3153
3154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3155pub enum SkeletonKind {
3156 Line,
3157 Title,
3158 Block,
3159}
3160
3161impl SkeletonKind {
3162 fn class(self) -> &'static str {
3163 match self {
3164 Self::Line => "line",
3165 Self::Title => "title",
3166 Self::Block => "block",
3167 }
3168 }
3169}
3170
3171#[derive(Debug, Template)]
3172#[non_exhaustive]
3173#[template(path = "components/skeleton.html")]
3174pub struct Skeleton {
3175 pub kind: SkeletonKind,
3176}
3177
3178impl Skeleton {
3179 pub const fn line() -> Self {
3180 Self {
3181 kind: SkeletonKind::Line,
3182 }
3183 }
3184
3185 pub const fn title() -> Self {
3186 Self {
3187 kind: SkeletonKind::Title,
3188 }
3189 }
3190
3191 pub const fn block() -> Self {
3192 Self {
3193 kind: SkeletonKind::Block,
3194 }
3195 }
3196
3197 pub fn class_name(&self) -> String {
3198 format!("wf-skeleton {}", self.kind.class())
3199 }
3200}
3201
3202impl askama::filters::HtmlSafe for Skeleton {}
3203
3204#[derive(Debug, Template)]
3205#[non_exhaustive]
3206#[template(path = "components/spinner.html")]
3207pub struct Spinner {
3208 pub large: bool,
3209}
3210
3211impl Spinner {
3212 pub const fn new() -> Self {
3213 Self { large: false }
3214 }
3215
3216 pub const fn large() -> Self {
3217 Self { large: true }
3218 }
3219
3220 pub fn class_name(&self) -> &'static str {
3221 if self.large {
3222 "wf-spinner lg"
3223 } else {
3224 "wf-spinner"
3225 }
3226 }
3227}
3228
3229impl Default for Spinner {
3230 fn default() -> Self {
3231 Self::new()
3232 }
3233}
3234
3235impl askama::filters::HtmlSafe for Spinner {}
3236
3237#[derive(Debug, Template)]
3238#[non_exhaustive]
3239#[template(path = "components/minibuffer.html")]
3240pub struct Minibuffer<'a> {
3241 pub prompt: &'a str,
3242 pub message: Option<&'a str>,
3243 pub kind: Option<FeedbackKind>,
3244 pub time: Option<&'a str>,
3245}
3246
3247impl<'a> Minibuffer<'a> {
3248 pub const fn new() -> Self {
3249 Self {
3250 prompt: ">",
3251 message: None,
3252 kind: None,
3253 time: None,
3254 }
3255 }
3256
3257 pub const fn with_prompt(mut self, prompt: &'a str) -> Self {
3258 self.prompt = prompt;
3259 self
3260 }
3261
3262 pub const fn with_message(mut self, kind: FeedbackKind, message: &'a str) -> Self {
3263 self.kind = Some(kind);
3264 self.message = Some(message);
3265 self
3266 }
3267
3268 pub const fn with_time(mut self, time: &'a str) -> Self {
3269 self.time = Some(time);
3270 self
3271 }
3272
3273 pub fn message_class(&self) -> String {
3274 match self.kind {
3275 Some(kind) if self.message.is_some() => {
3276 format!("wf-minibuffer-msg is-visible is-{}", kind.class())
3277 }
3278 _ => "wf-minibuffer-msg".to_owned(),
3279 }
3280 }
3281}
3282
3283impl<'a> Default for Minibuffer<'a> {
3284 fn default() -> Self {
3285 Self::new()
3286 }
3287}
3288
3289impl<'a> askama::filters::HtmlSafe for Minibuffer<'a> {}
3290
3291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3292pub struct FeatureItem<'a> {
3293 pub title: &'a str,
3294 pub body: &'a str,
3295}
3296
3297impl<'a> FeatureItem<'a> {
3298 pub const fn new(title: &'a str, body: &'a str) -> Self {
3299 Self { title, body }
3300 }
3301}
3302
3303#[derive(Debug, Template)]
3304#[non_exhaustive]
3305#[template(path = "components/feature_grid.html")]
3306pub struct FeatureGrid<'a> {
3307 pub items: &'a [FeatureItem<'a>],
3308}
3309
3310impl<'a> FeatureGrid<'a> {
3311 pub const fn new(items: &'a [FeatureItem<'a>]) -> Self {
3312 Self { items }
3313 }
3314}
3315
3316impl<'a> askama::filters::HtmlSafe for FeatureGrid<'a> {}
3317
3318#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3319pub struct MarketingStep<'a> {
3320 pub title: &'a str,
3321 pub body: &'a str,
3322}
3323
3324impl<'a> MarketingStep<'a> {
3325 pub const fn new(title: &'a str, body: &'a str) -> Self {
3326 Self { title, body }
3327 }
3328}
3329
3330#[derive(Debug, Template)]
3331#[non_exhaustive]
3332#[template(path = "components/marketing_step_grid.html")]
3333pub struct MarketingStepGrid<'a> {
3334 pub steps: &'a [MarketingStep<'a>],
3335}
3336
3337impl<'a> MarketingStepGrid<'a> {
3338 pub const fn new(steps: &'a [MarketingStep<'a>]) -> Self {
3339 Self { steps }
3340 }
3341}
3342
3343impl<'a> askama::filters::HtmlSafe for MarketingStepGrid<'a> {}
3344
3345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3346pub struct PricingPlan<'a> {
3347 pub name: &'a str,
3348 pub price: &'a str,
3349 pub unit: Option<&'a str>,
3350 pub blurb: Option<&'a str>,
3351 pub featured: bool,
3352}
3353
3354impl<'a> PricingPlan<'a> {
3355 pub const fn new(name: &'a str, price: &'a str) -> Self {
3356 Self {
3357 name,
3358 price,
3359 unit: None,
3360 blurb: None,
3361 featured: false,
3362 }
3363 }
3364
3365 pub const fn with_unit(mut self, unit: &'a str) -> Self {
3366 self.unit = Some(unit);
3367 self
3368 }
3369
3370 pub const fn with_blurb(mut self, blurb: &'a str) -> Self {
3371 self.blurb = Some(blurb);
3372 self
3373 }
3374
3375 pub const fn featured(mut self) -> Self {
3376 self.featured = true;
3377 self
3378 }
3379
3380 pub fn class_name(&self) -> &'static str {
3381 if self.featured {
3382 "wf-plan is-featured"
3383 } else {
3384 "wf-plan"
3385 }
3386 }
3387}
3388
3389#[derive(Debug, Template)]
3390#[non_exhaustive]
3391#[template(path = "components/pricing_plans.html")]
3392pub struct PricingPlans<'a> {
3393 pub plans: &'a [PricingPlan<'a>],
3394}
3395
3396impl<'a> PricingPlans<'a> {
3397 pub const fn new(plans: &'a [PricingPlan<'a>]) -> Self {
3398 Self { plans }
3399 }
3400}
3401
3402impl<'a> askama::filters::HtmlSafe for PricingPlans<'a> {}
3403
3404#[derive(Debug, Template)]
3405#[non_exhaustive]
3406#[template(path = "components/testimonial.html")]
3407pub struct Testimonial<'a> {
3408 pub quote_html: TrustedHtml<'a>,
3409 pub name: &'a str,
3410 pub role: &'a str,
3411}
3412
3413impl<'a> Testimonial<'a> {
3414 pub const fn new(quote_html: TrustedHtml<'a>, name: &'a str, role: &'a str) -> Self {
3415 Self {
3416 quote_html,
3417 name,
3418 role,
3419 }
3420 }
3421}
3422
3423impl<'a> askama::filters::HtmlSafe for Testimonial<'a> {}
3424
3425#[derive(Debug, Template)]
3426#[non_exhaustive]
3427#[template(path = "components/marketing_section.html")]
3428pub struct MarketingSection<'a> {
3429 pub title: &'a str,
3430 pub content_html: TrustedHtml<'a>,
3431 pub kicker: Option<&'a str>,
3432 pub subtitle: Option<&'a str>,
3433}
3434
3435impl<'a> MarketingSection<'a> {
3436 pub const fn new(title: &'a str, content_html: TrustedHtml<'a>) -> Self {
3437 Self {
3438 title,
3439 content_html,
3440 kicker: None,
3441 subtitle: None,
3442 }
3443 }
3444
3445 pub const fn with_kicker(mut self, kicker: &'a str) -> Self {
3446 self.kicker = Some(kicker);
3447 self
3448 }
3449
3450 pub const fn with_subtitle(mut self, subtitle: &'a str) -> Self {
3451 self.subtitle = Some(subtitle);
3452 self
3453 }
3454}
3455
3456impl<'a> askama::filters::HtmlSafe for MarketingSection<'a> {}
3457
3458#[cfg(test)]
3459mod tests {
3460 use super::*;
3461
3462 #[test]
3463 fn renders_button_with_htmx_attrs() {
3464 let attrs = [HtmlAttr::hx_post("/save?next=<home>")];
3465 let html = Button::primary("Save").with_attrs(&attrs).render().unwrap();
3466
3467 assert!(html.contains(r#"class="wf-btn primary""#));
3468 assert!(html.contains(r#"hx-post="/save?next="#));
3469 assert!(!html.contains(r#"hx-post="/save?next=<home>""#));
3470 }
3471
3472 #[test]
3473 fn field_escapes_copy_and_renders_trusted_control_html() {
3474 let html = Field::new(
3475 "Email <required>",
3476 TrustedHtml::new(r#"<input class="wf-input" name="email">"#),
3477 )
3478 .with_hint("Use <work> address")
3479 .render()
3480 .unwrap();
3481
3482 assert!(html.contains("Email"));
3483 assert!(!html.contains("Email <required>"));
3484 assert!(html.contains(r#"<input class="wf-input" name="email">"#));
3485 assert!(html.contains("Use"));
3486 assert!(!html.contains("Use <work> address"));
3487 }
3488
3489 #[test]
3490 fn trusted_html_writes_without_formatter_allocation() {
3491 let mut html = String::new();
3492
3493 askama::FastWritable::write_into(
3494 &TrustedHtml::new("<strong>Ready</strong>"),
3495 &mut html,
3496 askama::NO_VALUES,
3497 )
3498 .unwrap();
3499
3500 assert_eq!(html, "<strong>Ready</strong>");
3501 }
3502
3503 #[derive(Template)]
3504 #[template(source = "{{ button }}", ext = "html")]
3505 struct NestedButton<'a> {
3506 button: Button<'a>,
3507 }
3508
3509 #[test]
3510 fn nested_components_render_as_html() {
3511 let html = NestedButton {
3512 button: Button::primary("Save"),
3513 }
3514 .render()
3515 .unwrap();
3516
3517 assert!(html.contains("<button"));
3518 assert!(!html.contains("<button"));
3519 }
3520
3521 #[test]
3522 fn action_primitives_render_wave_funk_markup() {
3523 let attrs = [HtmlAttr::hx_post("/actions/archive")];
3524 let buttons = [
3525 Button::new("Left"),
3526 Button::primary("Archive").with_attrs(&attrs),
3527 ];
3528
3529 let group_html = ButtonGroup::new(&buttons).render().unwrap();
3530 let split_html = SplitButton::new(Button::primary("Run"), Button::new("More"))
3531 .render()
3532 .unwrap();
3533 let icon_html = IconButton::new(TrustedHtml::new("×"), "Close")
3534 .with_variant(ButtonVariant::Ghost)
3535 .render()
3536 .unwrap();
3537
3538 assert!(group_html.contains(r#"class="wf-btn-group""#));
3539 assert!(group_html.contains(r#"hx-post="/actions/archive""#));
3540 assert!(split_html.contains(r#"class="wf-btn-split""#));
3541 assert!(split_html.contains(r#"class="wf-btn caret""#));
3542 assert!(icon_html.contains(r#"class="wf-icon-btn ghost""#));
3543 assert!(icon_html.contains(r#"aria-label="Close""#));
3544 assert!(icon_html.contains("×"));
3545 }
3546
3547 #[test]
3548 fn text_form_primitives_escape_copy_and_attrs() {
3549 let attrs = [HtmlAttr::hx_get("/validate/email")];
3550 let input_html = Input::email("email")
3551 .with_value("sandeep<wavefunk>")
3552 .with_placeholder("Email <address>")
3553 .with_attrs(&attrs)
3554 .render()
3555 .unwrap();
3556 let textarea_html = Textarea::new("notes")
3557 .with_value("Hello <team>")
3558 .with_placeholder("Notes <optional>")
3559 .render()
3560 .unwrap();
3561 let options = [
3562 SelectOption::new("starter", "Starter"),
3563 SelectOption::new("pro", "Pro <team>").selected(),
3564 ];
3565 let select_html = Select::new("plan", &options).render().unwrap();
3566
3567 assert!(input_html.contains(r#"class="wf-input""#));
3568 assert!(input_html.contains(r#"type="email""#));
3569 assert!(input_html.contains(r#"hx-get="/validate/email""#));
3570 assert!(!input_html.contains("sandeep<wavefunk>"));
3571 assert!(!input_html.contains("Email <address>"));
3572 assert!(textarea_html.contains(r#"class="wf-textarea""#));
3573 assert!(!textarea_html.contains("Hello <team>"));
3574 assert!(select_html.contains(r#"class="wf-select""#));
3575 assert!(select_html.contains(r#"value="pro" selected"#));
3576 assert!(!select_html.contains("Pro <team>"));
3577 }
3578
3579 #[test]
3580 fn grouped_choice_and_range_primitives_render_expected_classes() {
3581 let input_html = Input::url("site_url").render().unwrap();
3582 let group_html = InputGroup::new(TrustedHtml::new(&input_html))
3583 .with_prefix("https://")
3584 .with_suffix(".wavefunk.test")
3585 .render()
3586 .unwrap();
3587 let checkbox_html = CheckRow::checkbox("terms", "yes", "Accept <terms>")
3588 .checked()
3589 .render()
3590 .unwrap();
3591 let radio_html = CheckRow::radio("plan", "pro", "Pro").render().unwrap();
3592 let switch_html = Switch::new("enabled").checked().render().unwrap();
3593 let range_html = Range::new("volume")
3594 .with_bounds("0", "100")
3595 .with_value("50")
3596 .render()
3597 .unwrap();
3598 let field_html = Field::new("URL", TrustedHtml::new(&group_html))
3599 .with_state(FieldState::Success)
3600 .render()
3601 .unwrap();
3602
3603 assert!(group_html.contains(r#"class="wf-input-group""#));
3604 assert!(group_html.contains(r#"class="wf-input-addon">https://"#));
3605 assert!(checkbox_html.contains(r#"class="wf-check-row""#));
3606 assert!(checkbox_html.contains(r#"type="checkbox""#));
3607 assert!(checkbox_html.contains("checked"));
3608 assert!(!checkbox_html.contains("Accept <terms>"));
3609 assert!(radio_html.contains(r#"type="radio""#));
3610 assert!(switch_html.contains(r#"class="wf-switch""#));
3611 assert!(switch_html.contains("checked"));
3612 assert!(range_html.contains(r#"class="wf-range""#));
3613 assert!(range_html.contains(r#"min="0""#));
3614 assert!(field_html.contains(r#"class="wf-field is-success""#));
3615 }
3616
3617 #[test]
3618 fn layout_navigation_and_data_primitives_render_expected_markup() {
3619 let panel = Panel::new("Deployments", TrustedHtml::new("<p>Ready</p>"))
3620 .with_action(TrustedHtml::new(
3621 r#"<a class="wf-panel-link" href="/all">All</a>"#,
3622 ))
3623 .render()
3624 .unwrap();
3625 let card = Card::new("Project <alpha>", TrustedHtml::new("<p>Live</p>"))
3626 .with_kicker("Status")
3627 .raised()
3628 .render()
3629 .unwrap();
3630 let stats = [Stat::new("Requests", "42").with_unit("rpm")];
3631 let stat_row = StatRow::new(&stats).render().unwrap();
3632 let badge = Badge::muted("beta").render().unwrap();
3633 let avatar = Avatar::new("SN").accent().render().unwrap();
3634 let crumbs = [
3635 BreadcrumbItem::link("Projects", "/projects"),
3636 BreadcrumbItem::current("Wavefunk <UI>"),
3637 ];
3638 let breadcrumbs = Breadcrumbs::new(&crumbs).render().unwrap();
3639 let tabs = [
3640 TabItem::link("Overview", "/").active(),
3641 TabItem::link("Settings", "/settings"),
3642 ];
3643 let tab_html = Tabs::new(&tabs).render().unwrap();
3644 let segments = [
3645 SegmentOption::new("List", "list").active(),
3646 SegmentOption::new("Grid", "grid"),
3647 ];
3648 let seg_html = SegmentedControl::new(&segments).render().unwrap();
3649 let pages = [
3650 PageLink::link("1", "/page/1").active(),
3651 PageLink::ellipsis(),
3652 PageLink::disabled("Next"),
3653 ];
3654 let pagination = Pagination::new(&pages).render().unwrap();
3655 let nav_section = NavSection::new("Workspace").render().unwrap();
3656 let nav_item = NavItem::new("Dashboard", "/").active().with_count("3");
3657 let topbar = Topbar::new(TrustedHtml::new(&breadcrumbs), TrustedHtml::new(&badge))
3658 .render()
3659 .unwrap();
3660 let statusbar = Statusbar::new("Connected", "v0.1").render().unwrap();
3661 let empty = EmptyState::new("No hooks", "Create a hook to start.")
3662 .with_glyph(TrustedHtml::new("∅"))
3663 .bordered()
3664 .render()
3665 .unwrap();
3666 let table_headers = [TableHeader::new("Name"), TableHeader::numeric("Runs")];
3667 let table_cells = [TableCell::strong("Build <main>"), TableCell::numeric("12")];
3668 let table_rows = [TableRow::new(&table_cells).selected()];
3669 let table = Table::new(&table_headers, &table_rows)
3670 .interactive()
3671 .render()
3672 .unwrap();
3673 let dl_items = [DefinitionItem::new("Runtime", "Rust <stable>")];
3674 let dl = DefinitionList::new(&dl_items).render().unwrap();
3675 let grid = Grid::new(TrustedHtml::new(&card))
3676 .with_columns(2)
3677 .render()
3678 .unwrap();
3679 let split = Split::new(TrustedHtml::new(&panel))
3680 .vertical()
3681 .render()
3682 .unwrap();
3683
3684 assert!(panel.contains(r#"class="wf-panel""#));
3685 assert!(card.contains(r#"class="wf-card is-raised""#));
3686 assert!(!card.contains("Project <alpha>"));
3687 assert!(stat_row.contains(r#"class="wf-stat-row""#));
3688 assert!(badge.contains(r#"class="wf-badge muted""#));
3689 assert!(avatar.contains(r#"class="wf-avatar accent""#));
3690 assert!(breadcrumbs.contains(r#"class="wf-crumbs""#));
3691 assert!(!breadcrumbs.contains("Wavefunk <UI>"));
3692 assert!(tab_html.contains(r#"class="wf-tabs""#));
3693 assert!(seg_html.contains(r#"class="wf-seg""#));
3694 assert!(pagination.contains(r#"class="wf-pagination""#));
3695 assert!(nav_section.contains(r#"class="wf-nav-section""#));
3696 assert!(
3697 nav_item
3698 .render()
3699 .unwrap()
3700 .contains(r#"class="wf-nav-item is-active""#)
3701 );
3702 assert!(topbar.contains(r#"class="wf-topbar""#));
3703 assert!(statusbar.contains(r#"class="wf-statusbar wf-hair""#));
3704 assert!(empty.contains(r#"class="wf-empty bordered""#));
3705 assert!(table.contains(r#"class="wf-table is-interactive""#));
3706 assert!(!table.contains("Build <main>"));
3707 assert!(dl.contains(r#"class="wf-dl""#));
3708 assert!(!dl.contains("Rust <stable>"));
3709 assert!(grid.contains(r#"class="wf-grid cols-2""#));
3710 assert!(split.contains(r#"class="wf-split vertical""#));
3711 }
3712
3713 #[test]
3714 fn feedback_overlay_and_loading_primitives_render_expected_markup() {
3715 let callout = Callout::new(FeedbackKind::Warn, TrustedHtml::new("<p>Heads up</p>"))
3716 .with_title("Warning")
3717 .render()
3718 .unwrap();
3719 let toast = Toast::new(FeedbackKind::Ok, "Saved <now>")
3720 .render()
3721 .unwrap();
3722 let toast_host = ToastHost::new().render().unwrap();
3723 let tooltip = Tooltip::new("Copy id", TrustedHtml::new(r#"<button>copy</button>"#))
3724 .render()
3725 .unwrap();
3726 let menu_items = [
3727 MenuItem::button("Open"),
3728 MenuItem::link("Settings", "/settings"),
3729 MenuItem::separator(),
3730 MenuItem::button("Delete").danger(),
3731 ];
3732 let menu = Menu::new(&menu_items).render().unwrap();
3733 let popover = Popover::new(
3734 TrustedHtml::new(r#"<button data-popover-toggle>Open</button>"#),
3735 TrustedHtml::new(&menu),
3736 )
3737 .with_heading("Menu")
3738 .open()
3739 .render()
3740 .unwrap();
3741 let modal = Modal::new("Confirm", TrustedHtml::new("<p>Continue?</p>"))
3742 .with_footer(TrustedHtml::new(
3743 r#"<button class="wf-btn primary">Confirm</button>"#,
3744 ))
3745 .open()
3746 .render()
3747 .unwrap();
3748 let drawer = Drawer::new("Details", TrustedHtml::new("<p>Side sheet</p>"))
3749 .left()
3750 .open()
3751 .render()
3752 .unwrap();
3753 let skeleton = Skeleton::title().render().unwrap();
3754 let spinner = Spinner::large().render().unwrap();
3755 let minibuffer = Minibuffer::new()
3756 .with_message(FeedbackKind::Info, "Queued <job>")
3757 .with_time("09:41")
3758 .render()
3759 .unwrap();
3760
3761 assert!(callout.contains(r#"class="wf-callout warn""#));
3762 assert!(toast.contains(r#"class="wf-toast ok""#));
3763 assert!(!toast.contains("Saved <now>"));
3764 assert!(toast_host.contains(r#"class="wf-toast-host""#));
3765 assert!(tooltip.contains(r#"class="wf-tooltip""#));
3766 assert!(tooltip.contains(r#"data-tip="Copy id""#));
3767 assert!(menu.contains(r#"class="wf-menu""#));
3768 assert!(menu.contains(r#"class="wf-menu-sep""#));
3769 assert!(popover.contains(r#"class="wf-popover is-open""#));
3770 assert!(modal.contains(r#"class="wf-modal is-open""#));
3771 assert!(modal.contains(r#"class="wf-overlay is-open""#));
3772 assert!(modal.contains(r#"data-wf-dismiss="overlay""#));
3773 assert!(drawer.contains(r#"class="wf-drawer is-open left""#));
3774 assert!(drawer.contains(r#"data-wf-dismiss="overlay""#));
3775 assert!(skeleton.contains(r#"class="wf-skeleton title""#));
3776 assert!(spinner.contains(r#"class="wf-spinner lg""#));
3777 assert!(minibuffer.contains(r#"class="wf-minibuffer""#));
3778 assert!(minibuffer.contains("data-wf-echo"));
3779 assert!(!minibuffer.contains("Queued <job>"));
3780 }
3781
3782 #[test]
3783 fn form_composition_and_dropzone_components_render_expected_markup() {
3784 let input_html = Input::email("email")
3785 .with_placeholder("you@example.test")
3786 .render()
3787 .unwrap();
3788 let field_html = Field::new("Email", TrustedHtml::new(&input_html))
3789 .with_hint("Use <work> address")
3790 .render()
3791 .unwrap();
3792 let actions_html = FormActions::new(TrustedHtml::new(
3793 r#"<button class="wf-btn primary">Save</button>"#,
3794 ))
3795 .with_secondary(TrustedHtml::new(
3796 r#"<button class="wf-btn">Cancel</button>"#,
3797 ))
3798 .render()
3799 .unwrap();
3800 let section_html = FormSection::new("Profile <setup>", TrustedHtml::new(&field_html))
3801 .with_description("Shown to teammates <public>")
3802 .render()
3803 .unwrap();
3804 let attrs = [HtmlAttr::hx_post("/profile")];
3805 let form_html = Form::new(TrustedHtml::new(§ion_html))
3806 .with_action("/profile/save?next=<home>")
3807 .with_method("post")
3808 .with_attrs(&attrs)
3809 .render()
3810 .unwrap();
3811 let dropzone_attrs = [HtmlAttr::new("data-intent", "avatar <upload>")];
3812 let dropzone_html = Dropzone::new("avatar")
3813 .with_title("Drop avatar <image>")
3814 .with_hint("PNG or JPG <2MB>")
3815 .with_accept("image/png,image/jpeg")
3816 .with_attrs(&dropzone_attrs)
3817 .multiple()
3818 .disabled()
3819 .dragover()
3820 .render()
3821 .unwrap();
3822
3823 assert!(actions_html.contains(r#"class="wf-form-actions""#));
3824 assert!(section_html.contains(r#"class="wf-form-section""#));
3825 assert!(!section_html.contains("Profile <setup>"));
3826 assert!(!section_html.contains("Shown to teammates <public>"));
3827 assert!(form_html.contains(r#"<form class="wf-form""#));
3828 assert!(form_html.contains(r#"method="post""#));
3829 assert!(form_html.contains(r#"hx-post="/profile""#));
3830 assert!(!form_html.contains(r#"action="/profile/save?next=<home>""#));
3831 assert!(dropzone_html.contains(r#"class="wf-dropzone is-dragover is-disabled""#));
3832 assert!(dropzone_html.contains(r#"type="file""#));
3833 assert!(dropzone_html.contains(r#"multiple"#));
3834 assert!(dropzone_html.contains(r#"disabled"#));
3835 assert!(dropzone_html.contains(r#"accept="image/png,image/jpeg""#));
3836 assert!(dropzone_html.contains(r#"data-intent="avatar "#));
3837 assert!(!dropzone_html.contains(r#"data-intent="avatar <upload>""#));
3838 assert!(!dropzone_html.contains("Drop avatar <image>"));
3839 assert!(!dropzone_html.contains("PNG or JPG <2MB>"));
3840 }
3841
3842 #[test]
3843 fn table_workflow_components_support_sorting_actions_and_chrome() {
3844 let _source_compatible_header = TableHeader {
3845 label: "Legacy",
3846 numeric: false,
3847 };
3848 let _source_compatible_cell = TableCell {
3849 text: "Legacy",
3850 numeric: false,
3851 strong: false,
3852 muted: false,
3853 };
3854 let headers = [
3855 DataTableHeader::new("Name").sortable("name", SortDirection::Ascending),
3856 DataTableHeader::numeric("Runs").with_width(TableColumnWidth::Small),
3857 DataTableHeader::new("Actions").action_column(),
3858 ];
3859 let actions = IconButton::new(TrustedHtml::new("×"), "Stop")
3860 .with_variant(ButtonVariant::Danger)
3861 .render()
3862 .unwrap();
3863 let row_cells = [
3864 DataTableCell::strong("Build <main>"),
3865 DataTableCell::numeric("12"),
3866 DataTableCell::html(TrustedHtml::new(&actions)),
3867 ];
3868 let rows = [DataTableRow::new(&row_cells).selected()];
3869 let filter_html = Input::new("q")
3870 .with_size(ControlSize::Small)
3871 .with_placeholder("Search")
3872 .render()
3873 .unwrap();
3874 let bulk_html = Button::new("Delete").render().unwrap();
3875 let table_html = DataTable::new(&headers, &rows)
3876 .interactive()
3877 .sticky()
3878 .pin_last()
3879 .render()
3880 .unwrap();
3881 let wrap_html = TableWrap::new(TrustedHtml::new(&table_html))
3882 .with_filterbar(TrustedHtml::new(&filter_html))
3883 .with_bulkbar("1 selected", TrustedHtml::new(&bulk_html))
3884 .with_footer(TrustedHtml::new("Showing 1-1 of 1"))
3885 .render()
3886 .unwrap();
3887
3888 assert!(table_html.contains(r#"class="wf-sort-h is-active""#));
3889 assert!(table_html.contains(r#"data-sort-key="name""#));
3890 assert!(table_html.contains(r#"class="wf-sort-arrow">^"#));
3891 assert!(table_html.contains(r#"class="wf-col-sm num""#));
3892 assert!(table_html.contains(r#"class="wf-col-act""#));
3893 assert!(table_html.contains("×"));
3894 assert!(!table_html.contains("Build <main>"));
3895 assert!(wrap_html.contains(r#"class="wf-tablewrap""#));
3896 assert!(wrap_html.contains(r#"class="wf-filterbar""#));
3897 assert!(wrap_html.contains(r#"class="wf-bulkbar""#));
3898 assert!(wrap_html.contains(r#"class="wf-tablefoot""#));
3899 }
3900
3901 #[test]
3902 fn progress_stepper_and_disclosure_components_render_expected_markup() {
3903 let progress = Progress::new(60).render().unwrap();
3904 let indeterminate = Progress::indeterminate().render().unwrap();
3905 let meter = Meter::new(75)
3906 .with_size_px(96, 6)
3907 .with_color(MeterColor::Ok)
3908 .render()
3909 .unwrap();
3910 let kbd = Kbd::new("Ctrl <K>").render().unwrap();
3911 let steps = [
3912 StepItem::new("Account").done(),
3913 StepItem::new("Profile <public>")
3914 .active()
3915 .with_href("/profile"),
3916 StepItem::new("Invite"),
3917 ];
3918 let stepper = Stepper::new(&steps).render().unwrap();
3919 let accordion_items = [
3920 AccordionItem::new("What is <UI>?", TrustedHtml::new("<p>Typed</p>")).open(),
3921 AccordionItem::new("Can it htmx?", TrustedHtml::new("<p>Yes</p>")),
3922 ];
3923 let accordion = Accordion::new(&accordion_items).render().unwrap();
3924 let faq_items = [FaqItem::new(
3925 "Why typed?",
3926 TrustedHtml::new("<p>To preserve semver.</p>"),
3927 )];
3928 let faq = Faq::new(&faq_items).render().unwrap();
3929
3930 assert!(progress.contains(r#"class="wf-progress""#));
3931 assert!(progress.contains(r#"style="--progress: 60%""#));
3932 assert!(indeterminate.contains(r#"class="wf-progress indeterminate""#));
3933 assert!(meter.contains(
3934 r#"style="--meter: 75%; --meter-w: 96px; --meter-h: 6px; --meter-c: var(--ok)""#
3935 ));
3936 assert!(kbd.contains(r#"class="wf-kbd""#));
3937 assert!(!kbd.contains("Ctrl <K>"));
3938 assert!(stepper.contains(r#"class="wf-step is-done""#));
3939 assert!(stepper.contains(r#"aria-current="step""#));
3940 assert!(!stepper.contains("Profile <public>"));
3941 assert!(accordion.contains(r#"class="wf-accordion""#));
3942 assert!(accordion.contains(r#"<details class="wf-accordion-item" open>"#));
3943 assert!(!accordion.contains("What is <UI>?"));
3944 assert!(faq.contains(r#"class="wf-faq""#));
3945 assert!(faq.contains("<p>To preserve semver.</p>"));
3946 }
3947
3948 #[test]
3949 fn identity_brand_and_operational_components_render_expected_markup() {
3950 let avatars = [
3951 Avatar::new("SN").with_image("/avatar.png").accent(),
3952 Avatar::new("WF").with_size(AvatarSize::Small),
3953 ];
3954 let avatar_group = AvatarGroup::new(&avatars).render().unwrap();
3955 let full_user = UserButton::new("Wave Funk", "team@example.test", Avatar::new("WF"))
3956 .render()
3957 .unwrap();
3958 let user = UserButton::new(
3959 "Sandeep <Nambiar>",
3960 "sandeep@example.test",
3961 Avatar::new("SN"),
3962 )
3963 .compact()
3964 .render()
3965 .unwrap();
3966 let wordmark = Wordmark::new("Wave <Funk>")
3967 .with_mark(TrustedHtml::new(r#"<svg class="wf-mark"></svg>"#))
3968 .render()
3969 .unwrap();
3970 let ranks = [RankRow::new("Builds <main>", "42", 72)];
3971 let rank_list = RankList::new(&ranks).render().unwrap();
3972 let feed_rows = [FeedRow::new("09:41", "Deploy <prod>", "Released <v1>")];
3973 let feed = Feed::new(&feed_rows).render().unwrap();
3974 let timeline_items =
3975 [
3976 TimelineItem::new("09:42", "Queued <job>", TrustedHtml::new("<p>Pending</p>"))
3977 .active(),
3978 ];
3979 let timeline = Timeline::new(&timeline_items).render().unwrap();
3980 let tree_children = [TreeItem::file("components.rs").active()];
3981 let tree_child_html = TreeView::new(&tree_children).nested().render().unwrap();
3982 let tree_items = [TreeItem::folder("src <root>")
3983 .collapsed()
3984 .with_children(TrustedHtml::new(&tree_child_html))];
3985 let tree = TreeView::new(&tree_items).render().unwrap();
3986 let framed = Framed::new(TrustedHtml::new("<code>cargo test</code>"))
3987 .dense()
3988 .dashed()
3989 .render()
3990 .unwrap();
3991
3992 assert!(avatar_group.contains(r#"class="wf-avatar-group""#));
3993 assert!(avatar_group.contains(r#"<img src="/avatar.png" alt="SN">"#));
3994 assert!(full_user.contains(r#"class="wf-user""#));
3995 assert!(!full_user.contains(r#"class="wf-user compact""#));
3996 assert!(user.contains(r#"class="wf-user compact""#));
3997 assert!(!user.contains("Sandeep <Nambiar>"));
3998 assert!(wordmark.contains(r#"class="wf-wordmark""#));
3999 assert!(wordmark.contains(r#"<svg class="wf-mark"></svg>"#));
4000 assert!(!wordmark.contains("Wave <Funk>"));
4001 assert!(rank_list.contains(r#"class="wf-rank""#));
4002 assert!(rank_list.contains(r#"style="width: 72%""#));
4003 assert!(!rank_list.contains("Builds <main>"));
4004 assert!(feed.contains(r#"class="wf-feed""#));
4005 assert!(!feed.contains("Deploy <prod>"));
4006 assert!(!feed.contains("Released <v1>"));
4007 assert!(timeline.contains(r#"class="wf-timeline-item is-active""#));
4008 assert!(!timeline.contains("Queued <job>"));
4009 assert!(tree.contains(r#"class="wf-tree""#));
4010 assert!(tree.contains(r#"class="is-collapsed""#));
4011 assert!(!tree.contains("src <root>"));
4012 assert!(framed.contains(r#"class="wf-framed dense dashed""#));
4013 }
4014
4015 #[test]
4016 fn marketing_primitives_render_stable_typed_sections() {
4017 let features = [
4018 FeatureItem::new("Typed <APIs>", "No struct literal churn."),
4019 FeatureItem::new("Embedded assets", "Self-contained binaries."),
4020 ];
4021 let feature_grid = FeatureGrid::new(&features).render().unwrap();
4022 let steps = [
4023 MarketingStep::new("Install", "Add the crate."),
4024 MarketingStep::new("Render", "Use Askama templates."),
4025 ];
4026 let step_grid = MarketingStepGrid::new(&steps).render().unwrap();
4027 let plans = [
4028 PricingPlan::new("Starter", "$9")
4029 .with_blurb("For small teams.")
4030 .featured(),
4031 PricingPlan::new("Scale", "$29"),
4032 ];
4033 let pricing = PricingPlans::new(&plans).render().unwrap();
4034 let testimonial = Testimonial::new(
4035 TrustedHtml::new("<p>Fast to wire.</p>"),
4036 "Operator <one>",
4037 "Founder",
4038 )
4039 .render()
4040 .unwrap();
4041 let section = MarketingSection::new("Component <system>", TrustedHtml::new(&feature_grid))
4042 .with_kicker("Wave Funk")
4043 .with_subtitle("Typed primitives for Rust apps.")
4044 .render()
4045 .unwrap();
4046
4047 assert!(feature_grid.contains(r#"class="mk-features""#));
4048 assert!(!feature_grid.contains("Typed <APIs>"));
4049 assert!(step_grid.contains(r#"class="mk-steps""#));
4050 assert!(pricing.contains(r#"class="wf-plans""#));
4051 assert!(pricing.contains(r#"class="wf-plan is-featured""#));
4052 assert!(testimonial.contains(r#"class="wf-testimonial""#));
4053 assert!(!testimonial.contains("Operator <one>"));
4054 assert!(section.contains(r#"class="mk-sect""#));
4055 assert!(!section.contains("Component <system>"));
4056 }
4057}