Skip to main content

wavefunk_ui/
components.rs

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("&lt;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("&times;"), "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("&times;"));
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("&empty;"))
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(&section_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("&times;"), "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("&times;"));
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}