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("<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("×"), "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("×"));
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("∅"))
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}