Skip to main content

wavefunk_ui/
components.rs

1use askama::Template;
2use std::fmt;
3
4#[derive(Clone, Copy, Debug)]
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
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct TrustedHtml<'a> {
50    html: &'a str,
51}
52
53impl<'a> TrustedHtml<'a> {
54    pub const fn new(html: &'a str) -> Self {
55        Self { html }
56    }
57
58    pub const fn as_str(self) -> &'a str {
59        self.html
60    }
61}
62
63impl fmt::Display for TrustedHtml<'_> {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(self.html)
66    }
67}
68
69impl askama::FastWritable for TrustedHtml<'_> {
70    #[inline]
71    fn write_into(&self, dest: &mut dyn fmt::Write, _: &dyn askama::Values) -> askama::Result<()> {
72        Ok(dest.write_str(self.html)?)
73    }
74}
75
76impl askama::filters::HtmlSafe for TrustedHtml<'_> {}
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum ButtonVariant {
80    Default,
81    Primary,
82    Ghost,
83    Danger,
84}
85
86impl ButtonVariant {
87    fn class(self) -> &'static str {
88        match self {
89            Self::Default => "",
90            Self::Primary => " primary",
91            Self::Ghost => " ghost",
92            Self::Danger => " danger",
93        }
94    }
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum ButtonSize {
99    Default,
100    Small,
101    Large,
102}
103
104impl ButtonSize {
105    fn class(self) -> &'static str {
106        match self {
107            Self::Default => "",
108            Self::Small => " sm",
109            Self::Large => " lg",
110        }
111    }
112}
113
114#[derive(Debug, Template)]
115#[non_exhaustive]
116#[template(path = "components/button.html")]
117pub struct Button<'a> {
118    pub label: &'a str,
119    pub href: Option<&'a str>,
120    pub variant: ButtonVariant,
121    pub size: ButtonSize,
122    pub attrs: &'a [HtmlAttr<'a>],
123    pub disabled: bool,
124    pub button_type: &'a str,
125}
126
127impl<'a> Button<'a> {
128    pub const fn new(label: &'a str) -> Self {
129        Self {
130            label,
131            href: None,
132            variant: ButtonVariant::Default,
133            size: ButtonSize::Default,
134            attrs: &[],
135            disabled: false,
136            button_type: "button",
137        }
138    }
139
140    pub const fn primary(label: &'a str) -> Self {
141        Self {
142            variant: ButtonVariant::Primary,
143            ..Self::new(label)
144        }
145    }
146
147    pub const fn link(label: &'a str, href: &'a str) -> Self {
148        Self {
149            href: Some(href),
150            ..Self::new(label)
151        }
152    }
153
154    pub const fn with_href(mut self, href: &'a str) -> Self {
155        self.href = Some(href);
156        self
157    }
158
159    pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
160        self.variant = variant;
161        self
162    }
163
164    pub const fn with_size(mut self, size: ButtonSize) -> Self {
165        self.size = size;
166        self
167    }
168
169    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
170        self.attrs = attrs;
171        self
172    }
173
174    pub const fn disabled(mut self) -> Self {
175        self.disabled = true;
176        self
177    }
178
179    pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
180        self.button_type = button_type;
181        self
182    }
183
184    pub fn class_name(&self) -> String {
185        format!("wf-btn{}{}", self.variant.class(), self.size.class())
186    }
187}
188
189impl<'a> askama::filters::HtmlSafe for Button<'a> {}
190
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum FeedbackKind {
193    Info,
194    Ok,
195    Warn,
196    Error,
197}
198
199impl FeedbackKind {
200    fn class(self) -> &'static str {
201        match self {
202            Self::Info => "info",
203            Self::Ok => "ok",
204            Self::Warn => "warn",
205            Self::Error => "err",
206        }
207    }
208}
209
210#[derive(Debug, Template)]
211#[non_exhaustive]
212#[template(path = "components/alert.html")]
213pub struct Alert<'a> {
214    pub kind: FeedbackKind,
215    pub title: Option<&'a str>,
216    pub message: &'a str,
217}
218
219impl<'a> Alert<'a> {
220    pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
221        Self {
222            kind,
223            title: None,
224            message,
225        }
226    }
227
228    pub const fn with_title(mut self, title: &'a str) -> Self {
229        self.title = Some(title);
230        self
231    }
232
233    pub fn class_name(&self) -> String {
234        format!("wf-alert {}", self.kind.class())
235    }
236}
237
238impl<'a> askama::filters::HtmlSafe for Alert<'a> {}
239
240#[derive(Debug, Template)]
241#[non_exhaustive]
242#[template(path = "components/tag.html")]
243pub struct Tag<'a> {
244    pub kind: Option<FeedbackKind>,
245    pub label: &'a str,
246    pub dot: bool,
247}
248
249impl<'a> Tag<'a> {
250    pub const fn new(label: &'a str) -> Self {
251        Self {
252            kind: None,
253            label,
254            dot: false,
255        }
256    }
257
258    pub const fn status(kind: FeedbackKind, label: &'a str) -> Self {
259        Self {
260            kind: Some(kind),
261            label,
262            dot: true,
263        }
264    }
265
266    pub const fn with_kind(mut self, kind: FeedbackKind) -> Self {
267        self.kind = Some(kind);
268        self
269    }
270
271    pub const fn with_dot(mut self) -> Self {
272        self.dot = true;
273        self
274    }
275
276    pub fn class_name(&self) -> String {
277        match self.kind {
278            Some(kind) => format!("wf-tag {}", kind.class()),
279            None => "wf-tag".to_owned(),
280        }
281    }
282}
283
284impl<'a> askama::filters::HtmlSafe for Tag<'a> {}
285
286#[derive(Clone, Copy, Debug, Eq, PartialEq)]
287pub enum FieldState {
288    Default,
289    Error,
290    Success,
291}
292
293impl FieldState {
294    fn class(self) -> &'static str {
295        match self {
296            Self::Default => "",
297            Self::Error => " is-error",
298            Self::Success => " is-success",
299        }
300    }
301}
302
303#[derive(Debug, Template)]
304#[non_exhaustive]
305#[template(path = "components/field.html")]
306pub struct Field<'a> {
307    pub label: &'a str,
308    pub control_html: TrustedHtml<'a>,
309    pub hint: Option<&'a str>,
310    pub state: FieldState,
311}
312
313impl<'a> Field<'a> {
314    pub const fn new(label: &'a str, control_html: TrustedHtml<'a>) -> Self {
315        Self {
316            label,
317            control_html,
318            hint: None,
319            state: FieldState::Default,
320        }
321    }
322
323    pub const fn with_hint(mut self, hint: &'a str) -> Self {
324        self.hint = Some(hint);
325        self
326    }
327
328    pub const fn with_state(mut self, state: FieldState) -> Self {
329        self.state = state;
330        self
331    }
332
333    pub fn class_name(&self) -> String {
334        format!("wf-field{}", self.state.class())
335    }
336}
337
338impl<'a> askama::filters::HtmlSafe for Field<'a> {}
339
340#[derive(Debug, Template)]
341#[non_exhaustive]
342#[template(path = "components/button_group.html")]
343pub struct ButtonGroup<'a> {
344    pub buttons: &'a [Button<'a>],
345    pub attrs: &'a [HtmlAttr<'a>],
346}
347
348impl<'a> ButtonGroup<'a> {
349    pub const fn new(buttons: &'a [Button<'a>]) -> Self {
350        Self {
351            buttons,
352            attrs: &[],
353        }
354    }
355
356    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
357        self.attrs = attrs;
358        self
359    }
360}
361
362impl<'a> askama::filters::HtmlSafe for ButtonGroup<'a> {}
363
364#[derive(Debug, Template)]
365#[non_exhaustive]
366#[template(path = "components/split_button.html")]
367pub struct SplitButton<'a> {
368    pub action: Button<'a>,
369    pub menu: Button<'a>,
370    pub attrs: &'a [HtmlAttr<'a>],
371}
372
373impl<'a> SplitButton<'a> {
374    pub const fn new(action: Button<'a>, menu: Button<'a>) -> Self {
375        Self {
376            action,
377            menu,
378            attrs: &[],
379        }
380    }
381
382    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
383        self.attrs = attrs;
384        self
385    }
386}
387
388impl<'a> askama::filters::HtmlSafe for SplitButton<'a> {}
389
390#[derive(Debug, Template)]
391#[non_exhaustive]
392#[template(path = "components/icon_button.html")]
393pub struct IconButton<'a> {
394    pub icon: TrustedHtml<'a>,
395    pub label: &'a str,
396    pub href: Option<&'a str>,
397    pub variant: ButtonVariant,
398    pub attrs: &'a [HtmlAttr<'a>],
399    pub disabled: bool,
400    pub button_type: &'a str,
401}
402
403impl<'a> IconButton<'a> {
404    pub const fn new(icon: TrustedHtml<'a>, label: &'a str) -> Self {
405        Self {
406            icon,
407            label,
408            href: None,
409            variant: ButtonVariant::Default,
410            attrs: &[],
411            disabled: false,
412            button_type: "button",
413        }
414    }
415
416    pub const fn with_href(mut self, href: &'a str) -> Self {
417        self.href = Some(href);
418        self
419    }
420
421    pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
422        self.variant = variant;
423        self
424    }
425
426    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
427        self.attrs = attrs;
428        self
429    }
430
431    pub const fn disabled(mut self) -> Self {
432        self.disabled = true;
433        self
434    }
435
436    pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
437        self.button_type = button_type;
438        self
439    }
440
441    pub fn class_name(&self) -> String {
442        format!("wf-icon-btn{}", self.variant.class())
443    }
444}
445
446impl<'a> askama::filters::HtmlSafe for IconButton<'a> {}
447
448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
449pub enum ControlSize {
450    Default,
451    Small,
452}
453
454impl ControlSize {
455    fn class(self) -> &'static str {
456        match self {
457            Self::Default => "",
458            Self::Small => " sm",
459        }
460    }
461}
462
463#[derive(Debug, Template)]
464#[non_exhaustive]
465#[template(path = "components/input.html")]
466pub struct Input<'a> {
467    pub name: &'a str,
468    pub input_type: &'a str,
469    pub value: Option<&'a str>,
470    pub placeholder: Option<&'a str>,
471    pub size: ControlSize,
472    pub attrs: &'a [HtmlAttr<'a>],
473    pub disabled: bool,
474    pub required: bool,
475}
476
477impl<'a> Input<'a> {
478    pub const fn new(name: &'a str) -> Self {
479        Self {
480            name,
481            input_type: "text",
482            value: None,
483            placeholder: None,
484            size: ControlSize::Default,
485            attrs: &[],
486            disabled: false,
487            required: false,
488        }
489    }
490
491    pub const fn email(name: &'a str) -> Self {
492        Self {
493            input_type: "email",
494            ..Self::new(name)
495        }
496    }
497
498    pub const fn url(name: &'a str) -> Self {
499        Self {
500            input_type: "url",
501            ..Self::new(name)
502        }
503    }
504
505    pub const fn with_type(mut self, input_type: &'a str) -> Self {
506        self.input_type = input_type;
507        self
508    }
509
510    pub const fn with_value(mut self, value: &'a str) -> Self {
511        self.value = Some(value);
512        self
513    }
514
515    pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
516        self.placeholder = Some(placeholder);
517        self
518    }
519
520    pub const fn with_size(mut self, size: ControlSize) -> Self {
521        self.size = size;
522        self
523    }
524
525    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
526        self.attrs = attrs;
527        self
528    }
529
530    pub const fn disabled(mut self) -> Self {
531        self.disabled = true;
532        self
533    }
534
535    pub const fn required(mut self) -> Self {
536        self.required = true;
537        self
538    }
539
540    pub fn class_name(&self) -> String {
541        format!("wf-input{}", self.size.class())
542    }
543}
544
545impl<'a> askama::filters::HtmlSafe for Input<'a> {}
546
547#[derive(Debug, Template)]
548#[non_exhaustive]
549#[template(path = "components/textarea.html")]
550pub struct Textarea<'a> {
551    pub name: &'a str,
552    pub value: Option<&'a str>,
553    pub placeholder: Option<&'a str>,
554    pub rows: Option<u16>,
555    pub attrs: &'a [HtmlAttr<'a>],
556    pub disabled: bool,
557    pub required: bool,
558}
559
560impl<'a> Textarea<'a> {
561    pub const fn new(name: &'a str) -> Self {
562        Self {
563            name,
564            value: None,
565            placeholder: None,
566            rows: None,
567            attrs: &[],
568            disabled: false,
569            required: false,
570        }
571    }
572
573    pub const fn with_value(mut self, value: &'a str) -> Self {
574        self.value = Some(value);
575        self
576    }
577
578    pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
579        self.placeholder = Some(placeholder);
580        self
581    }
582
583    pub const fn with_rows(mut self, rows: u16) -> Self {
584        self.rows = Some(rows);
585        self
586    }
587
588    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
589        self.attrs = attrs;
590        self
591    }
592
593    pub const fn disabled(mut self) -> Self {
594        self.disabled = true;
595        self
596    }
597
598    pub const fn required(mut self) -> Self {
599        self.required = true;
600        self
601    }
602}
603
604impl<'a> askama::filters::HtmlSafe for Textarea<'a> {}
605
606#[derive(Clone, Copy, Debug, Eq, PartialEq)]
607pub struct SelectOption<'a> {
608    pub value: &'a str,
609    pub label: &'a str,
610    pub selected: bool,
611    pub disabled: bool,
612}
613
614impl<'a> SelectOption<'a> {
615    pub const fn new(value: &'a str, label: &'a str) -> Self {
616        Self {
617            value,
618            label,
619            selected: false,
620            disabled: false,
621        }
622    }
623
624    pub const fn selected(mut self) -> Self {
625        self.selected = true;
626        self
627    }
628
629    pub const fn disabled(mut self) -> Self {
630        self.disabled = true;
631        self
632    }
633}
634
635#[derive(Debug, Template)]
636#[non_exhaustive]
637#[template(path = "components/select.html")]
638pub struct Select<'a> {
639    pub name: &'a str,
640    pub options: &'a [SelectOption<'a>],
641    pub size: ControlSize,
642    pub attrs: &'a [HtmlAttr<'a>],
643    pub disabled: bool,
644    pub required: bool,
645}
646
647impl<'a> Select<'a> {
648    pub const fn new(name: &'a str, options: &'a [SelectOption<'a>]) -> Self {
649        Self {
650            name,
651            options,
652            size: ControlSize::Default,
653            attrs: &[],
654            disabled: false,
655            required: false,
656        }
657    }
658
659    pub const fn with_size(mut self, size: ControlSize) -> Self {
660        self.size = size;
661        self
662    }
663
664    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
665        self.attrs = attrs;
666        self
667    }
668
669    pub const fn disabled(mut self) -> Self {
670        self.disabled = true;
671        self
672    }
673
674    pub const fn required(mut self) -> Self {
675        self.required = true;
676        self
677    }
678
679    pub fn class_name(&self) -> String {
680        format!("wf-select{}", self.size.class())
681    }
682}
683
684impl<'a> askama::filters::HtmlSafe for Select<'a> {}
685
686#[derive(Debug, Template)]
687#[non_exhaustive]
688#[template(path = "components/input_group.html")]
689pub struct InputGroup<'a> {
690    pub control_html: TrustedHtml<'a>,
691    pub prefix: Option<&'a str>,
692    pub suffix: Option<&'a str>,
693    pub attrs: &'a [HtmlAttr<'a>],
694}
695
696impl<'a> InputGroup<'a> {
697    pub const fn new(control_html: TrustedHtml<'a>) -> Self {
698        Self {
699            control_html,
700            prefix: None,
701            suffix: None,
702            attrs: &[],
703        }
704    }
705
706    pub const fn with_prefix(mut self, prefix: &'a str) -> Self {
707        self.prefix = Some(prefix);
708        self
709    }
710
711    pub const fn with_suffix(mut self, suffix: &'a str) -> Self {
712        self.suffix = Some(suffix);
713        self
714    }
715
716    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
717        self.attrs = attrs;
718        self
719    }
720}
721
722impl<'a> askama::filters::HtmlSafe for InputGroup<'a> {}
723
724#[derive(Clone, Copy, Debug, Eq, PartialEq)]
725pub enum CheckKind {
726    Checkbox,
727    Radio,
728}
729
730impl CheckKind {
731    fn input_type(self) -> &'static str {
732        match self {
733            Self::Checkbox => "checkbox",
734            Self::Radio => "radio",
735        }
736    }
737}
738
739#[derive(Debug, Template)]
740#[non_exhaustive]
741#[template(path = "components/check_row.html")]
742pub struct CheckRow<'a> {
743    pub kind: CheckKind,
744    pub name: &'a str,
745    pub value: &'a str,
746    pub label: &'a str,
747    pub attrs: &'a [HtmlAttr<'a>],
748    pub checked: bool,
749    pub disabled: bool,
750}
751
752impl<'a> CheckRow<'a> {
753    pub const fn checkbox(name: &'a str, value: &'a str, label: &'a str) -> Self {
754        Self {
755            kind: CheckKind::Checkbox,
756            name,
757            value,
758            label,
759            attrs: &[],
760            checked: false,
761            disabled: false,
762        }
763    }
764
765    pub const fn radio(name: &'a str, value: &'a str, label: &'a str) -> Self {
766        Self {
767            kind: CheckKind::Radio,
768            ..Self::checkbox(name, value, label)
769        }
770    }
771
772    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
773        self.attrs = attrs;
774        self
775    }
776
777    pub const fn checked(mut self) -> Self {
778        self.checked = true;
779        self
780    }
781
782    pub const fn disabled(mut self) -> Self {
783        self.disabled = true;
784        self
785    }
786
787    pub fn input_type(&self) -> &'static str {
788        self.kind.input_type()
789    }
790}
791
792impl<'a> askama::filters::HtmlSafe for CheckRow<'a> {}
793
794#[derive(Debug, Template)]
795#[non_exhaustive]
796#[template(path = "components/switch.html")]
797pub struct Switch<'a> {
798    pub name: &'a str,
799    pub value: &'a str,
800    pub attrs: &'a [HtmlAttr<'a>],
801    pub checked: bool,
802    pub disabled: bool,
803}
804
805impl<'a> Switch<'a> {
806    pub const fn new(name: &'a str) -> Self {
807        Self {
808            name,
809            value: "on",
810            attrs: &[],
811            checked: false,
812            disabled: false,
813        }
814    }
815
816    pub const fn with_value(mut self, value: &'a str) -> Self {
817        self.value = value;
818        self
819    }
820
821    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
822        self.attrs = attrs;
823        self
824    }
825
826    pub const fn checked(mut self) -> Self {
827        self.checked = true;
828        self
829    }
830
831    pub const fn disabled(mut self) -> Self {
832        self.disabled = true;
833        self
834    }
835}
836
837impl<'a> askama::filters::HtmlSafe for Switch<'a> {}
838
839#[derive(Debug, Template)]
840#[non_exhaustive]
841#[template(path = "components/range.html")]
842pub struct Range<'a> {
843    pub name: &'a str,
844    pub value: Option<&'a str>,
845    pub min: Option<&'a str>,
846    pub max: Option<&'a str>,
847    pub step: Option<&'a str>,
848    pub attrs: &'a [HtmlAttr<'a>],
849    pub disabled: bool,
850}
851
852impl<'a> Range<'a> {
853    pub const fn new(name: &'a str) -> Self {
854        Self {
855            name,
856            value: None,
857            min: None,
858            max: None,
859            step: None,
860            attrs: &[],
861            disabled: false,
862        }
863    }
864
865    pub const fn with_value(mut self, value: &'a str) -> Self {
866        self.value = Some(value);
867        self
868    }
869
870    pub const fn with_bounds(mut self, min: &'a str, max: &'a str) -> Self {
871        self.min = Some(min);
872        self.max = Some(max);
873        self
874    }
875
876    pub const fn with_step(mut self, step: &'a str) -> Self {
877        self.step = Some(step);
878        self
879    }
880
881    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
882        self.attrs = attrs;
883        self
884    }
885
886    pub const fn disabled(mut self) -> Self {
887        self.disabled = true;
888        self
889    }
890}
891
892impl<'a> askama::filters::HtmlSafe for Range<'a> {}
893
894#[derive(Debug, Template)]
895#[non_exhaustive]
896#[template(path = "components/panel.html")]
897pub struct Panel<'a> {
898    pub title: &'a str,
899    pub body_html: TrustedHtml<'a>,
900    pub action_html: Option<TrustedHtml<'a>>,
901    pub danger: bool,
902    pub attrs: &'a [HtmlAttr<'a>],
903}
904
905impl<'a> Panel<'a> {
906    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
907        Self {
908            title,
909            body_html,
910            action_html: None,
911            danger: false,
912            attrs: &[],
913        }
914    }
915
916    pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
917        self.action_html = Some(action_html);
918        self
919    }
920
921    pub const fn danger(mut self) -> Self {
922        self.danger = true;
923        self
924    }
925
926    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
927        self.attrs = attrs;
928        self
929    }
930
931    pub fn class_name(&self) -> &'static str {
932        if self.danger {
933            "wf-panel is-danger"
934        } else {
935            "wf-panel"
936        }
937    }
938}
939
940impl<'a> askama::filters::HtmlSafe for Panel<'a> {}
941
942#[derive(Debug, Template)]
943#[non_exhaustive]
944#[template(path = "components/card.html")]
945pub struct Card<'a> {
946    pub title: &'a str,
947    pub body_html: TrustedHtml<'a>,
948    pub kicker: Option<&'a str>,
949    pub foot_html: Option<TrustedHtml<'a>>,
950    pub raised: bool,
951    pub attrs: &'a [HtmlAttr<'a>],
952}
953
954impl<'a> Card<'a> {
955    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
956        Self {
957            title,
958            body_html,
959            kicker: None,
960            foot_html: None,
961            raised: false,
962            attrs: &[],
963        }
964    }
965
966    pub const fn with_kicker(mut self, kicker: &'a str) -> Self {
967        self.kicker = Some(kicker);
968        self
969    }
970
971    pub const fn with_foot(mut self, foot_html: TrustedHtml<'a>) -> Self {
972        self.foot_html = Some(foot_html);
973        self
974    }
975
976    pub const fn raised(mut self) -> Self {
977        self.raised = true;
978        self
979    }
980
981    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
982        self.attrs = attrs;
983        self
984    }
985
986    pub fn class_name(&self) -> &'static str {
987        if self.raised {
988            "wf-card is-raised"
989        } else {
990            "wf-card"
991        }
992    }
993}
994
995impl<'a> askama::filters::HtmlSafe for Card<'a> {}
996
997#[derive(Clone, Copy, Debug, Eq, PartialEq)]
998pub enum BadgeKind {
999    Default,
1000    Muted,
1001    Error,
1002}
1003
1004impl BadgeKind {
1005    fn class(self) -> &'static str {
1006        match self {
1007            Self::Default => "",
1008            Self::Muted => " muted",
1009            Self::Error => " err",
1010        }
1011    }
1012}
1013
1014#[derive(Debug, Template)]
1015#[non_exhaustive]
1016#[template(path = "components/badge.html")]
1017pub struct Badge<'a> {
1018    pub label: &'a str,
1019    pub kind: BadgeKind,
1020}
1021
1022impl<'a> Badge<'a> {
1023    pub const fn new(label: &'a str) -> Self {
1024        Self {
1025            label,
1026            kind: BadgeKind::Default,
1027        }
1028    }
1029
1030    pub const fn muted(label: &'a str) -> Self {
1031        Self {
1032            kind: BadgeKind::Muted,
1033            ..Self::new(label)
1034        }
1035    }
1036
1037    pub const fn error(label: &'a str) -> Self {
1038        Self {
1039            kind: BadgeKind::Error,
1040            ..Self::new(label)
1041        }
1042    }
1043
1044    pub fn class_name(&self) -> String {
1045        format!("wf-badge{}", self.kind.class())
1046    }
1047}
1048
1049impl<'a> askama::filters::HtmlSafe for Badge<'a> {}
1050
1051#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1052pub enum AvatarSize {
1053    Default,
1054    Small,
1055    Large,
1056    ExtraLarge,
1057}
1058
1059impl AvatarSize {
1060    fn class(self) -> &'static str {
1061        match self {
1062            Self::Default => "",
1063            Self::Small => " sm",
1064            Self::Large => " lg",
1065            Self::ExtraLarge => " xl",
1066        }
1067    }
1068}
1069
1070#[derive(Debug, Template)]
1071#[non_exhaustive]
1072#[template(path = "components/avatar.html")]
1073pub struct Avatar<'a> {
1074    pub initials: &'a str,
1075    pub image_src: Option<&'a str>,
1076    pub size: AvatarSize,
1077    pub accent: bool,
1078}
1079
1080impl<'a> Avatar<'a> {
1081    pub const fn new(initials: &'a str) -> Self {
1082        Self {
1083            initials,
1084            image_src: None,
1085            size: AvatarSize::Default,
1086            accent: false,
1087        }
1088    }
1089
1090    pub const fn with_image(mut self, image_src: &'a str) -> Self {
1091        self.image_src = Some(image_src);
1092        self
1093    }
1094
1095    pub const fn with_size(mut self, size: AvatarSize) -> Self {
1096        self.size = size;
1097        self
1098    }
1099
1100    pub const fn accent(mut self) -> Self {
1101        self.accent = true;
1102        self
1103    }
1104
1105    pub fn class_name(&self) -> String {
1106        let accent = if self.accent { " accent" } else { "" };
1107        format!("wf-avatar{}{}", self.size.class(), accent)
1108    }
1109}
1110
1111impl<'a> askama::filters::HtmlSafe for Avatar<'a> {}
1112
1113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1114pub enum DeltaKind {
1115    Neutral,
1116    Up,
1117    Down,
1118}
1119
1120impl DeltaKind {
1121    fn class(self) -> &'static str {
1122        match self {
1123            Self::Neutral => "",
1124            Self::Up => " up",
1125            Self::Down => " down",
1126        }
1127    }
1128}
1129
1130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1131pub struct Stat<'a> {
1132    pub label: &'a str,
1133    pub value: &'a str,
1134    pub unit: Option<&'a str>,
1135    pub delta: Option<&'a str>,
1136    pub delta_kind: DeltaKind,
1137    pub foot: Option<&'a str>,
1138}
1139
1140impl<'a> Stat<'a> {
1141    pub const fn new(label: &'a str, value: &'a str) -> Self {
1142        Self {
1143            label,
1144            value,
1145            unit: None,
1146            delta: None,
1147            delta_kind: DeltaKind::Neutral,
1148            foot: None,
1149        }
1150    }
1151
1152    pub const fn with_unit(mut self, unit: &'a str) -> Self {
1153        self.unit = Some(unit);
1154        self
1155    }
1156
1157    pub const fn with_delta(mut self, delta: &'a str, kind: DeltaKind) -> Self {
1158        self.delta = Some(delta);
1159        self.delta_kind = kind;
1160        self
1161    }
1162
1163    pub const fn with_foot(mut self, foot: &'a str) -> Self {
1164        self.foot = Some(foot);
1165        self
1166    }
1167
1168    pub fn delta_class(&self) -> String {
1169        format!("wf-stat-delta{}", self.delta_kind.class())
1170    }
1171}
1172
1173#[derive(Debug, Template)]
1174#[non_exhaustive]
1175#[template(path = "components/stat_row.html")]
1176pub struct StatRow<'a> {
1177    pub stats: &'a [Stat<'a>],
1178}
1179
1180impl<'a> StatRow<'a> {
1181    pub const fn new(stats: &'a [Stat<'a>]) -> Self {
1182        Self { stats }
1183    }
1184}
1185
1186impl<'a> askama::filters::HtmlSafe for StatRow<'a> {}
1187
1188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1189pub struct BreadcrumbItem<'a> {
1190    pub label: &'a str,
1191    pub href: Option<&'a str>,
1192    pub current: bool,
1193}
1194
1195impl<'a> BreadcrumbItem<'a> {
1196    pub const fn link(label: &'a str, href: &'a str) -> Self {
1197        Self {
1198            label,
1199            href: Some(href),
1200            current: false,
1201        }
1202    }
1203
1204    pub const fn current(label: &'a str) -> Self {
1205        Self {
1206            label,
1207            href: None,
1208            current: true,
1209        }
1210    }
1211}
1212
1213#[derive(Debug, Template)]
1214#[non_exhaustive]
1215#[template(path = "components/breadcrumbs.html")]
1216pub struct Breadcrumbs<'a> {
1217    pub items: &'a [BreadcrumbItem<'a>],
1218}
1219
1220impl<'a> Breadcrumbs<'a> {
1221    pub const fn new(items: &'a [BreadcrumbItem<'a>]) -> Self {
1222        Self { items }
1223    }
1224}
1225
1226impl<'a> askama::filters::HtmlSafe for Breadcrumbs<'a> {}
1227
1228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1229pub struct TabItem<'a> {
1230    pub label: &'a str,
1231    pub href: &'a str,
1232    pub active: bool,
1233}
1234
1235impl<'a> TabItem<'a> {
1236    pub const fn link(label: &'a str, href: &'a str) -> Self {
1237        Self {
1238            label,
1239            href,
1240            active: false,
1241        }
1242    }
1243
1244    pub const fn active(mut self) -> Self {
1245        self.active = true;
1246        self
1247    }
1248}
1249
1250#[derive(Debug, Template)]
1251#[non_exhaustive]
1252#[template(path = "components/tabs.html")]
1253pub struct Tabs<'a> {
1254    pub items: &'a [TabItem<'a>],
1255}
1256
1257impl<'a> Tabs<'a> {
1258    pub const fn new(items: &'a [TabItem<'a>]) -> Self {
1259        Self { items }
1260    }
1261}
1262
1263impl<'a> askama::filters::HtmlSafe for Tabs<'a> {}
1264
1265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1266pub struct SegmentOption<'a> {
1267    pub label: &'a str,
1268    pub value: &'a str,
1269    pub active: bool,
1270}
1271
1272impl<'a> SegmentOption<'a> {
1273    pub const fn new(label: &'a str, value: &'a str) -> Self {
1274        Self {
1275            label,
1276            value,
1277            active: false,
1278        }
1279    }
1280
1281    pub const fn active(mut self) -> Self {
1282        self.active = true;
1283        self
1284    }
1285}
1286
1287#[derive(Debug, Template)]
1288#[non_exhaustive]
1289#[template(path = "components/segmented_control.html")]
1290pub struct SegmentedControl<'a> {
1291    pub options: &'a [SegmentOption<'a>],
1292    pub small: bool,
1293}
1294
1295impl<'a> SegmentedControl<'a> {
1296    pub const fn new(options: &'a [SegmentOption<'a>]) -> Self {
1297        Self {
1298            options,
1299            small: false,
1300        }
1301    }
1302
1303    pub const fn small(mut self) -> Self {
1304        self.small = true;
1305        self
1306    }
1307
1308    pub fn class_name(&self) -> &'static str {
1309        if self.small { "wf-seg sm" } else { "wf-seg" }
1310    }
1311}
1312
1313impl<'a> askama::filters::HtmlSafe for SegmentedControl<'a> {}
1314
1315#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1316pub struct PageLink<'a> {
1317    pub label: &'a str,
1318    pub href: Option<&'a str>,
1319    pub active: bool,
1320    pub disabled: bool,
1321    pub ellipsis: bool,
1322}
1323
1324impl<'a> PageLink<'a> {
1325    pub const fn link(label: &'a str, href: &'a str) -> Self {
1326        Self {
1327            label,
1328            href: Some(href),
1329            active: false,
1330            disabled: false,
1331            ellipsis: false,
1332        }
1333    }
1334
1335    pub const fn disabled(label: &'a str) -> Self {
1336        Self {
1337            label,
1338            href: None,
1339            active: false,
1340            disabled: true,
1341            ellipsis: false,
1342        }
1343    }
1344
1345    pub const fn ellipsis() -> Self {
1346        Self {
1347            label: "...",
1348            href: None,
1349            active: false,
1350            disabled: false,
1351            ellipsis: true,
1352        }
1353    }
1354
1355    pub const fn active(mut self) -> Self {
1356        self.active = true;
1357        self
1358    }
1359}
1360
1361#[derive(Debug, Template)]
1362#[non_exhaustive]
1363#[template(path = "components/pagination.html")]
1364pub struct Pagination<'a> {
1365    pub pages: &'a [PageLink<'a>],
1366}
1367
1368impl<'a> Pagination<'a> {
1369    pub const fn new(pages: &'a [PageLink<'a>]) -> Self {
1370        Self { pages }
1371    }
1372}
1373
1374impl<'a> askama::filters::HtmlSafe for Pagination<'a> {}
1375
1376#[derive(Debug, Template)]
1377#[non_exhaustive]
1378#[template(path = "components/nav_section.html")]
1379pub struct NavSection<'a> {
1380    pub label: &'a str,
1381}
1382
1383impl<'a> NavSection<'a> {
1384    pub const fn new(label: &'a str) -> Self {
1385        Self { label }
1386    }
1387}
1388
1389impl<'a> askama::filters::HtmlSafe for NavSection<'a> {}
1390
1391#[derive(Debug, Template)]
1392#[non_exhaustive]
1393#[template(path = "components/nav_item.html")]
1394pub struct NavItem<'a> {
1395    pub label: &'a str,
1396    pub href: &'a str,
1397    pub count: Option<&'a str>,
1398    pub active: bool,
1399}
1400
1401impl<'a> NavItem<'a> {
1402    pub const fn new(label: &'a str, href: &'a str) -> Self {
1403        Self {
1404            label,
1405            href,
1406            count: None,
1407            active: false,
1408        }
1409    }
1410
1411    pub const fn active(mut self) -> Self {
1412        self.active = true;
1413        self
1414    }
1415
1416    pub const fn with_count(mut self, count: &'a str) -> Self {
1417        self.count = Some(count);
1418        self
1419    }
1420
1421    pub fn class_name(&self) -> &'static str {
1422        if self.active {
1423            "wf-nav-item is-active"
1424        } else {
1425            "wf-nav-item"
1426        }
1427    }
1428}
1429
1430impl<'a> askama::filters::HtmlSafe for NavItem<'a> {}
1431
1432#[derive(Debug, Template)]
1433#[non_exhaustive]
1434#[template(path = "components/topbar.html")]
1435pub struct Topbar<'a> {
1436    pub breadcrumbs_html: TrustedHtml<'a>,
1437    pub actions_html: TrustedHtml<'a>,
1438}
1439
1440impl<'a> Topbar<'a> {
1441    pub const fn new(breadcrumbs_html: TrustedHtml<'a>, actions_html: TrustedHtml<'a>) -> Self {
1442        Self {
1443            breadcrumbs_html,
1444            actions_html,
1445        }
1446    }
1447}
1448
1449impl<'a> askama::filters::HtmlSafe for Topbar<'a> {}
1450
1451#[derive(Debug, Template)]
1452#[non_exhaustive]
1453#[template(path = "components/statusbar.html")]
1454pub struct Statusbar<'a> {
1455    pub left: &'a str,
1456    pub right: &'a str,
1457}
1458
1459impl<'a> Statusbar<'a> {
1460    pub const fn new(left: &'a str, right: &'a str) -> Self {
1461        Self { left, right }
1462    }
1463}
1464
1465impl<'a> askama::filters::HtmlSafe for Statusbar<'a> {}
1466
1467#[derive(Debug, Template)]
1468#[non_exhaustive]
1469#[template(path = "components/empty_state.html")]
1470pub struct EmptyState<'a> {
1471    pub title: &'a str,
1472    pub body: &'a str,
1473    pub glyph_html: Option<TrustedHtml<'a>>,
1474    pub actions_html: Option<TrustedHtml<'a>>,
1475    pub bordered: bool,
1476    pub dense: bool,
1477}
1478
1479impl<'a> EmptyState<'a> {
1480    pub const fn new(title: &'a str, body: &'a str) -> Self {
1481        Self {
1482            title,
1483            body,
1484            glyph_html: None,
1485            actions_html: None,
1486            bordered: false,
1487            dense: false,
1488        }
1489    }
1490
1491    pub const fn with_glyph(mut self, glyph_html: TrustedHtml<'a>) -> Self {
1492        self.glyph_html = Some(glyph_html);
1493        self
1494    }
1495
1496    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
1497        self.actions_html = Some(actions_html);
1498        self
1499    }
1500
1501    pub const fn bordered(mut self) -> Self {
1502        self.bordered = true;
1503        self
1504    }
1505
1506    pub const fn dense(mut self) -> Self {
1507        self.dense = true;
1508        self
1509    }
1510
1511    pub fn class_name(&self) -> String {
1512        let bordered = if self.bordered { " bordered" } else { "" };
1513        let dense = if self.dense { " dense" } else { "" };
1514        format!("wf-empty{bordered}{dense}")
1515    }
1516}
1517
1518impl<'a> askama::filters::HtmlSafe for EmptyState<'a> {}
1519
1520#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1521pub struct TableHeader<'a> {
1522    pub label: &'a str,
1523    pub numeric: bool,
1524}
1525
1526impl<'a> TableHeader<'a> {
1527    pub const fn new(label: &'a str) -> Self {
1528        Self {
1529            label,
1530            numeric: false,
1531        }
1532    }
1533
1534    pub const fn numeric(label: &'a str) -> Self {
1535        Self {
1536            label,
1537            numeric: true,
1538        }
1539    }
1540
1541    pub fn class_name(&self) -> &'static str {
1542        if self.numeric { "num" } else { "" }
1543    }
1544}
1545
1546#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1547pub struct TableCell<'a> {
1548    pub text: &'a str,
1549    pub numeric: bool,
1550    pub strong: bool,
1551    pub muted: bool,
1552}
1553
1554impl<'a> TableCell<'a> {
1555    pub const fn new(text: &'a str) -> Self {
1556        Self {
1557            text,
1558            numeric: false,
1559            strong: false,
1560            muted: false,
1561        }
1562    }
1563
1564    pub const fn numeric(text: &'a str) -> Self {
1565        Self {
1566            numeric: true,
1567            ..Self::new(text)
1568        }
1569    }
1570
1571    pub const fn strong(text: &'a str) -> Self {
1572        Self {
1573            strong: true,
1574            ..Self::new(text)
1575        }
1576    }
1577
1578    pub const fn muted(text: &'a str) -> Self {
1579        Self {
1580            muted: true,
1581            ..Self::new(text)
1582        }
1583    }
1584
1585    pub fn class_name(&self) -> String {
1586        let numeric = if self.numeric { "num" } else { "" };
1587        let strong = if self.strong { " strong" } else { "" };
1588        let muted = if self.muted { " muted" } else { "" };
1589        format!("{numeric}{strong}{muted}")
1590    }
1591}
1592
1593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1594pub struct TableRow<'a> {
1595    pub cells: &'a [TableCell<'a>],
1596    pub selected: bool,
1597}
1598
1599impl<'a> TableRow<'a> {
1600    pub const fn new(cells: &'a [TableCell<'a>]) -> Self {
1601        Self {
1602            cells,
1603            selected: false,
1604        }
1605    }
1606
1607    pub const fn selected(mut self) -> Self {
1608        self.selected = true;
1609        self
1610    }
1611}
1612
1613#[derive(Debug, Template)]
1614#[non_exhaustive]
1615#[template(path = "components/table.html")]
1616pub struct Table<'a> {
1617    pub headers: &'a [TableHeader<'a>],
1618    pub rows: &'a [TableRow<'a>],
1619    pub flush: bool,
1620    pub interactive: bool,
1621    pub sticky: bool,
1622    pub pin_last: bool,
1623}
1624
1625impl<'a> Table<'a> {
1626    pub const fn new(headers: &'a [TableHeader<'a>], rows: &'a [TableRow<'a>]) -> Self {
1627        Self {
1628            headers,
1629            rows,
1630            flush: false,
1631            interactive: false,
1632            sticky: false,
1633            pin_last: false,
1634        }
1635    }
1636
1637    pub const fn flush(mut self) -> Self {
1638        self.flush = true;
1639        self
1640    }
1641
1642    pub const fn interactive(mut self) -> Self {
1643        self.interactive = true;
1644        self
1645    }
1646
1647    pub const fn sticky(mut self) -> Self {
1648        self.sticky = true;
1649        self
1650    }
1651
1652    pub const fn pin_last(mut self) -> Self {
1653        self.pin_last = true;
1654        self
1655    }
1656
1657    pub fn class_name(&self) -> String {
1658        let flush = if self.flush { " flush" } else { "" };
1659        let interactive = if self.interactive {
1660            " is-interactive"
1661        } else {
1662            ""
1663        };
1664        let sticky = if self.sticky { " sticky" } else { "" };
1665        let pin_last = if self.pin_last { " pin-last" } else { "" };
1666        format!("wf-table{flush}{interactive}{sticky}{pin_last}")
1667    }
1668}
1669
1670impl<'a> askama::filters::HtmlSafe for Table<'a> {}
1671
1672#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1673pub struct DefinitionItem<'a> {
1674    pub term: &'a str,
1675    pub description: &'a str,
1676}
1677
1678impl<'a> DefinitionItem<'a> {
1679    pub const fn new(term: &'a str, description: &'a str) -> Self {
1680        Self { term, description }
1681    }
1682}
1683
1684#[derive(Debug, Template)]
1685#[non_exhaustive]
1686#[template(path = "components/definition_list.html")]
1687pub struct DefinitionList<'a> {
1688    pub items: &'a [DefinitionItem<'a>],
1689    pub flush: bool,
1690}
1691
1692impl<'a> DefinitionList<'a> {
1693    pub const fn new(items: &'a [DefinitionItem<'a>]) -> Self {
1694        Self {
1695            items,
1696            flush: false,
1697        }
1698    }
1699
1700    pub const fn flush(mut self) -> Self {
1701        self.flush = true;
1702        self
1703    }
1704
1705    pub fn class_name(&self) -> &'static str {
1706        if self.flush { "wf-dl flush" } else { "wf-dl" }
1707    }
1708}
1709
1710impl<'a> askama::filters::HtmlSafe for DefinitionList<'a> {}
1711
1712#[derive(Debug, Template)]
1713#[non_exhaustive]
1714#[template(path = "components/grid.html")]
1715pub struct Grid<'a> {
1716    pub content_html: TrustedHtml<'a>,
1717    pub columns: u8,
1718}
1719
1720impl<'a> Grid<'a> {
1721    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
1722        Self {
1723            content_html,
1724            columns: 2,
1725        }
1726    }
1727
1728    pub const fn with_columns(mut self, columns: u8) -> Self {
1729        self.columns = columns;
1730        self
1731    }
1732
1733    pub fn class_name(&self) -> String {
1734        format!("wf-grid cols-{}", self.columns)
1735    }
1736}
1737
1738impl<'a> askama::filters::HtmlSafe for Grid<'a> {}
1739
1740#[derive(Debug, Template)]
1741#[non_exhaustive]
1742#[template(path = "components/split.html")]
1743pub struct Split<'a> {
1744    pub content_html: TrustedHtml<'a>,
1745    pub vertical: bool,
1746}
1747
1748impl<'a> Split<'a> {
1749    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
1750        Self {
1751            content_html,
1752            vertical: false,
1753        }
1754    }
1755
1756    pub const fn vertical(mut self) -> Self {
1757        self.vertical = true;
1758        self
1759    }
1760
1761    pub fn class_name(&self) -> &'static str {
1762        if self.vertical {
1763            "wf-split vertical"
1764        } else {
1765            "wf-split"
1766        }
1767    }
1768}
1769
1770impl<'a> askama::filters::HtmlSafe for Split<'a> {}
1771
1772#[derive(Debug, Template)]
1773#[non_exhaustive]
1774#[template(path = "components/callout.html")]
1775pub struct Callout<'a> {
1776    pub kind: FeedbackKind,
1777    pub title: Option<&'a str>,
1778    pub body_html: TrustedHtml<'a>,
1779}
1780
1781impl<'a> Callout<'a> {
1782    pub const fn new(kind: FeedbackKind, body_html: TrustedHtml<'a>) -> Self {
1783        Self {
1784            kind,
1785            title: None,
1786            body_html,
1787        }
1788    }
1789
1790    pub const fn with_title(mut self, title: &'a str) -> Self {
1791        self.title = Some(title);
1792        self
1793    }
1794
1795    pub fn class_name(&self) -> String {
1796        format!("wf-callout {}", self.kind.class())
1797    }
1798}
1799
1800impl<'a> askama::filters::HtmlSafe for Callout<'a> {}
1801
1802#[derive(Debug, Template)]
1803#[non_exhaustive]
1804#[template(path = "components/toast.html")]
1805pub struct Toast<'a> {
1806    pub kind: FeedbackKind,
1807    pub message: &'a str,
1808}
1809
1810impl<'a> Toast<'a> {
1811    pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
1812        Self { kind, message }
1813    }
1814
1815    pub fn class_name(&self) -> String {
1816        format!("wf-toast {}", self.kind.class())
1817    }
1818}
1819
1820impl<'a> askama::filters::HtmlSafe for Toast<'a> {}
1821
1822#[derive(Debug, Template)]
1823#[non_exhaustive]
1824#[template(path = "components/toast_host.html")]
1825pub struct ToastHost<'a> {
1826    pub id: &'a str,
1827}
1828
1829impl<'a> ToastHost<'a> {
1830    pub const fn new() -> Self {
1831        Self { id: "toast-host" }
1832    }
1833
1834    pub const fn with_id(mut self, id: &'a str) -> Self {
1835        self.id = id;
1836        self
1837    }
1838}
1839
1840impl<'a> Default for ToastHost<'a> {
1841    fn default() -> Self {
1842        Self::new()
1843    }
1844}
1845
1846impl<'a> askama::filters::HtmlSafe for ToastHost<'a> {}
1847
1848#[derive(Debug, Template)]
1849#[non_exhaustive]
1850#[template(path = "components/tooltip.html")]
1851pub struct Tooltip<'a> {
1852    pub tip: &'a str,
1853    pub content_html: TrustedHtml<'a>,
1854}
1855
1856impl<'a> Tooltip<'a> {
1857    pub const fn new(tip: &'a str, content_html: TrustedHtml<'a>) -> Self {
1858        Self { tip, content_html }
1859    }
1860}
1861
1862impl<'a> askama::filters::HtmlSafe for Tooltip<'a> {}
1863
1864#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1865pub enum MenuItemKind {
1866    Button,
1867    Link,
1868    Separator,
1869}
1870
1871#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1872pub struct MenuItem<'a> {
1873    pub kind: MenuItemKind,
1874    pub label: &'a str,
1875    pub href: Option<&'a str>,
1876    pub danger: bool,
1877    pub disabled: bool,
1878    pub kbd: Option<&'a str>,
1879}
1880
1881impl<'a> MenuItem<'a> {
1882    pub const fn button(label: &'a str) -> Self {
1883        Self {
1884            kind: MenuItemKind::Button,
1885            label,
1886            href: None,
1887            danger: false,
1888            disabled: false,
1889            kbd: None,
1890        }
1891    }
1892
1893    pub const fn link(label: &'a str, href: &'a str) -> Self {
1894        Self {
1895            kind: MenuItemKind::Link,
1896            href: Some(href),
1897            ..Self::button(label)
1898        }
1899    }
1900
1901    pub const fn separator() -> Self {
1902        Self {
1903            kind: MenuItemKind::Separator,
1904            label: "",
1905            href: None,
1906            danger: false,
1907            disabled: false,
1908            kbd: None,
1909        }
1910    }
1911
1912    pub const fn danger(mut self) -> Self {
1913        self.danger = true;
1914        self
1915    }
1916
1917    pub const fn disabled(mut self) -> Self {
1918        self.disabled = true;
1919        self
1920    }
1921
1922    pub const fn with_kbd(mut self, kbd: &'a str) -> Self {
1923        self.kbd = Some(kbd);
1924        self
1925    }
1926
1927    pub fn class_name(&self) -> &'static str {
1928        if self.danger {
1929            "wf-menu-item danger"
1930        } else {
1931            "wf-menu-item"
1932        }
1933    }
1934}
1935
1936#[derive(Debug, Template)]
1937#[non_exhaustive]
1938#[template(path = "components/menu.html")]
1939pub struct Menu<'a> {
1940    pub items: &'a [MenuItem<'a>],
1941}
1942
1943impl<'a> Menu<'a> {
1944    pub const fn new(items: &'a [MenuItem<'a>]) -> Self {
1945        Self { items }
1946    }
1947}
1948
1949impl<'a> askama::filters::HtmlSafe for Menu<'a> {}
1950
1951#[derive(Debug, Template)]
1952#[non_exhaustive]
1953#[template(path = "components/popover.html")]
1954pub struct Popover<'a> {
1955    pub trigger_html: TrustedHtml<'a>,
1956    pub body_html: TrustedHtml<'a>,
1957    pub heading: Option<&'a str>,
1958    pub side: &'a str,
1959    pub open: bool,
1960}
1961
1962impl<'a> Popover<'a> {
1963    pub const fn new(trigger_html: TrustedHtml<'a>, body_html: TrustedHtml<'a>) -> Self {
1964        Self {
1965            trigger_html,
1966            body_html,
1967            heading: None,
1968            side: "bottom",
1969            open: false,
1970        }
1971    }
1972
1973    pub const fn with_heading(mut self, heading: &'a str) -> Self {
1974        self.heading = Some(heading);
1975        self
1976    }
1977
1978    pub const fn with_side(mut self, side: &'a str) -> Self {
1979        self.side = side;
1980        self
1981    }
1982
1983    pub const fn open(mut self) -> Self {
1984        self.open = true;
1985        self
1986    }
1987
1988    pub fn popover_class(&self) -> &'static str {
1989        if self.open {
1990            "wf-popover is-open"
1991        } else {
1992            "wf-popover"
1993        }
1994    }
1995}
1996
1997impl<'a> askama::filters::HtmlSafe for Popover<'a> {}
1998
1999#[derive(Debug, Template)]
2000#[non_exhaustive]
2001#[template(path = "components/modal.html")]
2002pub struct Modal<'a> {
2003    pub title: &'a str,
2004    pub body_html: TrustedHtml<'a>,
2005    pub footer_html: Option<TrustedHtml<'a>>,
2006    pub open: bool,
2007}
2008
2009impl<'a> Modal<'a> {
2010    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2011        Self {
2012            title,
2013            body_html,
2014            footer_html: None,
2015            open: false,
2016        }
2017    }
2018
2019    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
2020        self.footer_html = Some(footer_html);
2021        self
2022    }
2023
2024    pub const fn open(mut self) -> Self {
2025        self.open = true;
2026        self
2027    }
2028
2029    pub fn overlay_class(&self) -> &'static str {
2030        if self.open {
2031            "wf-overlay is-open"
2032        } else {
2033            "wf-overlay"
2034        }
2035    }
2036
2037    pub fn modal_class(&self) -> &'static str {
2038        if self.open {
2039            "wf-modal is-open"
2040        } else {
2041            "wf-modal"
2042        }
2043    }
2044}
2045
2046impl<'a> askama::filters::HtmlSafe for Modal<'a> {}
2047
2048#[derive(Debug, Template)]
2049#[non_exhaustive]
2050#[template(path = "components/drawer.html")]
2051pub struct Drawer<'a> {
2052    pub title: &'a str,
2053    pub body_html: TrustedHtml<'a>,
2054    pub footer_html: Option<TrustedHtml<'a>>,
2055    pub open: bool,
2056    pub left: bool,
2057}
2058
2059impl<'a> Drawer<'a> {
2060    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2061        Self {
2062            title,
2063            body_html,
2064            footer_html: None,
2065            open: false,
2066            left: false,
2067        }
2068    }
2069
2070    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
2071        self.footer_html = Some(footer_html);
2072        self
2073    }
2074
2075    pub const fn open(mut self) -> Self {
2076        self.open = true;
2077        self
2078    }
2079
2080    pub const fn left(mut self) -> Self {
2081        self.left = true;
2082        self
2083    }
2084
2085    pub fn overlay_class(&self) -> &'static str {
2086        if self.open {
2087            "wf-overlay is-open"
2088        } else {
2089            "wf-overlay"
2090        }
2091    }
2092
2093    pub fn drawer_class(&self) -> String {
2094        let open = if self.open { " is-open" } else { "" };
2095        let left = if self.left { " left" } else { "" };
2096        format!("wf-drawer{open}{left}")
2097    }
2098}
2099
2100impl<'a> askama::filters::HtmlSafe for Drawer<'a> {}
2101
2102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2103pub enum SkeletonKind {
2104    Line,
2105    Title,
2106    Block,
2107}
2108
2109impl SkeletonKind {
2110    fn class(self) -> &'static str {
2111        match self {
2112            Self::Line => "line",
2113            Self::Title => "title",
2114            Self::Block => "block",
2115        }
2116    }
2117}
2118
2119#[derive(Debug, Template)]
2120#[non_exhaustive]
2121#[template(path = "components/skeleton.html")]
2122pub struct Skeleton {
2123    pub kind: SkeletonKind,
2124}
2125
2126impl Skeleton {
2127    pub const fn line() -> Self {
2128        Self {
2129            kind: SkeletonKind::Line,
2130        }
2131    }
2132
2133    pub const fn title() -> Self {
2134        Self {
2135            kind: SkeletonKind::Title,
2136        }
2137    }
2138
2139    pub const fn block() -> Self {
2140        Self {
2141            kind: SkeletonKind::Block,
2142        }
2143    }
2144
2145    pub fn class_name(&self) -> String {
2146        format!("wf-skeleton {}", self.kind.class())
2147    }
2148}
2149
2150impl askama::filters::HtmlSafe for Skeleton {}
2151
2152#[derive(Debug, Template)]
2153#[non_exhaustive]
2154#[template(path = "components/spinner.html")]
2155pub struct Spinner {
2156    pub large: bool,
2157}
2158
2159impl Spinner {
2160    pub const fn new() -> Self {
2161        Self { large: false }
2162    }
2163
2164    pub const fn large() -> Self {
2165        Self { large: true }
2166    }
2167
2168    pub fn class_name(&self) -> &'static str {
2169        if self.large {
2170            "wf-spinner lg"
2171        } else {
2172            "wf-spinner"
2173        }
2174    }
2175}
2176
2177impl Default for Spinner {
2178    fn default() -> Self {
2179        Self::new()
2180    }
2181}
2182
2183impl askama::filters::HtmlSafe for Spinner {}
2184
2185#[derive(Debug, Template)]
2186#[non_exhaustive]
2187#[template(path = "components/minibuffer.html")]
2188pub struct Minibuffer<'a> {
2189    pub prompt: &'a str,
2190    pub message: Option<&'a str>,
2191    pub kind: Option<FeedbackKind>,
2192    pub time: Option<&'a str>,
2193}
2194
2195impl<'a> Minibuffer<'a> {
2196    pub const fn new() -> Self {
2197        Self {
2198            prompt: ">",
2199            message: None,
2200            kind: None,
2201            time: None,
2202        }
2203    }
2204
2205    pub const fn with_prompt(mut self, prompt: &'a str) -> Self {
2206        self.prompt = prompt;
2207        self
2208    }
2209
2210    pub const fn with_message(mut self, kind: FeedbackKind, message: &'a str) -> Self {
2211        self.kind = Some(kind);
2212        self.message = Some(message);
2213        self
2214    }
2215
2216    pub const fn with_time(mut self, time: &'a str) -> Self {
2217        self.time = Some(time);
2218        self
2219    }
2220
2221    pub fn message_class(&self) -> String {
2222        match self.kind {
2223            Some(kind) if self.message.is_some() => {
2224                format!("wf-minibuffer-msg is-visible is-{}", kind.class())
2225            }
2226            _ => "wf-minibuffer-msg".to_owned(),
2227        }
2228    }
2229}
2230
2231impl<'a> Default for Minibuffer<'a> {
2232    fn default() -> Self {
2233        Self::new()
2234    }
2235}
2236
2237impl<'a> askama::filters::HtmlSafe for Minibuffer<'a> {}
2238
2239#[cfg(test)]
2240mod tests {
2241    use super::*;
2242
2243    #[test]
2244    fn renders_button_with_htmx_attrs() {
2245        let attrs = [HtmlAttr::hx_post("/save?next=<home>")];
2246        let html = Button::primary("Save").with_attrs(&attrs).render().unwrap();
2247
2248        assert!(html.contains(r#"class="wf-btn primary""#));
2249        assert!(html.contains(r#"hx-post="/save?next="#));
2250        assert!(!html.contains(r#"hx-post="/save?next=<home>""#));
2251    }
2252
2253    #[test]
2254    fn field_escapes_copy_and_renders_trusted_control_html() {
2255        let html = Field::new(
2256            "Email <required>",
2257            TrustedHtml::new(r#"<input class="wf-input" name="email">"#),
2258        )
2259        .with_hint("Use <work> address")
2260        .render()
2261        .unwrap();
2262
2263        assert!(html.contains("Email"));
2264        assert!(!html.contains("Email <required>"));
2265        assert!(html.contains(r#"<input class="wf-input" name="email">"#));
2266        assert!(html.contains("Use"));
2267        assert!(!html.contains("Use <work> address"));
2268    }
2269
2270    #[test]
2271    fn trusted_html_writes_without_formatter_allocation() {
2272        let mut html = String::new();
2273
2274        askama::FastWritable::write_into(
2275            &TrustedHtml::new("<strong>Ready</strong>"),
2276            &mut html,
2277            askama::NO_VALUES,
2278        )
2279        .unwrap();
2280
2281        assert_eq!(html, "<strong>Ready</strong>");
2282    }
2283
2284    #[derive(Template)]
2285    #[template(source = "{{ button }}", ext = "html")]
2286    struct NestedButton<'a> {
2287        button: Button<'a>,
2288    }
2289
2290    #[test]
2291    fn nested_components_render_as_html() {
2292        let html = NestedButton {
2293            button: Button::primary("Save"),
2294        }
2295        .render()
2296        .unwrap();
2297
2298        assert!(html.contains("<button"));
2299        assert!(!html.contains("&lt;button"));
2300    }
2301
2302    #[test]
2303    fn action_primitives_render_wave_funk_markup() {
2304        let attrs = [HtmlAttr::hx_post("/actions/archive")];
2305        let buttons = [
2306            Button::new("Left"),
2307            Button::primary("Archive").with_attrs(&attrs),
2308        ];
2309
2310        let group_html = ButtonGroup::new(&buttons).render().unwrap();
2311        let split_html = SplitButton::new(Button::primary("Run"), Button::new("More"))
2312            .render()
2313            .unwrap();
2314        let icon_html = IconButton::new(TrustedHtml::new("&times;"), "Close")
2315            .with_variant(ButtonVariant::Ghost)
2316            .render()
2317            .unwrap();
2318
2319        assert!(group_html.contains(r#"class="wf-btn-group""#));
2320        assert!(group_html.contains(r#"hx-post="/actions/archive""#));
2321        assert!(split_html.contains(r#"class="wf-btn-split""#));
2322        assert!(split_html.contains(r#"class="wf-btn caret""#));
2323        assert!(icon_html.contains(r#"class="wf-icon-btn ghost""#));
2324        assert!(icon_html.contains(r#"aria-label="Close""#));
2325        assert!(icon_html.contains("&times;"));
2326    }
2327
2328    #[test]
2329    fn text_form_primitives_escape_copy_and_attrs() {
2330        let attrs = [HtmlAttr::hx_get("/validate/email")];
2331        let input_html = Input::email("email")
2332            .with_value("sandeep<wavefunk>")
2333            .with_placeholder("Email <address>")
2334            .with_attrs(&attrs)
2335            .render()
2336            .unwrap();
2337        let textarea_html = Textarea::new("notes")
2338            .with_value("Hello <team>")
2339            .with_placeholder("Notes <optional>")
2340            .render()
2341            .unwrap();
2342        let options = [
2343            SelectOption::new("starter", "Starter"),
2344            SelectOption::new("pro", "Pro <team>").selected(),
2345        ];
2346        let select_html = Select::new("plan", &options).render().unwrap();
2347
2348        assert!(input_html.contains(r#"class="wf-input""#));
2349        assert!(input_html.contains(r#"type="email""#));
2350        assert!(input_html.contains(r#"hx-get="/validate/email""#));
2351        assert!(!input_html.contains("sandeep<wavefunk>"));
2352        assert!(!input_html.contains("Email <address>"));
2353        assert!(textarea_html.contains(r#"class="wf-textarea""#));
2354        assert!(!textarea_html.contains("Hello <team>"));
2355        assert!(select_html.contains(r#"class="wf-select""#));
2356        assert!(select_html.contains(r#"value="pro" selected"#));
2357        assert!(!select_html.contains("Pro <team>"));
2358    }
2359
2360    #[test]
2361    fn grouped_choice_and_range_primitives_render_expected_classes() {
2362        let input_html = Input::url("site_url").render().unwrap();
2363        let group_html = InputGroup::new(TrustedHtml::new(&input_html))
2364            .with_prefix("https://")
2365            .with_suffix(".wavefunk.test")
2366            .render()
2367            .unwrap();
2368        let checkbox_html = CheckRow::checkbox("terms", "yes", "Accept <terms>")
2369            .checked()
2370            .render()
2371            .unwrap();
2372        let radio_html = CheckRow::radio("plan", "pro", "Pro").render().unwrap();
2373        let switch_html = Switch::new("enabled").checked().render().unwrap();
2374        let range_html = Range::new("volume")
2375            .with_bounds("0", "100")
2376            .with_value("50")
2377            .render()
2378            .unwrap();
2379        let field_html = Field::new("URL", TrustedHtml::new(&group_html))
2380            .with_state(FieldState::Success)
2381            .render()
2382            .unwrap();
2383
2384        assert!(group_html.contains(r#"class="wf-input-group""#));
2385        assert!(group_html.contains(r#"class="wf-input-addon">https://"#));
2386        assert!(checkbox_html.contains(r#"class="wf-check-row""#));
2387        assert!(checkbox_html.contains(r#"type="checkbox""#));
2388        assert!(checkbox_html.contains("checked"));
2389        assert!(!checkbox_html.contains("Accept <terms>"));
2390        assert!(radio_html.contains(r#"type="radio""#));
2391        assert!(switch_html.contains(r#"class="wf-switch""#));
2392        assert!(switch_html.contains("checked"));
2393        assert!(range_html.contains(r#"class="wf-range""#));
2394        assert!(range_html.contains(r#"min="0""#));
2395        assert!(field_html.contains(r#"class="wf-field is-success""#));
2396    }
2397
2398    #[test]
2399    fn layout_navigation_and_data_primitives_render_expected_markup() {
2400        let panel = Panel::new("Deployments", TrustedHtml::new("<p>Ready</p>"))
2401            .with_action(TrustedHtml::new(
2402                r#"<a class="wf-panel-link" href="/all">All</a>"#,
2403            ))
2404            .render()
2405            .unwrap();
2406        let card = Card::new("Project <alpha>", TrustedHtml::new("<p>Live</p>"))
2407            .with_kicker("Status")
2408            .raised()
2409            .render()
2410            .unwrap();
2411        let stats = [Stat::new("Requests", "42").with_unit("rpm")];
2412        let stat_row = StatRow::new(&stats).render().unwrap();
2413        let badge = Badge::muted("beta").render().unwrap();
2414        let avatar = Avatar::new("SN").accent().render().unwrap();
2415        let crumbs = [
2416            BreadcrumbItem::link("Projects", "/projects"),
2417            BreadcrumbItem::current("Wavefunk <UI>"),
2418        ];
2419        let breadcrumbs = Breadcrumbs::new(&crumbs).render().unwrap();
2420        let tabs = [
2421            TabItem::link("Overview", "/").active(),
2422            TabItem::link("Settings", "/settings"),
2423        ];
2424        let tab_html = Tabs::new(&tabs).render().unwrap();
2425        let segments = [
2426            SegmentOption::new("List", "list").active(),
2427            SegmentOption::new("Grid", "grid"),
2428        ];
2429        let seg_html = SegmentedControl::new(&segments).render().unwrap();
2430        let pages = [
2431            PageLink::link("1", "/page/1").active(),
2432            PageLink::ellipsis(),
2433            PageLink::disabled("Next"),
2434        ];
2435        let pagination = Pagination::new(&pages).render().unwrap();
2436        let nav_section = NavSection::new("Workspace").render().unwrap();
2437        let nav_item = NavItem::new("Dashboard", "/").active().with_count("3");
2438        let topbar = Topbar::new(TrustedHtml::new(&breadcrumbs), TrustedHtml::new(&badge))
2439            .render()
2440            .unwrap();
2441        let statusbar = Statusbar::new("Connected", "v0.1").render().unwrap();
2442        let empty = EmptyState::new("No hooks", "Create a hook to start.")
2443            .with_glyph(TrustedHtml::new("&empty;"))
2444            .bordered()
2445            .render()
2446            .unwrap();
2447        let table_headers = [TableHeader::new("Name"), TableHeader::numeric("Runs")];
2448        let table_cells = [TableCell::strong("Build <main>"), TableCell::numeric("12")];
2449        let table_rows = [TableRow::new(&table_cells).selected()];
2450        let table = Table::new(&table_headers, &table_rows)
2451            .interactive()
2452            .render()
2453            .unwrap();
2454        let dl_items = [DefinitionItem::new("Runtime", "Rust <stable>")];
2455        let dl = DefinitionList::new(&dl_items).render().unwrap();
2456        let grid = Grid::new(TrustedHtml::new(&card))
2457            .with_columns(2)
2458            .render()
2459            .unwrap();
2460        let split = Split::new(TrustedHtml::new(&panel))
2461            .vertical()
2462            .render()
2463            .unwrap();
2464
2465        assert!(panel.contains(r#"class="wf-panel""#));
2466        assert!(card.contains(r#"class="wf-card is-raised""#));
2467        assert!(!card.contains("Project <alpha>"));
2468        assert!(stat_row.contains(r#"class="wf-stat-row""#));
2469        assert!(badge.contains(r#"class="wf-badge muted""#));
2470        assert!(avatar.contains(r#"class="wf-avatar accent""#));
2471        assert!(breadcrumbs.contains(r#"class="wf-crumbs""#));
2472        assert!(!breadcrumbs.contains("Wavefunk <UI>"));
2473        assert!(tab_html.contains(r#"class="wf-tabs""#));
2474        assert!(seg_html.contains(r#"class="wf-seg""#));
2475        assert!(pagination.contains(r#"class="wf-pagination""#));
2476        assert!(nav_section.contains(r#"class="wf-nav-section""#));
2477        assert!(
2478            nav_item
2479                .render()
2480                .unwrap()
2481                .contains(r#"class="wf-nav-item is-active""#)
2482        );
2483        assert!(topbar.contains(r#"class="wf-topbar""#));
2484        assert!(statusbar.contains(r#"class="wf-statusbar wf-hair""#));
2485        assert!(empty.contains(r#"class="wf-empty bordered""#));
2486        assert!(table.contains(r#"class="wf-table is-interactive""#));
2487        assert!(!table.contains("Build <main>"));
2488        assert!(dl.contains(r#"class="wf-dl""#));
2489        assert!(!dl.contains("Rust <stable>"));
2490        assert!(grid.contains(r#"class="wf-grid cols-2""#));
2491        assert!(split.contains(r#"class="wf-split vertical""#));
2492    }
2493
2494    #[test]
2495    fn feedback_overlay_and_loading_primitives_render_expected_markup() {
2496        let callout = Callout::new(FeedbackKind::Warn, TrustedHtml::new("<p>Heads up</p>"))
2497            .with_title("Warning")
2498            .render()
2499            .unwrap();
2500        let toast = Toast::new(FeedbackKind::Ok, "Saved <now>")
2501            .render()
2502            .unwrap();
2503        let toast_host = ToastHost::new().render().unwrap();
2504        let tooltip = Tooltip::new("Copy id", TrustedHtml::new(r#"<button>copy</button>"#))
2505            .render()
2506            .unwrap();
2507        let menu_items = [
2508            MenuItem::button("Open"),
2509            MenuItem::link("Settings", "/settings"),
2510            MenuItem::separator(),
2511            MenuItem::button("Delete").danger(),
2512        ];
2513        let menu = Menu::new(&menu_items).render().unwrap();
2514        let popover = Popover::new(
2515            TrustedHtml::new(r#"<button data-popover-toggle>Open</button>"#),
2516            TrustedHtml::new(&menu),
2517        )
2518        .with_heading("Menu")
2519        .open()
2520        .render()
2521        .unwrap();
2522        let modal = Modal::new("Confirm", TrustedHtml::new("<p>Continue?</p>"))
2523            .with_footer(TrustedHtml::new(
2524                r#"<button class="wf-btn primary">Confirm</button>"#,
2525            ))
2526            .open()
2527            .render()
2528            .unwrap();
2529        let drawer = Drawer::new("Details", TrustedHtml::new("<p>Side sheet</p>"))
2530            .left()
2531            .open()
2532            .render()
2533            .unwrap();
2534        let skeleton = Skeleton::title().render().unwrap();
2535        let spinner = Spinner::large().render().unwrap();
2536        let minibuffer = Minibuffer::new()
2537            .with_message(FeedbackKind::Info, "Queued <job>")
2538            .with_time("09:41")
2539            .render()
2540            .unwrap();
2541
2542        assert!(callout.contains(r#"class="wf-callout warn""#));
2543        assert!(toast.contains(r#"class="wf-toast ok""#));
2544        assert!(!toast.contains("Saved <now>"));
2545        assert!(toast_host.contains(r#"class="wf-toast-host""#));
2546        assert!(tooltip.contains(r#"class="wf-tooltip""#));
2547        assert!(tooltip.contains(r#"data-tip="Copy id""#));
2548        assert!(menu.contains(r#"class="wf-menu""#));
2549        assert!(menu.contains(r#"class="wf-menu-sep""#));
2550        assert!(popover.contains(r#"class="wf-popover is-open""#));
2551        assert!(modal.contains(r#"class="wf-modal is-open""#));
2552        assert!(modal.contains(r#"class="wf-overlay is-open""#));
2553        assert!(drawer.contains(r#"class="wf-drawer is-open left""#));
2554        assert!(skeleton.contains(r#"class="wf-skeleton title""#));
2555        assert!(spinner.contains(r#"class="wf-spinner lg""#));
2556        assert!(minibuffer.contains(r#"class="wf-minibuffer""#));
2557        assert!(minibuffer.contains("data-wf-echo"));
2558        assert!(!minibuffer.contains("Queued <job>"));
2559    }
2560}