Skip to main content

wavefunk_ui/
components.rs

1use crate::assets;
2use askama::Template;
3use std::fmt::{self, Write as _};
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub struct HtmlAttr<'a> {
7    pub name: &'a str,
8    pub value: &'a str,
9}
10
11impl<'a> HtmlAttr<'a> {
12    pub const fn new(name: &'a str, value: &'a str) -> Self {
13        Self { name, value }
14    }
15
16    pub const fn hx_get(value: &'a str) -> Self {
17        Self::new("hx-get", value)
18    }
19
20    pub const fn hx_post(value: &'a str) -> Self {
21        Self::new("hx-post", value)
22    }
23
24    pub const fn hx_put(value: &'a str) -> Self {
25        Self::new("hx-put", value)
26    }
27
28    pub const fn hx_patch(value: &'a str) -> Self {
29        Self::new("hx-patch", value)
30    }
31
32    pub const fn hx_delete(value: &'a str) -> Self {
33        Self::new("hx-delete", value)
34    }
35
36    pub const fn hx_target(value: &'a str) -> Self {
37        Self::new("hx-target", value)
38    }
39
40    pub const fn hx_swap(value: &'a str) -> Self {
41        Self::new("hx-swap", value)
42    }
43
44    pub const fn hx_trigger(value: &'a str) -> Self {
45        Self::new("hx-trigger", value)
46    }
47
48    pub const fn hx_confirm(value: &'a str) -> Self {
49        Self::new("hx-confirm", value)
50    }
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub struct TrustedHtml<'a> {
55    html: &'a str,
56}
57
58impl<'a> TrustedHtml<'a> {
59    pub const fn new(html: &'a str) -> Self {
60        Self { html }
61    }
62
63    pub const fn as_str(self) -> &'a str {
64        self.html
65    }
66}
67
68impl fmt::Display for TrustedHtml<'_> {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.html)
71    }
72}
73
74impl askama::FastWritable for TrustedHtml<'_> {
75    #[inline]
76    fn write_into(&self, dest: &mut dyn fmt::Write, _: &dyn askama::Values) -> askama::Result<()> {
77        Ok(dest.write_str(self.html)?)
78    }
79}
80
81impl askama::filters::HtmlSafe for TrustedHtml<'_> {}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum ButtonVariant {
85    Default,
86    Primary,
87    Ghost,
88    Danger,
89}
90
91impl ButtonVariant {
92    fn class(self) -> &'static str {
93        match self {
94            Self::Default => "",
95            Self::Primary => " primary",
96            Self::Ghost => " ghost",
97            Self::Danger => " danger",
98        }
99    }
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub enum ButtonSize {
104    Default,
105    Small,
106    Large,
107}
108
109impl ButtonSize {
110    fn class(self) -> &'static str {
111        match self {
112            Self::Default => "",
113            Self::Small => " sm",
114            Self::Large => " lg",
115        }
116    }
117}
118
119#[derive(Debug, Template)]
120#[non_exhaustive]
121#[template(path = "components/button.html")]
122pub struct Button<'a> {
123    pub label: &'a str,
124    pub href: Option<&'a str>,
125    pub variant: ButtonVariant,
126    pub size: ButtonSize,
127    pub attrs: &'a [HtmlAttr<'a>],
128    pub disabled: bool,
129    pub button_type: &'a str,
130}
131
132impl<'a> Button<'a> {
133    pub const fn new(label: &'a str) -> Self {
134        Self {
135            label,
136            href: None,
137            variant: ButtonVariant::Default,
138            size: ButtonSize::Default,
139            attrs: &[],
140            disabled: false,
141            button_type: "button",
142        }
143    }
144
145    pub const fn primary(label: &'a str) -> Self {
146        Self {
147            variant: ButtonVariant::Primary,
148            ..Self::new(label)
149        }
150    }
151
152    pub const fn link(label: &'a str, href: &'a str) -> Self {
153        Self {
154            href: Some(href),
155            ..Self::new(label)
156        }
157    }
158
159    pub const fn with_href(mut self, href: &'a str) -> Self {
160        self.href = Some(href);
161        self
162    }
163
164    pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
165        self.variant = variant;
166        self
167    }
168
169    pub const fn with_size(mut self, size: ButtonSize) -> Self {
170        self.size = size;
171        self
172    }
173
174    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
175        self.attrs = attrs;
176        self
177    }
178
179    pub const fn disabled(mut self) -> Self {
180        self.disabled = true;
181        self
182    }
183
184    pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
185        self.button_type = button_type;
186        self
187    }
188
189    pub fn class_name(&self) -> String {
190        format!("wf-btn{}{}", self.variant.class(), self.size.class())
191    }
192}
193
194impl<'a> askama::filters::HtmlSafe for Button<'a> {}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub enum FeedbackKind {
198    Info,
199    Ok,
200    Warn,
201    Error,
202}
203
204impl FeedbackKind {
205    fn class(self) -> &'static str {
206        match self {
207            Self::Info => "info",
208            Self::Ok => "ok",
209            Self::Warn => "warn",
210            Self::Error => "err",
211        }
212    }
213}
214
215#[derive(Debug, Template)]
216#[non_exhaustive]
217#[template(path = "components/alert.html")]
218pub struct Alert<'a> {
219    pub kind: FeedbackKind,
220    pub title: Option<&'a str>,
221    pub message: &'a str,
222}
223
224impl<'a> Alert<'a> {
225    pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
226        Self {
227            kind,
228            title: None,
229            message,
230        }
231    }
232
233    pub const fn with_title(mut self, title: &'a str) -> Self {
234        self.title = Some(title);
235        self
236    }
237
238    pub fn class_name(&self) -> String {
239        format!("wf-alert {}", self.kind.class())
240    }
241}
242
243impl<'a> askama::filters::HtmlSafe for Alert<'a> {}
244
245#[derive(Debug, Template)]
246#[non_exhaustive]
247#[template(path = "components/tag.html")]
248pub struct Tag<'a> {
249    pub kind: Option<FeedbackKind>,
250    pub label: &'a str,
251    pub dot: bool,
252}
253
254impl<'a> Tag<'a> {
255    pub const fn new(label: &'a str) -> Self {
256        Self {
257            kind: None,
258            label,
259            dot: false,
260        }
261    }
262
263    pub const fn status(kind: FeedbackKind, label: &'a str) -> Self {
264        Self {
265            kind: Some(kind),
266            label,
267            dot: true,
268        }
269    }
270
271    pub const fn with_kind(mut self, kind: FeedbackKind) -> Self {
272        self.kind = Some(kind);
273        self
274    }
275
276    pub const fn with_dot(mut self) -> Self {
277        self.dot = true;
278        self
279    }
280
281    pub fn class_name(&self) -> String {
282        match self.kind {
283            Some(kind) => format!("wf-tag {}", kind.class()),
284            None => "wf-tag".to_owned(),
285        }
286    }
287}
288
289impl<'a> askama::filters::HtmlSafe for Tag<'a> {}
290
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292pub enum FieldState {
293    Default,
294    Error,
295    Success,
296}
297
298impl FieldState {
299    fn class(self) -> &'static str {
300        match self {
301            Self::Default => "",
302            Self::Error => " is-error",
303            Self::Success => " is-success",
304        }
305    }
306}
307
308#[derive(Debug, Template)]
309#[non_exhaustive]
310#[template(path = "components/field.html")]
311pub struct Field<'a> {
312    pub label: &'a str,
313    pub control_html: TrustedHtml<'a>,
314    pub hint: Option<&'a str>,
315    pub state: FieldState,
316}
317
318impl<'a> Field<'a> {
319    pub const fn new(label: &'a str, control_html: TrustedHtml<'a>) -> Self {
320        Self {
321            label,
322            control_html,
323            hint: None,
324            state: FieldState::Default,
325        }
326    }
327
328    pub const fn with_hint(mut self, hint: &'a str) -> Self {
329        self.hint = Some(hint);
330        self
331    }
332
333    pub const fn with_state(mut self, state: FieldState) -> Self {
334        self.state = state;
335        self
336    }
337
338    pub fn class_name(&self) -> String {
339        format!("wf-field{}", self.state.class())
340    }
341}
342
343impl<'a> askama::filters::HtmlSafe for Field<'a> {}
344
345#[derive(Debug, Template)]
346#[non_exhaustive]
347#[template(path = "components/form.html")]
348pub struct Form<'a> {
349    pub body_html: TrustedHtml<'a>,
350    pub action: Option<&'a str>,
351    pub method: &'a str,
352    pub enctype: Option<&'a str>,
353    pub attrs: &'a [HtmlAttr<'a>],
354}
355
356impl<'a> Form<'a> {
357    pub const fn new(body_html: TrustedHtml<'a>) -> Self {
358        Self {
359            body_html,
360            action: None,
361            method: "post",
362            enctype: None,
363            attrs: &[],
364        }
365    }
366
367    pub const fn with_action(mut self, action: &'a str) -> Self {
368        self.action = Some(action);
369        self
370    }
371
372    pub const fn with_method(mut self, method: &'a str) -> Self {
373        self.method = method;
374        self
375    }
376
377    pub const fn with_enctype(mut self, enctype: &'a str) -> Self {
378        self.enctype = Some(enctype);
379        self
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 Form<'a> {}
389
390#[derive(Debug, Template)]
391#[non_exhaustive]
392#[template(path = "components/form_section.html")]
393pub struct FormSection<'a> {
394    pub title: &'a str,
395    pub body_html: TrustedHtml<'a>,
396    pub description: Option<&'a str>,
397    pub actions_html: Option<TrustedHtml<'a>>,
398}
399
400impl<'a> FormSection<'a> {
401    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
402        Self {
403            title,
404            body_html,
405            description: None,
406            actions_html: None,
407        }
408    }
409
410    pub const fn with_description(mut self, description: &'a str) -> Self {
411        self.description = Some(description);
412        self
413    }
414
415    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
416        self.actions_html = Some(actions_html);
417        self
418    }
419}
420
421impl<'a> askama::filters::HtmlSafe for FormSection<'a> {}
422
423#[derive(Debug, Template)]
424#[non_exhaustive]
425#[template(path = "components/form_actions.html")]
426pub struct FormActions<'a> {
427    pub primary_html: TrustedHtml<'a>,
428    pub secondary_html: Option<TrustedHtml<'a>>,
429}
430
431impl<'a> FormActions<'a> {
432    pub const fn new(primary_html: TrustedHtml<'a>) -> Self {
433        Self {
434            primary_html,
435            secondary_html: None,
436        }
437    }
438
439    pub const fn with_secondary(mut self, secondary_html: TrustedHtml<'a>) -> Self {
440        self.secondary_html = Some(secondary_html);
441        self
442    }
443}
444
445impl<'a> askama::filters::HtmlSafe for FormActions<'a> {}
446
447#[derive(Debug, Template)]
448#[non_exhaustive]
449#[template(path = "components/object_fieldset.html")]
450pub struct ObjectFieldset<'a> {
451    pub legend: &'a str,
452    pub body_html: TrustedHtml<'a>,
453    pub description: Option<&'a str>,
454    pub actions_html: Option<TrustedHtml<'a>>,
455}
456
457impl<'a> ObjectFieldset<'a> {
458    pub const fn new(legend: &'a str, body_html: TrustedHtml<'a>) -> Self {
459        Self {
460            legend,
461            body_html,
462            description: None,
463            actions_html: None,
464        }
465    }
466
467    pub const fn with_description(mut self, description: &'a str) -> Self {
468        self.description = Some(description);
469        self
470    }
471
472    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
473        self.actions_html = Some(actions_html);
474        self
475    }
476}
477
478impl<'a> askama::filters::HtmlSafe for ObjectFieldset<'a> {}
479
480#[derive(Debug, Template)]
481#[non_exhaustive]
482#[template(path = "components/repeatable_array.html")]
483pub struct RepeatableArray<'a> {
484    pub label: &'a str,
485    pub items_html: TrustedHtml<'a>,
486    pub description: Option<&'a str>,
487    pub action_html: Option<TrustedHtml<'a>>,
488}
489
490impl<'a> RepeatableArray<'a> {
491    pub const fn new(label: &'a str, items_html: TrustedHtml<'a>) -> Self {
492        Self {
493            label,
494            items_html,
495            description: None,
496            action_html: None,
497        }
498    }
499
500    pub const fn with_description(mut self, description: &'a str) -> Self {
501        self.description = Some(description);
502        self
503    }
504
505    pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
506        self.action_html = Some(action_html);
507        self
508    }
509}
510
511impl<'a> askama::filters::HtmlSafe for RepeatableArray<'a> {}
512
513#[derive(Debug, Template)]
514#[non_exhaustive]
515#[template(path = "components/repeatable_item.html")]
516pub struct RepeatableItem<'a> {
517    pub label: &'a str,
518    pub body_html: TrustedHtml<'a>,
519    pub actions_html: Option<TrustedHtml<'a>>,
520}
521
522impl<'a> RepeatableItem<'a> {
523    pub const fn new(label: &'a str, body_html: TrustedHtml<'a>) -> Self {
524        Self {
525            label,
526            body_html,
527            actions_html: None,
528        }
529    }
530
531    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
532        self.actions_html = Some(actions_html);
533        self
534    }
535}
536
537impl<'a> askama::filters::HtmlSafe for RepeatableItem<'a> {}
538
539#[derive(Debug, Template)]
540#[non_exhaustive]
541#[template(path = "components/current_upload.html")]
542pub struct CurrentUpload<'a> {
543    pub label: &'a str,
544    pub href: &'a str,
545    pub filename: &'a str,
546    pub meta: Option<&'a str>,
547    pub thumbnail_html: Option<TrustedHtml<'a>>,
548    pub actions_html: Option<TrustedHtml<'a>>,
549}
550
551impl<'a> CurrentUpload<'a> {
552    pub const fn new(label: &'a str, href: &'a str, filename: &'a str) -> Self {
553        Self {
554            label,
555            href,
556            filename,
557            meta: None,
558            thumbnail_html: None,
559            actions_html: None,
560        }
561    }
562
563    pub const fn with_meta(mut self, meta: &'a str) -> Self {
564        self.meta = Some(meta);
565        self
566    }
567
568    pub const fn with_thumbnail(mut self, thumbnail_html: TrustedHtml<'a>) -> Self {
569        self.thumbnail_html = Some(thumbnail_html);
570        self
571    }
572
573    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
574        self.actions_html = Some(actions_html);
575        self
576    }
577}
578
579impl<'a> askama::filters::HtmlSafe for CurrentUpload<'a> {}
580
581#[derive(Debug, Template)]
582#[non_exhaustive]
583#[template(path = "components/reference_select.html")]
584pub struct ReferenceSelect<'a> {
585    pub label: &'a str,
586    pub select_html: TrustedHtml<'a>,
587    pub hint: Option<&'a str>,
588}
589
590impl<'a> ReferenceSelect<'a> {
591    pub const fn new(label: &'a str, select_html: TrustedHtml<'a>) -> Self {
592        Self {
593            label,
594            select_html,
595            hint: None,
596        }
597    }
598
599    pub const fn with_hint(mut self, hint: &'a str) -> Self {
600        self.hint = Some(hint);
601        self
602    }
603}
604
605impl<'a> askama::filters::HtmlSafe for ReferenceSelect<'a> {}
606
607#[derive(Debug, Template)]
608#[non_exhaustive]
609#[template(path = "components/markdown_textarea.html")]
610pub struct MarkdownTextarea<'a> {
611    pub name: &'a str,
612    pub value: Option<&'a str>,
613    pub placeholder: Option<&'a str>,
614    pub rows: u16,
615    pub attrs: &'a [HtmlAttr<'a>],
616}
617
618impl<'a> MarkdownTextarea<'a> {
619    pub const fn new(name: &'a str) -> Self {
620        Self {
621            name,
622            value: None,
623            placeholder: None,
624            rows: 6,
625            attrs: &[],
626        }
627    }
628
629    pub const fn with_value(mut self, value: &'a str) -> Self {
630        self.value = Some(value);
631        self
632    }
633
634    pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
635        self.placeholder = Some(placeholder);
636        self
637    }
638
639    pub const fn with_rows(mut self, rows: u16) -> Self {
640        self.rows = rows;
641        self
642    }
643
644    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
645        self.attrs = attrs;
646        self
647    }
648}
649
650impl<'a> askama::filters::HtmlSafe for MarkdownTextarea<'a> {}
651
652#[derive(Debug, Template)]
653#[non_exhaustive]
654#[template(path = "components/rich_text_host.html")]
655pub struct RichTextHost<'a> {
656    pub id: &'a str,
657    pub name: &'a str,
658    pub value: Option<&'a str>,
659    pub toolbar_html: Option<TrustedHtml<'a>>,
660    pub body_html: Option<TrustedHtml<'a>>,
661}
662
663impl<'a> RichTextHost<'a> {
664    pub const fn new(id: &'a str, name: &'a str) -> Self {
665        Self {
666            id,
667            name,
668            value: None,
669            toolbar_html: None,
670            body_html: None,
671        }
672    }
673
674    pub const fn with_value(mut self, value: &'a str) -> Self {
675        self.value = Some(value);
676        self
677    }
678
679    pub const fn with_toolbar(mut self, toolbar_html: TrustedHtml<'a>) -> Self {
680        self.toolbar_html = Some(toolbar_html);
681        self
682    }
683
684    pub const fn with_body(mut self, body_html: TrustedHtml<'a>) -> Self {
685        self.body_html = Some(body_html);
686        self
687    }
688}
689
690impl<'a> askama::filters::HtmlSafe for RichTextHost<'a> {}
691
692#[derive(Debug, Template)]
693#[non_exhaustive]
694#[template(path = "components/button_group.html")]
695pub struct ButtonGroup<'a> {
696    pub buttons: &'a [Button<'a>],
697    pub attrs: &'a [HtmlAttr<'a>],
698}
699
700impl<'a> ButtonGroup<'a> {
701    pub const fn new(buttons: &'a [Button<'a>]) -> Self {
702        Self {
703            buttons,
704            attrs: &[],
705        }
706    }
707
708    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
709        self.attrs = attrs;
710        self
711    }
712}
713
714impl<'a> askama::filters::HtmlSafe for ButtonGroup<'a> {}
715
716#[derive(Debug, Template)]
717#[non_exhaustive]
718#[template(path = "components/split_button.html")]
719pub struct SplitButton<'a> {
720    pub action: Button<'a>,
721    pub menu: Button<'a>,
722    pub attrs: &'a [HtmlAttr<'a>],
723}
724
725impl<'a> SplitButton<'a> {
726    pub const fn new(action: Button<'a>, menu: Button<'a>) -> Self {
727        Self {
728            action,
729            menu,
730            attrs: &[],
731        }
732    }
733
734    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
735        self.attrs = attrs;
736        self
737    }
738}
739
740impl<'a> askama::filters::HtmlSafe for SplitButton<'a> {}
741
742#[derive(Debug, Template)]
743#[non_exhaustive]
744#[template(path = "components/icon_button.html")]
745pub struct IconButton<'a> {
746    pub icon: TrustedHtml<'a>,
747    pub label: &'a str,
748    pub href: Option<&'a str>,
749    pub variant: ButtonVariant,
750    pub attrs: &'a [HtmlAttr<'a>],
751    pub disabled: bool,
752    pub button_type: &'a str,
753}
754
755impl<'a> IconButton<'a> {
756    pub const fn new(icon: TrustedHtml<'a>, label: &'a str) -> Self {
757        Self {
758            icon,
759            label,
760            href: None,
761            variant: ButtonVariant::Default,
762            attrs: &[],
763            disabled: false,
764            button_type: "button",
765        }
766    }
767
768    pub const fn with_href(mut self, href: &'a str) -> Self {
769        self.href = Some(href);
770        self
771    }
772
773    pub const fn with_variant(mut self, variant: ButtonVariant) -> Self {
774        self.variant = variant;
775        self
776    }
777
778    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
779        self.attrs = attrs;
780        self
781    }
782
783    pub const fn disabled(mut self) -> Self {
784        self.disabled = true;
785        self
786    }
787
788    pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
789        self.button_type = button_type;
790        self
791    }
792
793    pub fn class_name(&self) -> String {
794        format!("wf-icon-btn{}", self.variant.class())
795    }
796}
797
798impl<'a> askama::filters::HtmlSafe for IconButton<'a> {}
799
800#[derive(Clone, Copy, Debug, Eq, PartialEq)]
801pub enum ControlSize {
802    Default,
803    Small,
804}
805
806impl ControlSize {
807    fn class(self) -> &'static str {
808        match self {
809            Self::Default => "",
810            Self::Small => " sm",
811        }
812    }
813}
814
815#[derive(Debug, Template)]
816#[non_exhaustive]
817#[template(path = "components/input.html")]
818pub struct Input<'a> {
819    pub name: &'a str,
820    pub input_type: &'a str,
821    pub value: Option<&'a str>,
822    pub placeholder: Option<&'a str>,
823    pub size: ControlSize,
824    pub attrs: &'a [HtmlAttr<'a>],
825    pub disabled: bool,
826    pub required: bool,
827}
828
829impl<'a> Input<'a> {
830    pub const fn new(name: &'a str) -> Self {
831        Self {
832            name,
833            input_type: "text",
834            value: None,
835            placeholder: None,
836            size: ControlSize::Default,
837            attrs: &[],
838            disabled: false,
839            required: false,
840        }
841    }
842
843    pub const fn email(name: &'a str) -> Self {
844        Self {
845            input_type: "email",
846            ..Self::new(name)
847        }
848    }
849
850    pub const fn url(name: &'a str) -> Self {
851        Self {
852            input_type: "url",
853            ..Self::new(name)
854        }
855    }
856
857    pub const fn search(name: &'a str) -> Self {
858        Self {
859            input_type: "search",
860            ..Self::new(name)
861        }
862    }
863
864    pub const fn with_type(mut self, input_type: &'a str) -> Self {
865        self.input_type = input_type;
866        self
867    }
868
869    pub const fn with_value(mut self, value: &'a str) -> Self {
870        self.value = Some(value);
871        self
872    }
873
874    pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
875        self.placeholder = Some(placeholder);
876        self
877    }
878
879    pub const fn with_size(mut self, size: ControlSize) -> Self {
880        self.size = size;
881        self
882    }
883
884    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
885        self.attrs = attrs;
886        self
887    }
888
889    pub const fn disabled(mut self) -> Self {
890        self.disabled = true;
891        self
892    }
893
894    pub const fn required(mut self) -> Self {
895        self.required = true;
896        self
897    }
898
899    pub fn class_name(&self) -> String {
900        format!("wf-input{}", self.size.class())
901    }
902}
903
904impl<'a> askama::filters::HtmlSafe for Input<'a> {}
905
906#[derive(Debug, Template)]
907#[non_exhaustive]
908#[template(path = "components/textarea.html")]
909pub struct Textarea<'a> {
910    pub name: &'a str,
911    pub value: Option<&'a str>,
912    pub placeholder: Option<&'a str>,
913    pub rows: Option<u16>,
914    pub attrs: &'a [HtmlAttr<'a>],
915    pub disabled: bool,
916    pub required: bool,
917}
918
919impl<'a> Textarea<'a> {
920    pub const fn new(name: &'a str) -> Self {
921        Self {
922            name,
923            value: None,
924            placeholder: None,
925            rows: None,
926            attrs: &[],
927            disabled: false,
928            required: false,
929        }
930    }
931
932    pub const fn with_value(mut self, value: &'a str) -> Self {
933        self.value = Some(value);
934        self
935    }
936
937    pub const fn with_placeholder(mut self, placeholder: &'a str) -> Self {
938        self.placeholder = Some(placeholder);
939        self
940    }
941
942    pub const fn with_rows(mut self, rows: u16) -> Self {
943        self.rows = Some(rows);
944        self
945    }
946
947    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
948        self.attrs = attrs;
949        self
950    }
951
952    pub const fn disabled(mut self) -> Self {
953        self.disabled = true;
954        self
955    }
956
957    pub const fn required(mut self) -> Self {
958        self.required = true;
959        self
960    }
961}
962
963impl<'a> askama::filters::HtmlSafe for Textarea<'a> {}
964
965#[derive(Clone, Copy, Debug, Eq, PartialEq)]
966pub struct SelectOption<'a> {
967    pub value: &'a str,
968    pub label: &'a str,
969    pub selected: bool,
970    pub disabled: bool,
971}
972
973impl<'a> SelectOption<'a> {
974    pub const fn new(value: &'a str, label: &'a str) -> Self {
975        Self {
976            value,
977            label,
978            selected: false,
979            disabled: false,
980        }
981    }
982
983    pub const fn selected(mut self) -> Self {
984        self.selected = true;
985        self
986    }
987
988    pub const fn disabled(mut self) -> Self {
989        self.disabled = true;
990        self
991    }
992}
993
994#[derive(Debug, Template)]
995#[non_exhaustive]
996#[template(path = "components/select.html")]
997pub struct Select<'a> {
998    pub name: &'a str,
999    pub options: &'a [SelectOption<'a>],
1000    pub size: ControlSize,
1001    pub attrs: &'a [HtmlAttr<'a>],
1002    pub disabled: bool,
1003    pub required: bool,
1004}
1005
1006impl<'a> Select<'a> {
1007    pub const fn new(name: &'a str, options: &'a [SelectOption<'a>]) -> Self {
1008        Self {
1009            name,
1010            options,
1011            size: ControlSize::Default,
1012            attrs: &[],
1013            disabled: false,
1014            required: false,
1015        }
1016    }
1017
1018    pub const fn with_size(mut self, size: ControlSize) -> Self {
1019        self.size = size;
1020        self
1021    }
1022
1023    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1024        self.attrs = attrs;
1025        self
1026    }
1027
1028    pub const fn disabled(mut self) -> Self {
1029        self.disabled = true;
1030        self
1031    }
1032
1033    pub const fn required(mut self) -> Self {
1034        self.required = true;
1035        self
1036    }
1037
1038    pub fn class_name(&self) -> String {
1039        format!("wf-select{}", self.size.class())
1040    }
1041}
1042
1043impl<'a> askama::filters::HtmlSafe for Select<'a> {}
1044
1045#[derive(Debug, Template)]
1046#[non_exhaustive]
1047#[template(path = "components/input_group.html")]
1048pub struct InputGroup<'a> {
1049    pub control_html: TrustedHtml<'a>,
1050    pub prefix: Option<&'a str>,
1051    pub suffix: Option<&'a str>,
1052    pub attrs: &'a [HtmlAttr<'a>],
1053}
1054
1055impl<'a> InputGroup<'a> {
1056    pub const fn new(control_html: TrustedHtml<'a>) -> Self {
1057        Self {
1058            control_html,
1059            prefix: None,
1060            suffix: None,
1061            attrs: &[],
1062        }
1063    }
1064
1065    pub const fn with_prefix(mut self, prefix: &'a str) -> Self {
1066        self.prefix = Some(prefix);
1067        self
1068    }
1069
1070    pub const fn with_suffix(mut self, suffix: &'a str) -> Self {
1071        self.suffix = Some(suffix);
1072        self
1073    }
1074
1075    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1076        self.attrs = attrs;
1077        self
1078    }
1079}
1080
1081impl<'a> askama::filters::HtmlSafe for InputGroup<'a> {}
1082
1083#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1084pub enum CheckKind {
1085    Checkbox,
1086    Radio,
1087}
1088
1089impl CheckKind {
1090    fn input_type(self) -> &'static str {
1091        match self {
1092            Self::Checkbox => "checkbox",
1093            Self::Radio => "radio",
1094        }
1095    }
1096}
1097
1098#[derive(Debug, Template)]
1099#[non_exhaustive]
1100#[template(path = "components/check_row.html")]
1101pub struct CheckRow<'a> {
1102    pub kind: CheckKind,
1103    pub name: &'a str,
1104    pub value: &'a str,
1105    pub label: &'a str,
1106    pub attrs: &'a [HtmlAttr<'a>],
1107    pub checked: bool,
1108    pub disabled: bool,
1109}
1110
1111impl<'a> CheckRow<'a> {
1112    pub const fn checkbox(name: &'a str, value: &'a str, label: &'a str) -> Self {
1113        Self {
1114            kind: CheckKind::Checkbox,
1115            name,
1116            value,
1117            label,
1118            attrs: &[],
1119            checked: false,
1120            disabled: false,
1121        }
1122    }
1123
1124    pub const fn radio(name: &'a str, value: &'a str, label: &'a str) -> Self {
1125        Self {
1126            kind: CheckKind::Radio,
1127            ..Self::checkbox(name, value, label)
1128        }
1129    }
1130
1131    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1132        self.attrs = attrs;
1133        self
1134    }
1135
1136    pub const fn checked(mut self) -> Self {
1137        self.checked = true;
1138        self
1139    }
1140
1141    pub const fn disabled(mut self) -> Self {
1142        self.disabled = true;
1143        self
1144    }
1145
1146    pub fn input_type(&self) -> &'static str {
1147        self.kind.input_type()
1148    }
1149}
1150
1151impl<'a> askama::filters::HtmlSafe for CheckRow<'a> {}
1152
1153#[derive(Debug, Template)]
1154#[non_exhaustive]
1155#[template(path = "components/switch.html")]
1156pub struct Switch<'a> {
1157    pub name: &'a str,
1158    pub value: &'a str,
1159    pub attrs: &'a [HtmlAttr<'a>],
1160    pub checked: bool,
1161    pub disabled: bool,
1162}
1163
1164impl<'a> Switch<'a> {
1165    pub const fn new(name: &'a str) -> Self {
1166        Self {
1167            name,
1168            value: "on",
1169            attrs: &[],
1170            checked: false,
1171            disabled: false,
1172        }
1173    }
1174
1175    pub const fn with_value(mut self, value: &'a str) -> Self {
1176        self.value = value;
1177        self
1178    }
1179
1180    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1181        self.attrs = attrs;
1182        self
1183    }
1184
1185    pub const fn checked(mut self) -> Self {
1186        self.checked = true;
1187        self
1188    }
1189
1190    pub const fn disabled(mut self) -> Self {
1191        self.disabled = true;
1192        self
1193    }
1194}
1195
1196impl<'a> askama::filters::HtmlSafe for Switch<'a> {}
1197
1198#[derive(Debug, Template)]
1199#[non_exhaustive]
1200#[template(path = "components/range.html")]
1201pub struct Range<'a> {
1202    pub name: &'a str,
1203    pub value: Option<&'a str>,
1204    pub min: Option<&'a str>,
1205    pub max: Option<&'a str>,
1206    pub step: Option<&'a str>,
1207    pub attrs: &'a [HtmlAttr<'a>],
1208    pub disabled: bool,
1209}
1210
1211impl<'a> Range<'a> {
1212    pub const fn new(name: &'a str) -> Self {
1213        Self {
1214            name,
1215            value: None,
1216            min: None,
1217            max: None,
1218            step: None,
1219            attrs: &[],
1220            disabled: false,
1221        }
1222    }
1223
1224    pub const fn with_value(mut self, value: &'a str) -> Self {
1225        self.value = Some(value);
1226        self
1227    }
1228
1229    pub const fn with_bounds(mut self, min: &'a str, max: &'a str) -> Self {
1230        self.min = Some(min);
1231        self.max = Some(max);
1232        self
1233    }
1234
1235    pub const fn with_step(mut self, step: &'a str) -> Self {
1236        self.step = Some(step);
1237        self
1238    }
1239
1240    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1241        self.attrs = attrs;
1242        self
1243    }
1244
1245    pub const fn disabled(mut self) -> Self {
1246        self.disabled = true;
1247        self
1248    }
1249}
1250
1251impl<'a> askama::filters::HtmlSafe for Range<'a> {}
1252
1253#[derive(Debug, Template)]
1254#[non_exhaustive]
1255#[template(path = "components/dropzone.html")]
1256pub struct Dropzone<'a> {
1257    pub name: &'a str,
1258    pub title: &'a str,
1259    pub hint: Option<&'a str>,
1260    pub accept: Option<&'a str>,
1261    pub attrs: &'a [HtmlAttr<'a>],
1262    pub multiple: bool,
1263    pub disabled: bool,
1264    pub dragover: bool,
1265}
1266
1267impl<'a> Dropzone<'a> {
1268    pub const fn new(name: &'a str) -> Self {
1269        Self {
1270            name,
1271            title: "Drop files or click",
1272            hint: None,
1273            accept: None,
1274            attrs: &[],
1275            multiple: false,
1276            disabled: false,
1277            dragover: false,
1278        }
1279    }
1280
1281    pub const fn with_title(mut self, title: &'a str) -> Self {
1282        self.title = title;
1283        self
1284    }
1285
1286    pub const fn with_hint(mut self, hint: &'a str) -> Self {
1287        self.hint = Some(hint);
1288        self
1289    }
1290
1291    pub const fn with_accept(mut self, accept: &'a str) -> Self {
1292        self.accept = Some(accept);
1293        self
1294    }
1295
1296    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1297        self.attrs = attrs;
1298        self
1299    }
1300
1301    pub const fn multiple(mut self) -> Self {
1302        self.multiple = true;
1303        self
1304    }
1305
1306    pub const fn disabled(mut self) -> Self {
1307        self.disabled = true;
1308        self
1309    }
1310
1311    pub const fn dragover(mut self) -> Self {
1312        self.dragover = true;
1313        self
1314    }
1315
1316    pub fn class_name(&self) -> String {
1317        let dragover = if self.dragover { " is-dragover" } else { "" };
1318        let disabled = if self.disabled { " is-disabled" } else { "" };
1319        format!("wf-dropzone{dragover}{disabled}")
1320    }
1321}
1322
1323impl<'a> askama::filters::HtmlSafe for Dropzone<'a> {}
1324
1325#[derive(Debug, Template)]
1326#[non_exhaustive]
1327#[template(path = "components/panel.html")]
1328pub struct Panel<'a> {
1329    pub title: &'a str,
1330    pub body_html: TrustedHtml<'a>,
1331    pub action_html: Option<TrustedHtml<'a>>,
1332    pub danger: bool,
1333    pub attrs: &'a [HtmlAttr<'a>],
1334}
1335
1336impl<'a> Panel<'a> {
1337    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1338        Self {
1339            title,
1340            body_html,
1341            action_html: None,
1342            danger: false,
1343            attrs: &[],
1344        }
1345    }
1346
1347    pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
1348        self.action_html = Some(action_html);
1349        self
1350    }
1351
1352    pub const fn danger(mut self) -> Self {
1353        self.danger = true;
1354        self
1355    }
1356
1357    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1358        self.attrs = attrs;
1359        self
1360    }
1361
1362    pub fn class_name(&self) -> &'static str {
1363        if self.danger {
1364            "wf-panel is-danger"
1365        } else {
1366            "wf-panel"
1367        }
1368    }
1369}
1370
1371impl<'a> askama::filters::HtmlSafe for Panel<'a> {}
1372
1373#[derive(Debug, Template)]
1374#[non_exhaustive]
1375#[template(path = "components/form_panel.html")]
1376pub struct FormPanel<'a> {
1377    pub title: &'a str,
1378    pub body_html: TrustedHtml<'a>,
1379    pub subtitle: Option<&'a str>,
1380    pub actions_html: Option<TrustedHtml<'a>>,
1381    pub meta_html: Option<TrustedHtml<'a>>,
1382    pub attrs: &'a [HtmlAttr<'a>],
1383}
1384
1385impl<'a> FormPanel<'a> {
1386    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1387        Self {
1388            title,
1389            body_html,
1390            subtitle: None,
1391            actions_html: None,
1392            meta_html: None,
1393            attrs: &[],
1394        }
1395    }
1396
1397    pub const fn with_subtitle(mut self, subtitle: &'a str) -> Self {
1398        self.subtitle = Some(subtitle);
1399        self
1400    }
1401
1402    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
1403        self.actions_html = Some(actions_html);
1404        self
1405    }
1406
1407    pub const fn with_meta(mut self, meta_html: TrustedHtml<'a>) -> Self {
1408        self.meta_html = Some(meta_html);
1409        self
1410    }
1411
1412    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1413        self.attrs = attrs;
1414        self
1415    }
1416}
1417
1418impl<'a> askama::filters::HtmlSafe for FormPanel<'a> {}
1419
1420#[derive(Debug, Template)]
1421#[non_exhaustive]
1422#[template(path = "components/split_shell.html")]
1423pub struct SplitShell<'a> {
1424    pub content_html: TrustedHtml<'a>,
1425    pub visual_html: Option<TrustedHtml<'a>>,
1426    pub top_html: Option<TrustedHtml<'a>>,
1427    pub footer_html: Option<TrustedHtml<'a>>,
1428    pub mode: Option<&'a str>,
1429    pub mode_locked: bool,
1430    pub asset_base_path: &'a str,
1431    pub attrs: &'a [HtmlAttr<'a>],
1432}
1433
1434impl<'a> SplitShell<'a> {
1435    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
1436        Self {
1437            content_html,
1438            visual_html: None,
1439            top_html: None,
1440            footer_html: None,
1441            mode: None,
1442            mode_locked: false,
1443            asset_base_path: assets::DEFAULT_BASE_PATH,
1444            attrs: &[],
1445        }
1446    }
1447
1448    pub const fn with_visual(mut self, visual_html: TrustedHtml<'a>) -> Self {
1449        self.visual_html = Some(visual_html);
1450        self
1451    }
1452
1453    pub const fn with_top(mut self, top_html: TrustedHtml<'a>) -> Self {
1454        self.top_html = Some(top_html);
1455        self
1456    }
1457
1458    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
1459        self.footer_html = Some(footer_html);
1460        self
1461    }
1462
1463    pub const fn with_mode(mut self, mode: &'a str) -> Self {
1464        self.mode = Some(mode);
1465        self
1466    }
1467
1468    pub const fn mode_locked(mut self) -> Self {
1469        self.mode_locked = true;
1470        self
1471    }
1472
1473    pub const fn with_asset_base_path(mut self, asset_base_path: &'a str) -> Self {
1474        self.asset_base_path = asset_base_path;
1475        self
1476    }
1477
1478    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1479        self.attrs = attrs;
1480        self
1481    }
1482}
1483
1484impl<'a> askama::filters::HtmlSafe for SplitShell<'a> {}
1485
1486#[derive(Debug, Template)]
1487#[non_exhaustive]
1488#[template(path = "components/settings_section.html")]
1489pub struct SettingsSection<'a> {
1490    pub title: &'a str,
1491    pub body_html: TrustedHtml<'a>,
1492    pub description: Option<&'a str>,
1493    pub action_html: Option<TrustedHtml<'a>>,
1494    pub danger: bool,
1495    pub attrs: &'a [HtmlAttr<'a>],
1496}
1497
1498impl<'a> SettingsSection<'a> {
1499    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1500        Self {
1501            title,
1502            body_html,
1503            description: None,
1504            action_html: None,
1505            danger: false,
1506            attrs: &[],
1507        }
1508    }
1509
1510    pub const fn with_description(mut self, description: &'a str) -> Self {
1511        self.description = Some(description);
1512        self
1513    }
1514
1515    pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
1516        self.action_html = Some(action_html);
1517        self
1518    }
1519
1520    pub const fn danger(mut self) -> Self {
1521        self.danger = true;
1522        self
1523    }
1524
1525    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1526        self.attrs = attrs;
1527        self
1528    }
1529
1530    pub fn class_name(&self) -> &'static str {
1531        if self.danger {
1532            "wf-panel wf-settings-section is-danger"
1533        } else {
1534            "wf-panel wf-settings-section"
1535        }
1536    }
1537}
1538
1539impl<'a> askama::filters::HtmlSafe for SettingsSection<'a> {}
1540
1541#[derive(Debug, Template)]
1542#[non_exhaustive]
1543#[template(path = "components/inline_form_row.html")]
1544pub struct InlineFormRow<'a> {
1545    pub label: &'a str,
1546    pub control_html: TrustedHtml<'a>,
1547    pub hint: Option<&'a str>,
1548    pub action_html: Option<TrustedHtml<'a>>,
1549}
1550
1551impl<'a> InlineFormRow<'a> {
1552    pub const fn new(label: &'a str, control_html: TrustedHtml<'a>) -> Self {
1553        Self {
1554            label,
1555            control_html,
1556            hint: None,
1557            action_html: None,
1558        }
1559    }
1560
1561    pub const fn with_hint(mut self, hint: &'a str) -> Self {
1562        self.hint = Some(hint);
1563        self
1564    }
1565
1566    pub const fn with_action(mut self, action_html: TrustedHtml<'a>) -> Self {
1567        self.action_html = Some(action_html);
1568        self
1569    }
1570}
1571
1572impl<'a> askama::filters::HtmlSafe for InlineFormRow<'a> {}
1573
1574#[derive(Debug, Template)]
1575#[non_exhaustive]
1576#[template(path = "components/copyable_value.html")]
1577pub struct CopyableValue<'a> {
1578    pub label: &'a str,
1579    pub id: &'a str,
1580    pub value: &'a str,
1581    pub button_label: &'a str,
1582    pub secret: bool,
1583}
1584
1585impl<'a> CopyableValue<'a> {
1586    pub const fn new(label: &'a str, id: &'a str, value: &'a str) -> Self {
1587        Self {
1588            label,
1589            id,
1590            value,
1591            button_label: "Copy",
1592            secret: false,
1593        }
1594    }
1595
1596    pub const fn with_button_label(mut self, button_label: &'a str) -> Self {
1597        self.button_label = button_label;
1598        self
1599    }
1600
1601    pub const fn secret(mut self) -> Self {
1602        self.secret = true;
1603        self
1604    }
1605
1606    pub fn value_class(&self) -> &'static str {
1607        if self.secret {
1608            "wf-copyable-value is-secret"
1609        } else {
1610            "wf-copyable-value"
1611        }
1612    }
1613}
1614
1615impl<'a> askama::filters::HtmlSafe for CopyableValue<'a> {}
1616
1617#[derive(Debug, Template)]
1618#[non_exhaustive]
1619#[template(path = "components/secret_value.html")]
1620pub struct SecretValue<'a> {
1621    pub label: &'a str,
1622    pub id: &'a str,
1623    pub value: &'a str,
1624    pub button_label: &'a str,
1625    pub revealed: bool,
1626    pub copy_raw_value: bool,
1627    pub warning: Option<&'a str>,
1628    pub help_html: Option<TrustedHtml<'a>>,
1629    pub attrs: &'a [HtmlAttr<'a>],
1630}
1631
1632impl<'a> SecretValue<'a> {
1633    pub const fn new(label: &'a str, id: &'a str, value: &'a str) -> Self {
1634        Self {
1635            label,
1636            id,
1637            value,
1638            button_label: "Copy",
1639            revealed: false,
1640            copy_raw_value: false,
1641            warning: None,
1642            help_html: None,
1643            attrs: &[],
1644        }
1645    }
1646
1647    pub const fn revealed(mut self) -> Self {
1648        self.revealed = true;
1649        self
1650    }
1651
1652    pub const fn copy_raw_value(mut self) -> Self {
1653        self.copy_raw_value = true;
1654        self
1655    }
1656
1657    pub const fn with_button_label(mut self, button_label: &'a str) -> Self {
1658        self.button_label = button_label;
1659        self
1660    }
1661
1662    pub const fn with_warning(mut self, warning: &'a str) -> Self {
1663        self.warning = Some(warning);
1664        self
1665    }
1666
1667    pub const fn with_help(mut self, help_html: TrustedHtml<'a>) -> Self {
1668        self.help_html = Some(help_html);
1669        self
1670    }
1671
1672    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1673        self.attrs = attrs;
1674        self
1675    }
1676
1677    pub const fn display_value(&self) -> &str {
1678        if self.revealed {
1679            self.value
1680        } else {
1681            "********"
1682        }
1683    }
1684
1685    pub fn value_class(&self) -> &'static str {
1686        if self.revealed {
1687            "wf-secret-code is-revealed"
1688        } else {
1689            "wf-secret-code is-masked"
1690        }
1691    }
1692}
1693
1694impl<'a> askama::filters::HtmlSafe for SecretValue<'a> {}
1695
1696#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1697pub struct ChecklistItem<'a> {
1698    pub label: &'a str,
1699    pub description: Option<&'a str>,
1700    pub kind: FeedbackKind,
1701    pub status_label: Option<&'a str>,
1702    pub icon_html: Option<TrustedHtml<'a>>,
1703}
1704
1705impl<'a> ChecklistItem<'a> {
1706    pub const fn new(label: &'a str, kind: FeedbackKind) -> Self {
1707        Self {
1708            label,
1709            description: None,
1710            kind,
1711            status_label: None,
1712            icon_html: None,
1713        }
1714    }
1715
1716    pub const fn info(label: &'a str) -> Self {
1717        Self::new(label, FeedbackKind::Info)
1718    }
1719
1720    pub const fn ok(label: &'a str) -> Self {
1721        Self::new(label, FeedbackKind::Ok)
1722    }
1723
1724    pub const fn warn(label: &'a str) -> Self {
1725        Self::new(label, FeedbackKind::Warn)
1726    }
1727
1728    pub const fn error(label: &'a str) -> Self {
1729        Self::new(label, FeedbackKind::Error)
1730    }
1731
1732    pub const fn with_description(mut self, description: &'a str) -> Self {
1733        self.description = Some(description);
1734        self
1735    }
1736
1737    pub const fn with_status_label(mut self, status_label: &'a str) -> Self {
1738        self.status_label = Some(status_label);
1739        self
1740    }
1741
1742    pub const fn with_icon(mut self, icon_html: TrustedHtml<'a>) -> Self {
1743        self.icon_html = Some(icon_html);
1744        self
1745    }
1746
1747    pub fn class_name(&self) -> &'static str {
1748        match self.kind {
1749            FeedbackKind::Info => "wf-checklist-item is-info",
1750            FeedbackKind::Ok => "wf-checklist-item is-ok",
1751            FeedbackKind::Warn => "wf-checklist-item is-warn",
1752            FeedbackKind::Error => "wf-checklist-item is-err",
1753        }
1754    }
1755
1756    pub fn status_text(&self) -> &'a str {
1757        self.status_label.unwrap_or(match self.kind {
1758            FeedbackKind::Info => "info",
1759            FeedbackKind::Ok => "ok",
1760            FeedbackKind::Warn => "warn",
1761            FeedbackKind::Error => "error",
1762        })
1763    }
1764}
1765
1766#[derive(Debug, Template)]
1767#[non_exhaustive]
1768#[template(path = "components/checklist.html")]
1769pub struct Checklist<'a> {
1770    pub items: &'a [ChecklistItem<'a>],
1771    pub attrs: &'a [HtmlAttr<'a>],
1772}
1773
1774impl<'a> Checklist<'a> {
1775    pub const fn new(items: &'a [ChecklistItem<'a>]) -> Self {
1776        Self { items, attrs: &[] }
1777    }
1778
1779    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1780        self.attrs = attrs;
1781        self
1782    }
1783}
1784
1785impl<'a> askama::filters::HtmlSafe for Checklist<'a> {}
1786
1787#[derive(Debug, Template)]
1788#[non_exhaustive]
1789#[template(path = "components/code_grid.html")]
1790pub struct CodeGrid<'a> {
1791    pub codes: &'a [&'a str],
1792    pub label: Option<&'a str>,
1793    pub attrs: &'a [HtmlAttr<'a>],
1794}
1795
1796impl<'a> CodeGrid<'a> {
1797    pub const fn new(codes: &'a [&'a str]) -> Self {
1798        Self {
1799            codes,
1800            label: None,
1801            attrs: &[],
1802        }
1803    }
1804
1805    pub const fn with_label(mut self, label: &'a str) -> Self {
1806        self.label = Some(label);
1807        self
1808    }
1809
1810    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1811        self.attrs = attrs;
1812        self
1813    }
1814}
1815
1816impl<'a> askama::filters::HtmlSafe for CodeGrid<'a> {}
1817
1818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1819pub struct CredentialStatusItem<'a> {
1820    pub label: &'a str,
1821    pub value: &'a str,
1822    pub kind: FeedbackKind,
1823    pub status_label: &'a str,
1824}
1825
1826impl<'a> CredentialStatusItem<'a> {
1827    pub const fn new(
1828        label: &'a str,
1829        value: &'a str,
1830        kind: FeedbackKind,
1831        status_label: &'a str,
1832    ) -> Self {
1833        Self {
1834            label,
1835            value,
1836            kind,
1837            status_label,
1838        }
1839    }
1840
1841    pub const fn ok(label: &'a str, value: &'a str) -> Self {
1842        Self::new(label, value, FeedbackKind::Ok, "ok")
1843    }
1844
1845    pub const fn warn(label: &'a str, value: &'a str) -> Self {
1846        Self::new(label, value, FeedbackKind::Warn, "warn")
1847    }
1848
1849    pub const fn error(label: &'a str, value: &'a str) -> Self {
1850        Self::new(label, value, FeedbackKind::Error, "error")
1851    }
1852
1853    pub const fn info(label: &'a str, value: &'a str) -> Self {
1854        Self::new(label, value, FeedbackKind::Info, "info")
1855    }
1856
1857    pub fn kind_class(&self) -> String {
1858        format!("wf-tag {}", self.kind.class())
1859    }
1860}
1861
1862#[derive(Debug, Template)]
1863#[non_exhaustive]
1864#[template(path = "components/credential_status_list.html")]
1865pub struct CredentialStatusList<'a> {
1866    pub items: &'a [CredentialStatusItem<'a>],
1867}
1868
1869impl<'a> CredentialStatusList<'a> {
1870    pub const fn new(items: &'a [CredentialStatusItem<'a>]) -> Self {
1871        Self { items }
1872    }
1873}
1874
1875impl<'a> askama::filters::HtmlSafe for CredentialStatusList<'a> {}
1876
1877#[derive(Debug, Template)]
1878#[non_exhaustive]
1879#[template(path = "components/confirm_action.html")]
1880pub struct ConfirmAction<'a> {
1881    pub label: &'a str,
1882    pub action: &'a str,
1883    pub method: &'a str,
1884    pub message: Option<&'a str>,
1885    pub confirm: Option<&'a str>,
1886    pub attrs: &'a [HtmlAttr<'a>],
1887}
1888
1889impl<'a> ConfirmAction<'a> {
1890    pub const fn new(label: &'a str, action: &'a str) -> Self {
1891        Self {
1892            label,
1893            action,
1894            method: "post",
1895            message: None,
1896            confirm: None,
1897            attrs: &[],
1898        }
1899    }
1900
1901    pub const fn with_method(mut self, method: &'a str) -> Self {
1902        self.method = method;
1903        self
1904    }
1905
1906    pub const fn with_message(mut self, message: &'a str) -> Self {
1907        self.message = Some(message);
1908        self
1909    }
1910
1911    pub const fn with_confirm(mut self, confirm: &'a str) -> Self {
1912        self.confirm = Some(confirm);
1913        self
1914    }
1915
1916    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1917        self.attrs = attrs;
1918        self
1919    }
1920}
1921
1922impl<'a> askama::filters::HtmlSafe for ConfirmAction<'a> {}
1923
1924#[derive(Debug, Template)]
1925#[non_exhaustive]
1926#[template(path = "components/card.html")]
1927pub struct Card<'a> {
1928    pub title: &'a str,
1929    pub body_html: TrustedHtml<'a>,
1930    pub kicker: Option<&'a str>,
1931    pub foot_html: Option<TrustedHtml<'a>>,
1932    pub raised: bool,
1933    pub attrs: &'a [HtmlAttr<'a>],
1934}
1935
1936impl<'a> Card<'a> {
1937    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
1938        Self {
1939            title,
1940            body_html,
1941            kicker: None,
1942            foot_html: None,
1943            raised: false,
1944            attrs: &[],
1945        }
1946    }
1947
1948    pub const fn with_kicker(mut self, kicker: &'a str) -> Self {
1949        self.kicker = Some(kicker);
1950        self
1951    }
1952
1953    pub const fn with_foot(mut self, foot_html: TrustedHtml<'a>) -> Self {
1954        self.foot_html = Some(foot_html);
1955        self
1956    }
1957
1958    pub const fn raised(mut self) -> Self {
1959        self.raised = true;
1960        self
1961    }
1962
1963    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
1964        self.attrs = attrs;
1965        self
1966    }
1967
1968    pub fn class_name(&self) -> &'static str {
1969        if self.raised {
1970            "wf-card is-raised"
1971        } else {
1972            "wf-card"
1973        }
1974    }
1975}
1976
1977impl<'a> askama::filters::HtmlSafe for Card<'a> {}
1978
1979#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1980pub enum BadgeKind {
1981    Default,
1982    Muted,
1983    Error,
1984}
1985
1986impl BadgeKind {
1987    fn class(self) -> &'static str {
1988        match self {
1989            Self::Default => "",
1990            Self::Muted => " muted",
1991            Self::Error => " err",
1992        }
1993    }
1994}
1995
1996#[derive(Debug, Template)]
1997#[non_exhaustive]
1998#[template(path = "components/badge.html")]
1999pub struct Badge<'a> {
2000    pub label: &'a str,
2001    pub kind: BadgeKind,
2002}
2003
2004impl<'a> Badge<'a> {
2005    pub const fn new(label: &'a str) -> Self {
2006        Self {
2007            label,
2008            kind: BadgeKind::Default,
2009        }
2010    }
2011
2012    pub const fn muted(label: &'a str) -> Self {
2013        Self {
2014            kind: BadgeKind::Muted,
2015            ..Self::new(label)
2016        }
2017    }
2018
2019    pub const fn error(label: &'a str) -> Self {
2020        Self {
2021            kind: BadgeKind::Error,
2022            ..Self::new(label)
2023        }
2024    }
2025
2026    pub fn class_name(&self) -> String {
2027        format!("wf-badge{}", self.kind.class())
2028    }
2029}
2030
2031impl<'a> askama::filters::HtmlSafe for Badge<'a> {}
2032
2033#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2034pub enum AvatarSize {
2035    Default,
2036    Small,
2037    Large,
2038    ExtraLarge,
2039}
2040
2041impl AvatarSize {
2042    fn class(self) -> &'static str {
2043        match self {
2044            Self::Default => "",
2045            Self::Small => " sm",
2046            Self::Large => " lg",
2047            Self::ExtraLarge => " xl",
2048        }
2049    }
2050}
2051
2052#[derive(Debug, Template)]
2053#[non_exhaustive]
2054#[template(path = "components/avatar.html")]
2055pub struct Avatar<'a> {
2056    pub initials: &'a str,
2057    pub image_src: Option<&'a str>,
2058    pub size: AvatarSize,
2059    pub accent: bool,
2060}
2061
2062impl<'a> Avatar<'a> {
2063    pub const fn new(initials: &'a str) -> Self {
2064        Self {
2065            initials,
2066            image_src: None,
2067            size: AvatarSize::Default,
2068            accent: false,
2069        }
2070    }
2071
2072    pub const fn with_image(mut self, image_src: &'a str) -> Self {
2073        self.image_src = Some(image_src);
2074        self
2075    }
2076
2077    pub const fn with_size(mut self, size: AvatarSize) -> Self {
2078        self.size = size;
2079        self
2080    }
2081
2082    pub const fn accent(mut self) -> Self {
2083        self.accent = true;
2084        self
2085    }
2086
2087    pub fn class_name(&self) -> String {
2088        let accent = if self.accent { " accent" } else { "" };
2089        format!("wf-avatar{}{}", self.size.class(), accent)
2090    }
2091}
2092
2093impl<'a> askama::filters::HtmlSafe for Avatar<'a> {}
2094
2095#[derive(Debug, Template)]
2096#[non_exhaustive]
2097#[template(path = "components/avatar_group.html")]
2098pub struct AvatarGroup<'a> {
2099    pub avatars: &'a [Avatar<'a>],
2100}
2101
2102impl<'a> AvatarGroup<'a> {
2103    pub const fn new(avatars: &'a [Avatar<'a>]) -> Self {
2104        Self { avatars }
2105    }
2106}
2107
2108impl<'a> askama::filters::HtmlSafe for AvatarGroup<'a> {}
2109
2110#[derive(Debug, Template)]
2111#[non_exhaustive]
2112#[template(path = "components/user_button.html")]
2113pub struct UserButton<'a> {
2114    pub name: &'a str,
2115    pub email: &'a str,
2116    pub avatar: Avatar<'a>,
2117    pub compact: bool,
2118    pub attrs: &'a [HtmlAttr<'a>],
2119}
2120
2121impl<'a> UserButton<'a> {
2122    pub const fn new(name: &'a str, email: &'a str, avatar: Avatar<'a>) -> Self {
2123        Self {
2124            name,
2125            email,
2126            avatar,
2127            compact: false,
2128            attrs: &[],
2129        }
2130    }
2131
2132    pub const fn compact(mut self) -> Self {
2133        self.compact = true;
2134        self
2135    }
2136
2137    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
2138        self.attrs = attrs;
2139        self
2140    }
2141
2142    pub fn class_name(&self) -> &'static str {
2143        if self.compact {
2144            "wf-user compact"
2145        } else {
2146            "wf-user"
2147        }
2148    }
2149}
2150
2151impl<'a> askama::filters::HtmlSafe for UserButton<'a> {}
2152
2153#[derive(Debug, Template)]
2154#[non_exhaustive]
2155#[template(path = "components/wordmark.html")]
2156pub struct Wordmark<'a> {
2157    pub name: &'a str,
2158    pub mark_html: Option<TrustedHtml<'a>>,
2159}
2160
2161impl<'a> Wordmark<'a> {
2162    pub const fn new(name: &'a str) -> Self {
2163        Self {
2164            name,
2165            mark_html: None,
2166        }
2167    }
2168
2169    pub const fn with_mark(mut self, mark_html: TrustedHtml<'a>) -> Self {
2170        self.mark_html = Some(mark_html);
2171        self
2172    }
2173}
2174
2175impl<'a> askama::filters::HtmlSafe for Wordmark<'a> {}
2176
2177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2178pub enum DeltaKind {
2179    Neutral,
2180    Up,
2181    Down,
2182}
2183
2184impl DeltaKind {
2185    fn class(self) -> &'static str {
2186        match self {
2187            Self::Neutral => "",
2188            Self::Up => " up",
2189            Self::Down => " down",
2190        }
2191    }
2192}
2193
2194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2195pub struct Stat<'a> {
2196    pub label: &'a str,
2197    pub value: &'a str,
2198    pub unit: Option<&'a str>,
2199    pub delta: Option<&'a str>,
2200    pub delta_kind: DeltaKind,
2201    pub foot: Option<&'a str>,
2202}
2203
2204impl<'a> Stat<'a> {
2205    pub const fn new(label: &'a str, value: &'a str) -> Self {
2206        Self {
2207            label,
2208            value,
2209            unit: None,
2210            delta: None,
2211            delta_kind: DeltaKind::Neutral,
2212            foot: None,
2213        }
2214    }
2215
2216    pub const fn with_unit(mut self, unit: &'a str) -> Self {
2217        self.unit = Some(unit);
2218        self
2219    }
2220
2221    pub const fn with_delta(mut self, delta: &'a str, kind: DeltaKind) -> Self {
2222        self.delta = Some(delta);
2223        self.delta_kind = kind;
2224        self
2225    }
2226
2227    pub const fn with_foot(mut self, foot: &'a str) -> Self {
2228        self.foot = Some(foot);
2229        self
2230    }
2231
2232    pub fn delta_class(&self) -> String {
2233        format!("wf-stat-delta{}", self.delta_kind.class())
2234    }
2235}
2236
2237#[derive(Debug, Template)]
2238#[non_exhaustive]
2239#[template(path = "components/stat_row.html")]
2240pub struct StatRow<'a> {
2241    pub stats: &'a [Stat<'a>],
2242}
2243
2244impl<'a> StatRow<'a> {
2245    pub const fn new(stats: &'a [Stat<'a>]) -> Self {
2246        Self { stats }
2247    }
2248}
2249
2250impl<'a> askama::filters::HtmlSafe for StatRow<'a> {}
2251
2252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2253pub struct BreadcrumbItem<'a> {
2254    pub label: &'a str,
2255    pub href: Option<&'a str>,
2256    pub current: bool,
2257}
2258
2259impl<'a> BreadcrumbItem<'a> {
2260    pub const fn link(label: &'a str, href: &'a str) -> Self {
2261        Self {
2262            label,
2263            href: Some(href),
2264            current: false,
2265        }
2266    }
2267
2268    pub const fn current(label: &'a str) -> Self {
2269        Self {
2270            label,
2271            href: None,
2272            current: true,
2273        }
2274    }
2275}
2276
2277#[derive(Debug, Template)]
2278#[non_exhaustive]
2279#[template(path = "components/breadcrumbs.html")]
2280pub struct Breadcrumbs<'a> {
2281    pub items: &'a [BreadcrumbItem<'a>],
2282}
2283
2284impl<'a> Breadcrumbs<'a> {
2285    pub const fn new(items: &'a [BreadcrumbItem<'a>]) -> Self {
2286        Self { items }
2287    }
2288}
2289
2290impl<'a> askama::filters::HtmlSafe for Breadcrumbs<'a> {}
2291
2292#[derive(Debug, Template)]
2293#[non_exhaustive]
2294#[template(path = "components/page_header.html")]
2295pub struct PageHeader<'a> {
2296    pub title: &'a str,
2297    pub subtitle: Option<&'a str>,
2298    pub back_href: Option<&'a str>,
2299    pub back_label: &'a str,
2300    pub meta_html: Option<TrustedHtml<'a>>,
2301    pub primary_html: Option<TrustedHtml<'a>>,
2302    pub secondary_html: Option<TrustedHtml<'a>>,
2303}
2304
2305impl<'a> PageHeader<'a> {
2306    pub const fn new(title: &'a str) -> Self {
2307        Self {
2308            title,
2309            subtitle: None,
2310            back_href: None,
2311            back_label: "Back",
2312            meta_html: None,
2313            primary_html: None,
2314            secondary_html: None,
2315        }
2316    }
2317
2318    pub const fn with_subtitle(mut self, subtitle: &'a str) -> Self {
2319        self.subtitle = Some(subtitle);
2320        self
2321    }
2322
2323    pub const fn with_back(mut self, href: &'a str, label: &'a str) -> Self {
2324        self.back_href = Some(href);
2325        self.back_label = label;
2326        self
2327    }
2328
2329    pub const fn with_meta(mut self, meta_html: TrustedHtml<'a>) -> Self {
2330        self.meta_html = Some(meta_html);
2331        self
2332    }
2333
2334    pub const fn with_primary(mut self, primary_html: TrustedHtml<'a>) -> Self {
2335        self.primary_html = Some(primary_html);
2336        self
2337    }
2338
2339    pub const fn with_secondary(mut self, secondary_html: TrustedHtml<'a>) -> Self {
2340        self.secondary_html = Some(secondary_html);
2341        self
2342    }
2343
2344    pub const fn has_actions(&self) -> bool {
2345        self.primary_html.is_some() || self.secondary_html.is_some()
2346    }
2347}
2348
2349impl<'a> askama::filters::HtmlSafe for PageHeader<'a> {}
2350
2351#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2352pub struct TabItem<'a> {
2353    pub label: &'a str,
2354    pub href: &'a str,
2355    pub active: bool,
2356}
2357
2358impl<'a> TabItem<'a> {
2359    pub const fn link(label: &'a str, href: &'a str) -> Self {
2360        Self {
2361            label,
2362            href,
2363            active: false,
2364        }
2365    }
2366
2367    pub const fn active(mut self) -> Self {
2368        self.active = true;
2369        self
2370    }
2371}
2372
2373#[derive(Debug, Template)]
2374#[non_exhaustive]
2375#[template(path = "components/tabs.html")]
2376pub struct Tabs<'a> {
2377    pub items: &'a [TabItem<'a>],
2378}
2379
2380impl<'a> Tabs<'a> {
2381    pub const fn new(items: &'a [TabItem<'a>]) -> Self {
2382        Self { items }
2383    }
2384}
2385
2386impl<'a> askama::filters::HtmlSafe for Tabs<'a> {}
2387
2388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2389pub struct SegmentOption<'a> {
2390    pub label: &'a str,
2391    pub value: &'a str,
2392    pub active: bool,
2393}
2394
2395impl<'a> SegmentOption<'a> {
2396    pub const fn new(label: &'a str, value: &'a str) -> Self {
2397        Self {
2398            label,
2399            value,
2400            active: false,
2401        }
2402    }
2403
2404    pub const fn active(mut self) -> Self {
2405        self.active = true;
2406        self
2407    }
2408}
2409
2410#[derive(Debug, Template)]
2411#[non_exhaustive]
2412#[template(path = "components/segmented_control.html")]
2413pub struct SegmentedControl<'a> {
2414    pub options: &'a [SegmentOption<'a>],
2415    pub small: bool,
2416}
2417
2418impl<'a> SegmentedControl<'a> {
2419    pub const fn new(options: &'a [SegmentOption<'a>]) -> Self {
2420        Self {
2421            options,
2422            small: false,
2423        }
2424    }
2425
2426    pub const fn small(mut self) -> Self {
2427        self.small = true;
2428        self
2429    }
2430
2431    pub fn class_name(&self) -> &'static str {
2432        if self.small { "wf-seg sm" } else { "wf-seg" }
2433    }
2434}
2435
2436impl<'a> askama::filters::HtmlSafe for SegmentedControl<'a> {}
2437
2438#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2439pub struct PageLink<'a> {
2440    pub label: &'a str,
2441    pub href: Option<&'a str>,
2442    pub active: bool,
2443    pub disabled: bool,
2444    pub ellipsis: bool,
2445}
2446
2447impl<'a> PageLink<'a> {
2448    pub const fn link(label: &'a str, href: &'a str) -> Self {
2449        Self {
2450            label,
2451            href: Some(href),
2452            active: false,
2453            disabled: false,
2454            ellipsis: false,
2455        }
2456    }
2457
2458    pub const fn disabled(label: &'a str) -> Self {
2459        Self {
2460            label,
2461            href: None,
2462            active: false,
2463            disabled: true,
2464            ellipsis: false,
2465        }
2466    }
2467
2468    pub const fn ellipsis() -> Self {
2469        Self {
2470            label: "...",
2471            href: None,
2472            active: false,
2473            disabled: false,
2474            ellipsis: true,
2475        }
2476    }
2477
2478    pub const fn active(mut self) -> Self {
2479        self.active = true;
2480        self
2481    }
2482}
2483
2484#[derive(Debug, Template)]
2485#[non_exhaustive]
2486#[template(path = "components/pagination.html")]
2487pub struct Pagination<'a> {
2488    pub pages: &'a [PageLink<'a>],
2489}
2490
2491impl<'a> Pagination<'a> {
2492    pub const fn new(pages: &'a [PageLink<'a>]) -> Self {
2493        Self { pages }
2494    }
2495}
2496
2497impl<'a> askama::filters::HtmlSafe for Pagination<'a> {}
2498
2499#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2500pub enum StepState {
2501    Upcoming,
2502    Active,
2503    Done,
2504}
2505
2506#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2507pub struct StepItem<'a> {
2508    pub label: &'a str,
2509    pub href: Option<&'a str>,
2510    pub state: StepState,
2511}
2512
2513impl<'a> StepItem<'a> {
2514    pub const fn new(label: &'a str) -> Self {
2515        Self {
2516            label,
2517            href: None,
2518            state: StepState::Upcoming,
2519        }
2520    }
2521
2522    pub const fn with_href(mut self, href: &'a str) -> Self {
2523        self.href = Some(href);
2524        self
2525    }
2526
2527    pub const fn active(mut self) -> Self {
2528        self.state = StepState::Active;
2529        self
2530    }
2531
2532    pub const fn done(mut self) -> Self {
2533        self.state = StepState::Done;
2534        self
2535    }
2536
2537    pub fn class_name(&self) -> &'static str {
2538        match self.state {
2539            StepState::Upcoming => "wf-step",
2540            StepState::Active => "wf-step is-active",
2541            StepState::Done => "wf-step is-done",
2542        }
2543    }
2544
2545    pub fn is_active(&self) -> bool {
2546        self.state == StepState::Active
2547    }
2548}
2549
2550#[derive(Debug, Template)]
2551#[non_exhaustive]
2552#[template(path = "components/stepper.html")]
2553pub struct Stepper<'a> {
2554    pub steps: &'a [StepItem<'a>],
2555}
2556
2557impl<'a> Stepper<'a> {
2558    pub const fn new(steps: &'a [StepItem<'a>]) -> Self {
2559        Self { steps }
2560    }
2561}
2562
2563impl<'a> askama::filters::HtmlSafe for Stepper<'a> {}
2564
2565#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2566pub struct AccordionItem<'a> {
2567    pub title: &'a str,
2568    pub body_html: TrustedHtml<'a>,
2569    pub open: bool,
2570}
2571
2572impl<'a> AccordionItem<'a> {
2573    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
2574        Self {
2575            title,
2576            body_html,
2577            open: false,
2578        }
2579    }
2580
2581    pub const fn open(mut self) -> Self {
2582        self.open = true;
2583        self
2584    }
2585}
2586
2587#[derive(Debug, Template)]
2588#[non_exhaustive]
2589#[template(path = "components/accordion.html")]
2590pub struct Accordion<'a> {
2591    pub items: &'a [AccordionItem<'a>],
2592}
2593
2594impl<'a> Accordion<'a> {
2595    pub const fn new(items: &'a [AccordionItem<'a>]) -> Self {
2596        Self { items }
2597    }
2598}
2599
2600impl<'a> askama::filters::HtmlSafe for Accordion<'a> {}
2601
2602#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2603pub struct FaqItem<'a> {
2604    pub question: &'a str,
2605    pub answer_html: TrustedHtml<'a>,
2606}
2607
2608impl<'a> FaqItem<'a> {
2609    pub const fn new(question: &'a str, answer_html: TrustedHtml<'a>) -> Self {
2610        Self {
2611            question,
2612            answer_html,
2613        }
2614    }
2615}
2616
2617#[derive(Debug, Template)]
2618#[non_exhaustive]
2619#[template(path = "components/faq.html")]
2620pub struct Faq<'a> {
2621    pub items: &'a [FaqItem<'a>],
2622}
2623
2624impl<'a> Faq<'a> {
2625    pub const fn new(items: &'a [FaqItem<'a>]) -> Self {
2626        Self { items }
2627    }
2628}
2629
2630impl<'a> askama::filters::HtmlSafe for Faq<'a> {}
2631
2632#[derive(Debug, Template)]
2633#[non_exhaustive]
2634#[template(path = "components/nav_section.html")]
2635pub struct NavSection<'a> {
2636    pub label: &'a str,
2637}
2638
2639impl<'a> NavSection<'a> {
2640    pub const fn new(label: &'a str) -> Self {
2641        Self { label }
2642    }
2643}
2644
2645impl<'a> askama::filters::HtmlSafe for NavSection<'a> {}
2646
2647#[derive(Debug, Template)]
2648#[non_exhaustive]
2649#[template(path = "components/nav_item.html")]
2650pub struct NavItem<'a> {
2651    pub label: &'a str,
2652    pub href: &'a str,
2653    pub count: Option<&'a str>,
2654    pub active: bool,
2655}
2656
2657impl<'a> NavItem<'a> {
2658    pub const fn new(label: &'a str, href: &'a str) -> Self {
2659        Self {
2660            label,
2661            href,
2662            count: None,
2663            active: false,
2664        }
2665    }
2666
2667    pub const fn active(mut self) -> Self {
2668        self.active = true;
2669        self
2670    }
2671
2672    pub const fn with_count(mut self, count: &'a str) -> Self {
2673        self.count = Some(count);
2674        self
2675    }
2676
2677    pub fn class_name(&self) -> &'static str {
2678        if self.active {
2679            "wf-nav-item is-active"
2680        } else {
2681            "wf-nav-item"
2682        }
2683    }
2684}
2685
2686impl<'a> askama::filters::HtmlSafe for NavItem<'a> {}
2687
2688#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2689pub struct ContextSwitcherItem<'a> {
2690    pub label: &'a str,
2691    pub href: &'a str,
2692    pub meta: Option<&'a str>,
2693    pub badge_html: Option<TrustedHtml<'a>>,
2694    pub active: bool,
2695    pub disabled: bool,
2696}
2697
2698impl<'a> ContextSwitcherItem<'a> {
2699    pub const fn link(label: &'a str, href: &'a str) -> Self {
2700        Self {
2701            label,
2702            href,
2703            meta: None,
2704            badge_html: None,
2705            active: false,
2706            disabled: false,
2707        }
2708    }
2709
2710    pub const fn with_meta(mut self, meta: &'a str) -> Self {
2711        self.meta = Some(meta);
2712        self
2713    }
2714
2715    pub const fn with_badge(mut self, badge_html: TrustedHtml<'a>) -> Self {
2716        self.badge_html = Some(badge_html);
2717        self
2718    }
2719
2720    pub const fn active(mut self) -> Self {
2721        self.active = true;
2722        self
2723    }
2724
2725    pub const fn disabled(mut self) -> Self {
2726        self.disabled = true;
2727        self
2728    }
2729
2730    pub fn class_name(&self) -> &'static str {
2731        match (self.active, self.disabled) {
2732            (true, true) => "wf-context-switcher-item is-active is-disabled",
2733            (true, false) => "wf-context-switcher-item is-active",
2734            (false, true) => "wf-context-switcher-item is-disabled",
2735            (false, false) => "wf-context-switcher-item",
2736        }
2737    }
2738}
2739
2740#[derive(Debug, Template)]
2741#[non_exhaustive]
2742#[template(path = "components/context_switcher.html")]
2743pub struct ContextSwitcher<'a> {
2744    pub label: &'a str,
2745    pub current: &'a str,
2746    pub items: &'a [ContextSwitcherItem<'a>],
2747    pub meta_html: Option<TrustedHtml<'a>>,
2748    pub open: bool,
2749    pub attrs: &'a [HtmlAttr<'a>],
2750}
2751
2752impl<'a> ContextSwitcher<'a> {
2753    pub const fn new(
2754        label: &'a str,
2755        current: &'a str,
2756        items: &'a [ContextSwitcherItem<'a>],
2757    ) -> Self {
2758        Self {
2759            label,
2760            current,
2761            items,
2762            meta_html: None,
2763            open: false,
2764            attrs: &[],
2765        }
2766    }
2767
2768    pub const fn with_meta(mut self, meta_html: TrustedHtml<'a>) -> Self {
2769        self.meta_html = Some(meta_html);
2770        self
2771    }
2772
2773    pub const fn open(mut self) -> Self {
2774        self.open = true;
2775        self
2776    }
2777
2778    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
2779        self.attrs = attrs;
2780        self
2781    }
2782}
2783
2784impl<'a> askama::filters::HtmlSafe for ContextSwitcher<'a> {}
2785
2786#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2787pub struct SidenavItem<'a> {
2788    pub label: &'a str,
2789    pub href: &'a str,
2790    pub badge: Option<&'a str>,
2791    pub coming_soon: Option<&'a str>,
2792    pub active: bool,
2793    pub muted: bool,
2794    pub disabled: bool,
2795    pub attrs: &'a [HtmlAttr<'a>],
2796}
2797
2798impl<'a> SidenavItem<'a> {
2799    pub const fn link(label: &'a str, href: &'a str) -> Self {
2800        Self {
2801            label,
2802            href,
2803            badge: None,
2804            coming_soon: None,
2805            active: false,
2806            muted: false,
2807            disabled: false,
2808            attrs: &[],
2809        }
2810    }
2811
2812    pub const fn active(mut self) -> Self {
2813        self.active = true;
2814        self
2815    }
2816
2817    pub const fn muted(mut self) -> Self {
2818        self.muted = true;
2819        self
2820    }
2821
2822    pub const fn disabled(mut self) -> Self {
2823        self.disabled = true;
2824        self
2825    }
2826
2827    pub const fn with_badge(mut self, badge: &'a str) -> Self {
2828        self.badge = Some(badge);
2829        self
2830    }
2831
2832    pub const fn with_coming_soon(mut self, coming_soon: &'a str) -> Self {
2833        self.coming_soon = Some(coming_soon);
2834        self
2835    }
2836
2837    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
2838        self.attrs = attrs;
2839        self
2840    }
2841
2842    pub fn class_name(&self) -> &'static str {
2843        match (self.active, self.muted, self.disabled) {
2844            (true, true, true) => "wf-sidenav-item is-active is-muted is-disabled",
2845            (true, true, false) => "wf-sidenav-item is-active is-muted",
2846            (true, false, true) => "wf-sidenav-item is-active is-disabled",
2847            (true, false, false) => "wf-sidenav-item is-active",
2848            (false, true, true) => "wf-sidenav-item is-muted is-disabled",
2849            (false, true, false) => "wf-sidenav-item is-muted",
2850            (false, false, true) => "wf-sidenav-item is-disabled",
2851            (false, false, false) => "wf-sidenav-item",
2852        }
2853    }
2854}
2855
2856#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2857pub struct SidenavSection<'a> {
2858    pub label: &'a str,
2859    pub items: &'a [SidenavItem<'a>],
2860}
2861
2862impl<'a> SidenavSection<'a> {
2863    pub const fn new(label: &'a str, items: &'a [SidenavItem<'a>]) -> Self {
2864        Self { label, items }
2865    }
2866}
2867
2868#[derive(Debug, Template)]
2869#[non_exhaustive]
2870#[template(path = "components/sidenav.html")]
2871pub struct Sidenav<'a> {
2872    pub sections: &'a [SidenavSection<'a>],
2873    pub attrs: &'a [HtmlAttr<'a>],
2874    pub landmark: bool,
2875}
2876
2877impl<'a> Sidenav<'a> {
2878    pub const fn new(sections: &'a [SidenavSection<'a>]) -> Self {
2879        Self {
2880            sections,
2881            attrs: &[],
2882            landmark: true,
2883        }
2884    }
2885
2886    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
2887        self.attrs = attrs;
2888        self
2889    }
2890
2891    pub const fn embedded(mut self) -> Self {
2892        self.landmark = false;
2893        self
2894    }
2895}
2896
2897impl<'a> askama::filters::HtmlSafe for Sidenav<'a> {}
2898
2899#[derive(Debug, Template)]
2900#[non_exhaustive]
2901#[template(path = "components/topbar.html")]
2902pub struct Topbar<'a> {
2903    pub breadcrumbs_html: TrustedHtml<'a>,
2904    pub actions_html: TrustedHtml<'a>,
2905}
2906
2907impl<'a> Topbar<'a> {
2908    pub const fn new(breadcrumbs_html: TrustedHtml<'a>, actions_html: TrustedHtml<'a>) -> Self {
2909        Self {
2910            breadcrumbs_html,
2911            actions_html,
2912        }
2913    }
2914}
2915
2916impl<'a> askama::filters::HtmlSafe for Topbar<'a> {}
2917
2918#[derive(Debug, Template)]
2919#[non_exhaustive]
2920#[template(path = "components/statusbar.html")]
2921pub struct Statusbar<'a> {
2922    pub left: &'a str,
2923    pub right: &'a str,
2924}
2925
2926impl<'a> Statusbar<'a> {
2927    pub const fn new(left: &'a str, right: &'a str) -> Self {
2928        Self { left, right }
2929    }
2930}
2931
2932impl<'a> askama::filters::HtmlSafe for Statusbar<'a> {}
2933
2934#[derive(Debug, Template)]
2935#[non_exhaustive]
2936#[template(path = "components/empty_state.html")]
2937pub struct EmptyState<'a> {
2938    pub title: &'a str,
2939    pub body: &'a str,
2940    pub glyph_html: Option<TrustedHtml<'a>>,
2941    pub actions_html: Option<TrustedHtml<'a>>,
2942    pub bordered: bool,
2943    pub dense: bool,
2944}
2945
2946impl<'a> EmptyState<'a> {
2947    pub const fn new(title: &'a str, body: &'a str) -> Self {
2948        Self {
2949            title,
2950            body,
2951            glyph_html: None,
2952            actions_html: None,
2953            bordered: false,
2954            dense: false,
2955        }
2956    }
2957
2958    pub const fn with_glyph(mut self, glyph_html: TrustedHtml<'a>) -> Self {
2959        self.glyph_html = Some(glyph_html);
2960        self
2961    }
2962
2963    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
2964        self.actions_html = Some(actions_html);
2965        self
2966    }
2967
2968    pub const fn bordered(mut self) -> Self {
2969        self.bordered = true;
2970        self
2971    }
2972
2973    pub const fn dense(mut self) -> Self {
2974        self.dense = true;
2975        self
2976    }
2977
2978    pub fn class_name(&self) -> String {
2979        let bordered = if self.bordered { " bordered" } else { "" };
2980        let dense = if self.dense { " dense" } else { "" };
2981        format!("wf-empty{bordered}{dense}")
2982    }
2983}
2984
2985impl<'a> askama::filters::HtmlSafe for EmptyState<'a> {}
2986
2987#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2988pub enum SortDirection {
2989    Ascending,
2990    Descending,
2991}
2992
2993impl SortDirection {
2994    fn arrow(self) -> &'static str {
2995        match self {
2996            Self::Ascending => "^",
2997            Self::Descending => "v",
2998        }
2999    }
3000}
3001
3002#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3003pub enum TableColumnWidth {
3004    Auto,
3005    ExtraSmall,
3006    Small,
3007    Medium,
3008    Large,
3009    ExtraLarge,
3010    Id,
3011    Checkbox,
3012    Action,
3013}
3014
3015#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3016pub struct TableHeader<'a> {
3017    pub label: &'a str,
3018    pub numeric: bool,
3019}
3020
3021impl<'a> TableHeader<'a> {
3022    pub const fn new(label: &'a str) -> Self {
3023        Self {
3024            label,
3025            numeric: false,
3026        }
3027    }
3028
3029    pub const fn numeric(label: &'a str) -> Self {
3030        Self {
3031            label,
3032            numeric: true,
3033        }
3034    }
3035
3036    pub fn class_name(&self) -> &'static str {
3037        if self.numeric { "num" } else { "" }
3038    }
3039}
3040
3041#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3042pub struct TableCell<'a> {
3043    pub text: &'a str,
3044    pub numeric: bool,
3045    pub strong: bool,
3046    pub muted: bool,
3047}
3048
3049impl<'a> TableCell<'a> {
3050    pub const fn new(text: &'a str) -> Self {
3051        Self {
3052            text,
3053            numeric: false,
3054            strong: false,
3055            muted: false,
3056        }
3057    }
3058
3059    pub const fn numeric(text: &'a str) -> Self {
3060        Self {
3061            numeric: true,
3062            ..Self::new(text)
3063        }
3064    }
3065
3066    pub const fn strong(text: &'a str) -> Self {
3067        Self {
3068            strong: true,
3069            ..Self::new(text)
3070        }
3071    }
3072
3073    pub const fn muted(text: &'a str) -> Self {
3074        Self {
3075            muted: true,
3076            ..Self::new(text)
3077        }
3078    }
3079
3080    pub fn class_name(&self) -> &'static str {
3081        match (self.numeric, self.strong, self.muted) {
3082            (false, false, false) => "",
3083            (true, false, false) => "num",
3084            (false, true, false) => "strong",
3085            (false, false, true) => "muted",
3086            (true, true, false) => "num strong",
3087            (true, false, true) => "num muted",
3088            (false, true, true) => "strong muted",
3089            (true, true, true) => "num strong muted",
3090        }
3091    }
3092}
3093
3094#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3095pub struct TableRow<'a> {
3096    pub cells: &'a [TableCell<'a>],
3097    pub selected: bool,
3098}
3099
3100impl<'a> TableRow<'a> {
3101    pub const fn new(cells: &'a [TableCell<'a>]) -> Self {
3102        Self {
3103            cells,
3104            selected: false,
3105        }
3106    }
3107
3108    pub const fn selected(mut self) -> Self {
3109        self.selected = true;
3110        self
3111    }
3112}
3113
3114#[derive(Debug, Template)]
3115#[non_exhaustive]
3116#[template(path = "components/table.html")]
3117pub struct Table<'a> {
3118    pub headers: &'a [TableHeader<'a>],
3119    pub rows: &'a [TableRow<'a>],
3120    pub flush: bool,
3121    pub interactive: bool,
3122    pub sticky: bool,
3123    pub pin_last: bool,
3124}
3125
3126impl<'a> Table<'a> {
3127    pub const fn new(headers: &'a [TableHeader<'a>], rows: &'a [TableRow<'a>]) -> Self {
3128        Self {
3129            headers,
3130            rows,
3131            flush: false,
3132            interactive: false,
3133            sticky: false,
3134            pin_last: false,
3135        }
3136    }
3137
3138    pub const fn flush(mut self) -> Self {
3139        self.flush = true;
3140        self
3141    }
3142
3143    pub const fn interactive(mut self) -> Self {
3144        self.interactive = true;
3145        self
3146    }
3147
3148    pub const fn sticky(mut self) -> Self {
3149        self.sticky = true;
3150        self
3151    }
3152
3153    pub const fn pin_last(mut self) -> Self {
3154        self.pin_last = true;
3155        self
3156    }
3157
3158    pub fn class_name(&self) -> String {
3159        let flush = if self.flush { " flush" } else { "" };
3160        let interactive = if self.interactive {
3161            " is-interactive"
3162        } else {
3163            ""
3164        };
3165        let sticky = if self.sticky { " sticky" } else { "" };
3166        let pin_last = if self.pin_last { " pin-last" } else { "" };
3167        format!("wf-table{flush}{interactive}{sticky}{pin_last}")
3168    }
3169}
3170
3171impl<'a> askama::filters::HtmlSafe for Table<'a> {}
3172
3173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3174pub struct DataTableHeader<'a> {
3175    pub label: &'a str,
3176    pub numeric: bool,
3177    pub sort_key: Option<&'a str>,
3178    pub sort_direction: Option<SortDirection>,
3179    pub width: TableColumnWidth,
3180}
3181
3182impl<'a> DataTableHeader<'a> {
3183    pub const fn new(label: &'a str) -> Self {
3184        Self {
3185            label,
3186            numeric: false,
3187            sort_key: None,
3188            sort_direction: None,
3189            width: TableColumnWidth::Auto,
3190        }
3191    }
3192
3193    pub const fn numeric(label: &'a str) -> Self {
3194        Self {
3195            numeric: true,
3196            ..Self::new(label)
3197        }
3198    }
3199
3200    pub const fn sort(label: &'a str, sort_key: &'a str) -> Self {
3201        Self {
3202            sort_key: Some(sort_key),
3203            ..Self::new(label)
3204        }
3205    }
3206
3207    pub const fn sorted(label: &'a str, sort_key: &'a str, direction: SortDirection) -> Self {
3208        Self {
3209            sort_direction: Some(direction),
3210            ..Self::sort(label, sort_key)
3211        }
3212    }
3213
3214    pub const fn sortable(mut self, sort_key: &'a str, direction: SortDirection) -> Self {
3215        self.sort_key = Some(sort_key);
3216        self.sort_direction = Some(direction);
3217        self
3218    }
3219
3220    pub const fn with_width(mut self, width: TableColumnWidth) -> Self {
3221        self.width = width;
3222        self
3223    }
3224
3225    pub const fn action_column(mut self) -> Self {
3226        self.width = TableColumnWidth::Action;
3227        self
3228    }
3229
3230    pub fn class_name(&self) -> &'static str {
3231        match (self.width, self.numeric) {
3232            (TableColumnWidth::Auto, false) => "",
3233            (TableColumnWidth::Auto, true) => "num",
3234            (TableColumnWidth::ExtraSmall, false) => "wf-col-xs",
3235            (TableColumnWidth::ExtraSmall, true) => "wf-col-xs num",
3236            (TableColumnWidth::Small, false) => "wf-col-sm",
3237            (TableColumnWidth::Small, true) => "wf-col-sm num",
3238            (TableColumnWidth::Medium, false) => "wf-col-md",
3239            (TableColumnWidth::Medium, true) => "wf-col-md num",
3240            (TableColumnWidth::Large, false) => "wf-col-lg",
3241            (TableColumnWidth::Large, true) => "wf-col-lg num",
3242            (TableColumnWidth::ExtraLarge, false) => "wf-col-xl",
3243            (TableColumnWidth::ExtraLarge, true) => "wf-col-xl num",
3244            (TableColumnWidth::Id, false) => "wf-col-id",
3245            (TableColumnWidth::Id, true) => "wf-col-id num",
3246            (TableColumnWidth::Checkbox, false) => "wf-col-chk",
3247            (TableColumnWidth::Checkbox, true) => "wf-col-chk num",
3248            (TableColumnWidth::Action, false) => "wf-col-act",
3249            (TableColumnWidth::Action, true) => "wf-col-act num",
3250        }
3251    }
3252
3253    pub fn sort_arrow(&self) -> &'static str {
3254        self.sort_direction.map(SortDirection::arrow).unwrap_or("-")
3255    }
3256}
3257
3258#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3259pub struct DataTableCell<'a> {
3260    pub text: &'a str,
3261    pub html: Option<TrustedHtml<'a>>,
3262    pub numeric: bool,
3263    pub strong: bool,
3264    pub muted: bool,
3265}
3266
3267impl<'a> DataTableCell<'a> {
3268    pub const fn new(text: &'a str) -> Self {
3269        Self {
3270            text,
3271            html: None,
3272            numeric: false,
3273            strong: false,
3274            muted: false,
3275        }
3276    }
3277
3278    pub const fn numeric(text: &'a str) -> Self {
3279        Self {
3280            numeric: true,
3281            ..Self::new(text)
3282        }
3283    }
3284
3285    pub const fn strong(text: &'a str) -> Self {
3286        Self {
3287            strong: true,
3288            ..Self::new(text)
3289        }
3290    }
3291
3292    pub const fn muted(text: &'a str) -> Self {
3293        Self {
3294            muted: true,
3295            ..Self::new(text)
3296        }
3297    }
3298
3299    pub const fn html(html: TrustedHtml<'a>) -> Self {
3300        Self {
3301            text: "",
3302            html: Some(html),
3303            numeric: false,
3304            strong: false,
3305            muted: false,
3306        }
3307    }
3308
3309    pub fn class_name(&self) -> &'static str {
3310        match (self.numeric, self.strong, self.muted) {
3311            (false, false, false) => "",
3312            (true, false, false) => "num",
3313            (false, true, false) => "strong",
3314            (false, false, true) => "muted",
3315            (true, true, false) => "num strong",
3316            (true, false, true) => "num muted",
3317            (false, true, true) => "strong muted",
3318            (true, true, true) => "num strong muted",
3319        }
3320    }
3321}
3322
3323#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3324pub struct DataTableRow<'a> {
3325    pub cells: &'a [DataTableCell<'a>],
3326    pub selected: bool,
3327}
3328
3329impl<'a> DataTableRow<'a> {
3330    pub const fn new(cells: &'a [DataTableCell<'a>]) -> Self {
3331        Self {
3332            cells,
3333            selected: false,
3334        }
3335    }
3336
3337    pub const fn selected(mut self) -> Self {
3338        self.selected = true;
3339        self
3340    }
3341}
3342
3343#[derive(Debug, Template)]
3344#[non_exhaustive]
3345#[template(path = "components/data_table.html")]
3346pub struct DataTable<'a> {
3347    pub headers: &'a [DataTableHeader<'a>],
3348    pub rows: &'a [DataTableRow<'a>],
3349    pub flush: bool,
3350    pub interactive: bool,
3351    pub sticky: bool,
3352    pub pin_last: bool,
3353}
3354
3355impl<'a> DataTable<'a> {
3356    pub const fn new(headers: &'a [DataTableHeader<'a>], rows: &'a [DataTableRow<'a>]) -> Self {
3357        Self {
3358            headers,
3359            rows,
3360            flush: false,
3361            interactive: false,
3362            sticky: false,
3363            pin_last: false,
3364        }
3365    }
3366
3367    pub const fn flush(mut self) -> Self {
3368        self.flush = true;
3369        self
3370    }
3371
3372    pub const fn interactive(mut self) -> Self {
3373        self.interactive = true;
3374        self
3375    }
3376
3377    pub const fn sticky(mut self) -> Self {
3378        self.sticky = true;
3379        self
3380    }
3381
3382    pub const fn pin_last(mut self) -> Self {
3383        self.pin_last = true;
3384        self
3385    }
3386
3387    pub fn class_name(&self) -> String {
3388        let flush = if self.flush { " flush" } else { "" };
3389        let interactive = if self.interactive {
3390            " is-interactive"
3391        } else {
3392            ""
3393        };
3394        let sticky = if self.sticky { " sticky" } else { "" };
3395        let pin_last = if self.pin_last { " pin-last" } else { "" };
3396        format!("wf-table{flush}{interactive}{sticky}{pin_last}")
3397    }
3398}
3399
3400impl<'a> askama::filters::HtmlSafe for DataTable<'a> {}
3401
3402#[derive(Debug, Template)]
3403#[non_exhaustive]
3404#[template(path = "components/filter_bar.html")]
3405pub struct FilterBar<'a> {
3406    pub controls_html: TrustedHtml<'a>,
3407    pub actions_html: Option<TrustedHtml<'a>>,
3408    pub attrs: &'a [HtmlAttr<'a>],
3409}
3410
3411impl<'a> FilterBar<'a> {
3412    pub const fn new(controls_html: TrustedHtml<'a>) -> Self {
3413        Self {
3414            controls_html,
3415            actions_html: None,
3416            attrs: &[],
3417        }
3418    }
3419
3420    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
3421        self.actions_html = Some(actions_html);
3422        self
3423    }
3424
3425    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
3426        self.attrs = attrs;
3427        self
3428    }
3429}
3430
3431impl<'a> askama::filters::HtmlSafe for FilterBar<'a> {}
3432
3433#[derive(Debug, Template)]
3434#[non_exhaustive]
3435#[template(path = "components/bulk_action_bar.html")]
3436pub struct BulkActionBar<'a> {
3437    pub count_label: &'a str,
3438    pub actions_html: TrustedHtml<'a>,
3439    pub attrs: &'a [HtmlAttr<'a>],
3440}
3441
3442impl<'a> BulkActionBar<'a> {
3443    pub const fn new(count_label: &'a str, actions_html: TrustedHtml<'a>) -> Self {
3444        Self {
3445            count_label,
3446            actions_html,
3447            attrs: &[],
3448        }
3449    }
3450
3451    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
3452        self.attrs = attrs;
3453        self
3454    }
3455}
3456
3457impl<'a> askama::filters::HtmlSafe for BulkActionBar<'a> {}
3458
3459#[derive(Debug, Template)]
3460#[non_exhaustive]
3461#[template(path = "components/table_footer.html")]
3462pub struct TableFooter<'a> {
3463    pub content_html: TrustedHtml<'a>,
3464    pub actions_html: Option<TrustedHtml<'a>>,
3465    pub attrs: &'a [HtmlAttr<'a>],
3466}
3467
3468impl<'a> TableFooter<'a> {
3469    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
3470        Self {
3471            content_html,
3472            actions_html: None,
3473            attrs: &[],
3474        }
3475    }
3476
3477    pub const fn with_actions(mut self, actions_html: TrustedHtml<'a>) -> Self {
3478        self.actions_html = Some(actions_html);
3479        self
3480    }
3481
3482    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
3483        self.attrs = attrs;
3484        self
3485    }
3486}
3487
3488impl<'a> askama::filters::HtmlSafe for TableFooter<'a> {}
3489
3490#[derive(Debug, Template)]
3491#[non_exhaustive]
3492#[template(path = "components/row_select.html")]
3493pub struct RowSelect<'a> {
3494    pub name: &'a str,
3495    pub value: &'a str,
3496    pub label: &'a str,
3497    pub checked: bool,
3498    pub disabled: bool,
3499    pub attrs: &'a [HtmlAttr<'a>],
3500}
3501
3502impl<'a> RowSelect<'a> {
3503    pub const fn new(name: &'a str, value: &'a str, label: &'a str) -> Self {
3504        Self {
3505            name,
3506            value,
3507            label,
3508            checked: false,
3509            disabled: false,
3510            attrs: &[],
3511        }
3512    }
3513
3514    pub const fn checked(mut self) -> Self {
3515        self.checked = true;
3516        self
3517    }
3518
3519    pub const fn disabled(mut self) -> Self {
3520        self.disabled = true;
3521        self
3522    }
3523
3524    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
3525        self.attrs = attrs;
3526        self
3527    }
3528}
3529
3530impl<'a> askama::filters::HtmlSafe for RowSelect<'a> {}
3531
3532#[derive(Debug, Template)]
3533#[non_exhaustive]
3534#[template(path = "components/table_wrap.html")]
3535pub struct TableWrap<'a> {
3536    pub table_html: TrustedHtml<'a>,
3537    pub filterbar_html: Option<TrustedHtml<'a>>,
3538    pub filterbar_component_html: Option<TrustedHtml<'a>>,
3539    pub bulk_count: Option<&'a str>,
3540    pub bulk_actions_html: Option<TrustedHtml<'a>>,
3541    pub bulkbar_component_html: Option<TrustedHtml<'a>>,
3542    pub footer_html: Option<TrustedHtml<'a>>,
3543    pub footer_component_html: Option<TrustedHtml<'a>>,
3544}
3545
3546impl<'a> TableWrap<'a> {
3547    pub const fn new(table_html: TrustedHtml<'a>) -> Self {
3548        Self {
3549            table_html,
3550            filterbar_html: None,
3551            filterbar_component_html: None,
3552            bulk_count: None,
3553            bulk_actions_html: None,
3554            bulkbar_component_html: None,
3555            footer_html: None,
3556            footer_component_html: None,
3557        }
3558    }
3559
3560    pub const fn with_filterbar(mut self, filterbar_html: TrustedHtml<'a>) -> Self {
3561        self.filterbar_html = Some(filterbar_html);
3562        self
3563    }
3564
3565    pub const fn with_filterbar_component(mut self, filterbar_html: TrustedHtml<'a>) -> Self {
3566        self.filterbar_component_html = Some(filterbar_html);
3567        self
3568    }
3569
3570    pub const fn with_bulkbar(
3571        mut self,
3572        bulk_count: &'a str,
3573        bulk_actions_html: TrustedHtml<'a>,
3574    ) -> Self {
3575        self.bulk_count = Some(bulk_count);
3576        self.bulk_actions_html = Some(bulk_actions_html);
3577        self
3578    }
3579
3580    pub const fn with_bulkbar_component(mut self, bulkbar_html: TrustedHtml<'a>) -> Self {
3581        self.bulkbar_component_html = Some(bulkbar_html);
3582        self
3583    }
3584
3585    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
3586        self.footer_html = Some(footer_html);
3587        self
3588    }
3589
3590    pub const fn with_footer_component(mut self, footer_html: TrustedHtml<'a>) -> Self {
3591        self.footer_component_html = Some(footer_html);
3592        self
3593    }
3594}
3595
3596impl<'a> askama::filters::HtmlSafe for TableWrap<'a> {}
3597
3598#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3599pub struct DefinitionItem<'a> {
3600    pub term: &'a str,
3601    pub description: &'a str,
3602}
3603
3604impl<'a> DefinitionItem<'a> {
3605    pub const fn new(term: &'a str, description: &'a str) -> Self {
3606        Self { term, description }
3607    }
3608}
3609
3610#[derive(Debug, Template)]
3611#[non_exhaustive]
3612#[template(path = "components/definition_list.html")]
3613pub struct DefinitionList<'a> {
3614    pub items: &'a [DefinitionItem<'a>],
3615    pub flush: bool,
3616}
3617
3618impl<'a> DefinitionList<'a> {
3619    pub const fn new(items: &'a [DefinitionItem<'a>]) -> Self {
3620        Self {
3621            items,
3622            flush: false,
3623        }
3624    }
3625
3626    pub const fn flush(mut self) -> Self {
3627        self.flush = true;
3628        self
3629    }
3630
3631    pub fn class_name(&self) -> &'static str {
3632        if self.flush { "wf-dl flush" } else { "wf-dl" }
3633    }
3634}
3635
3636impl<'a> askama::filters::HtmlSafe for DefinitionList<'a> {}
3637
3638#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3639pub struct RankRow<'a> {
3640    pub label: &'a str,
3641    pub value: &'a str,
3642    pub percent: u8,
3643}
3644
3645impl<'a> RankRow<'a> {
3646    pub const fn new(label: &'a str, value: &'a str, percent: u8) -> Self {
3647        Self {
3648            label,
3649            value,
3650            percent,
3651        }
3652    }
3653
3654    pub fn bounded_percent(&self) -> u8 {
3655        self.percent.min(100)
3656    }
3657}
3658
3659#[derive(Debug, Template)]
3660#[non_exhaustive]
3661#[template(path = "components/rank_list.html")]
3662pub struct RankList<'a> {
3663    pub rows: &'a [RankRow<'a>],
3664}
3665
3666impl<'a> RankList<'a> {
3667    pub const fn new(rows: &'a [RankRow<'a>]) -> Self {
3668        Self { rows }
3669    }
3670}
3671
3672impl<'a> askama::filters::HtmlSafe for RankList<'a> {}
3673
3674#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3675pub struct FeedRow<'a> {
3676    pub time: &'a str,
3677    pub kicker: &'a str,
3678    pub text: &'a str,
3679}
3680
3681impl<'a> FeedRow<'a> {
3682    pub const fn new(time: &'a str, kicker: &'a str, text: &'a str) -> Self {
3683        Self { time, kicker, text }
3684    }
3685}
3686
3687#[derive(Debug, Template)]
3688#[non_exhaustive]
3689#[template(path = "components/feed.html")]
3690pub struct Feed<'a> {
3691    pub rows: &'a [FeedRow<'a>],
3692}
3693
3694impl<'a> Feed<'a> {
3695    pub const fn new(rows: &'a [FeedRow<'a>]) -> Self {
3696        Self { rows }
3697    }
3698}
3699
3700impl<'a> askama::filters::HtmlSafe for Feed<'a> {}
3701
3702#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3703pub struct TimelineItem<'a> {
3704    pub time: &'a str,
3705    pub title: &'a str,
3706    pub body_html: TrustedHtml<'a>,
3707    pub active: bool,
3708}
3709
3710impl<'a> TimelineItem<'a> {
3711    pub const fn new(time: &'a str, title: &'a str, body_html: TrustedHtml<'a>) -> Self {
3712        Self {
3713            time,
3714            title,
3715            body_html,
3716            active: false,
3717        }
3718    }
3719
3720    pub const fn active(mut self) -> Self {
3721        self.active = true;
3722        self
3723    }
3724
3725    pub fn class_name(&self) -> &'static str {
3726        if self.active {
3727            "wf-timeline-item is-active"
3728        } else {
3729            "wf-timeline-item"
3730        }
3731    }
3732}
3733
3734#[derive(Debug, Template)]
3735#[non_exhaustive]
3736#[template(path = "components/timeline.html")]
3737pub struct Timeline<'a> {
3738    pub items: &'a [TimelineItem<'a>],
3739}
3740
3741impl<'a> Timeline<'a> {
3742    pub const fn new(items: &'a [TimelineItem<'a>]) -> Self {
3743        Self { items }
3744    }
3745}
3746
3747impl<'a> askama::filters::HtmlSafe for Timeline<'a> {}
3748
3749#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3750pub enum TreeItemKind {
3751    Folder,
3752    File,
3753}
3754
3755#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3756pub struct TreeItem<'a> {
3757    pub kind: TreeItemKind,
3758    pub label: &'a str,
3759    pub active: bool,
3760    pub collapsed: bool,
3761    pub children_html: Option<TrustedHtml<'a>>,
3762}
3763
3764impl<'a> TreeItem<'a> {
3765    pub const fn folder(label: &'a str) -> Self {
3766        Self {
3767            kind: TreeItemKind::Folder,
3768            label,
3769            active: false,
3770            collapsed: false,
3771            children_html: None,
3772        }
3773    }
3774
3775    pub const fn file(label: &'a str) -> Self {
3776        Self {
3777            kind: TreeItemKind::File,
3778            label,
3779            active: false,
3780            collapsed: false,
3781            children_html: None,
3782        }
3783    }
3784
3785    pub const fn active(mut self) -> Self {
3786        self.active = true;
3787        self
3788    }
3789
3790    pub const fn collapsed(mut self) -> Self {
3791        self.collapsed = true;
3792        self
3793    }
3794
3795    pub const fn with_children(mut self, children_html: TrustedHtml<'a>) -> Self {
3796        self.children_html = Some(children_html);
3797        self
3798    }
3799
3800    pub fn item_class(&self) -> &'static str {
3801        if self.collapsed { "is-collapsed" } else { "" }
3802    }
3803
3804    pub fn label_class(&self) -> &'static str {
3805        match (self.kind, self.active) {
3806            (TreeItemKind::Folder, _) => "tree-folder",
3807            (TreeItemKind::File, true) => "tree-file is-active",
3808            (TreeItemKind::File, false) => "tree-file",
3809        }
3810    }
3811}
3812
3813#[derive(Debug, Template)]
3814#[non_exhaustive]
3815#[template(path = "components/tree_view.html")]
3816pub struct TreeView<'a> {
3817    pub items: &'a [TreeItem<'a>],
3818    pub nested: bool,
3819}
3820
3821impl<'a> TreeView<'a> {
3822    pub const fn new(items: &'a [TreeItem<'a>]) -> Self {
3823        Self {
3824            items,
3825            nested: false,
3826        }
3827    }
3828
3829    pub const fn nested(mut self) -> Self {
3830        self.nested = true;
3831        self
3832    }
3833
3834    pub fn class_name(&self) -> &'static str {
3835        if self.nested { "" } else { "wf-tree" }
3836    }
3837}
3838
3839impl<'a> askama::filters::HtmlSafe for TreeView<'a> {}
3840
3841#[derive(Debug, Template)]
3842#[non_exhaustive]
3843#[template(path = "components/framed.html")]
3844pub struct Framed<'a> {
3845    pub content_html: TrustedHtml<'a>,
3846    pub dense: bool,
3847    pub dashed: bool,
3848}
3849
3850impl<'a> Framed<'a> {
3851    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
3852        Self {
3853            content_html,
3854            dense: false,
3855            dashed: false,
3856        }
3857    }
3858
3859    pub const fn dense(mut self) -> Self {
3860        self.dense = true;
3861        self
3862    }
3863
3864    pub const fn dashed(mut self) -> Self {
3865        self.dashed = true;
3866        self
3867    }
3868
3869    pub fn class_name(&self) -> String {
3870        let dense = if self.dense { " dense" } else { "" };
3871        let dashed = if self.dashed { " dashed" } else { "" };
3872        format!("wf-framed{dense}{dashed}")
3873    }
3874}
3875
3876impl<'a> askama::filters::HtmlSafe for Framed<'a> {}
3877
3878#[derive(Debug, Template)]
3879#[non_exhaustive]
3880#[template(path = "components/grid.html")]
3881pub struct Grid<'a> {
3882    pub content_html: TrustedHtml<'a>,
3883    pub columns: u8,
3884}
3885
3886impl<'a> Grid<'a> {
3887    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
3888        Self {
3889            content_html,
3890            columns: 2,
3891        }
3892    }
3893
3894    pub const fn with_columns(mut self, columns: u8) -> Self {
3895        self.columns = columns;
3896        self
3897    }
3898
3899    pub fn class_name(&self) -> String {
3900        format!("wf-grid cols-{}", self.columns)
3901    }
3902}
3903
3904impl<'a> askama::filters::HtmlSafe for Grid<'a> {}
3905
3906#[derive(Debug, Template)]
3907#[non_exhaustive]
3908#[template(path = "components/split.html")]
3909pub struct Split<'a> {
3910    pub content_html: TrustedHtml<'a>,
3911    pub vertical: bool,
3912}
3913
3914impl<'a> Split<'a> {
3915    pub const fn new(content_html: TrustedHtml<'a>) -> Self {
3916        Self {
3917            content_html,
3918            vertical: false,
3919        }
3920    }
3921
3922    pub const fn vertical(mut self) -> Self {
3923        self.vertical = true;
3924        self
3925    }
3926
3927    pub fn class_name(&self) -> &'static str {
3928        if self.vertical {
3929            "wf-split vertical"
3930        } else {
3931            "wf-split"
3932        }
3933    }
3934}
3935
3936impl<'a> askama::filters::HtmlSafe for Split<'a> {}
3937
3938#[derive(Debug, Template)]
3939#[non_exhaustive]
3940#[template(path = "components/callout.html")]
3941pub struct Callout<'a> {
3942    pub kind: FeedbackKind,
3943    pub title: Option<&'a str>,
3944    pub body_html: TrustedHtml<'a>,
3945}
3946
3947impl<'a> Callout<'a> {
3948    pub const fn new(kind: FeedbackKind, body_html: TrustedHtml<'a>) -> Self {
3949        Self {
3950            kind,
3951            title: None,
3952            body_html,
3953        }
3954    }
3955
3956    pub const fn with_title(mut self, title: &'a str) -> Self {
3957        self.title = Some(title);
3958        self
3959    }
3960
3961    pub fn class_name(&self) -> String {
3962        format!("wf-callout {}", self.kind.class())
3963    }
3964}
3965
3966impl<'a> askama::filters::HtmlSafe for Callout<'a> {}
3967
3968#[derive(Debug, Template)]
3969#[non_exhaustive]
3970#[template(path = "components/toast.html")]
3971pub struct Toast<'a> {
3972    pub kind: FeedbackKind,
3973    pub message: &'a str,
3974}
3975
3976impl<'a> Toast<'a> {
3977    pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
3978        Self { kind, message }
3979    }
3980
3981    pub fn class_name(&self) -> String {
3982        format!("wf-toast {}", self.kind.class())
3983    }
3984}
3985
3986impl<'a> askama::filters::HtmlSafe for Toast<'a> {}
3987
3988#[derive(Debug, Template)]
3989#[non_exhaustive]
3990#[template(path = "components/toast_host.html")]
3991pub struct ToastHost<'a> {
3992    pub id: &'a str,
3993}
3994
3995impl<'a> ToastHost<'a> {
3996    pub const fn new() -> Self {
3997        Self { id: "toast-host" }
3998    }
3999
4000    pub const fn with_id(mut self, id: &'a str) -> Self {
4001        self.id = id;
4002        self
4003    }
4004}
4005
4006impl<'a> Default for ToastHost<'a> {
4007    fn default() -> Self {
4008        Self::new()
4009    }
4010}
4011
4012impl<'a> askama::filters::HtmlSafe for ToastHost<'a> {}
4013
4014#[derive(Debug, Template)]
4015#[non_exhaustive]
4016#[template(path = "components/tooltip.html")]
4017pub struct Tooltip<'a> {
4018    pub tip: &'a str,
4019    pub content_html: TrustedHtml<'a>,
4020}
4021
4022impl<'a> Tooltip<'a> {
4023    pub const fn new(tip: &'a str, content_html: TrustedHtml<'a>) -> Self {
4024        Self { tip, content_html }
4025    }
4026}
4027
4028impl<'a> askama::filters::HtmlSafe for Tooltip<'a> {}
4029
4030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4031pub enum MenuItemKind {
4032    Button,
4033    Link,
4034    Separator,
4035}
4036
4037#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4038pub struct MenuItem<'a> {
4039    pub kind: MenuItemKind,
4040    pub label: &'a str,
4041    pub href: Option<&'a str>,
4042    pub danger: bool,
4043    pub disabled: bool,
4044    pub kbd: Option<&'a str>,
4045    pub attrs: &'a [HtmlAttr<'a>],
4046}
4047
4048impl<'a> MenuItem<'a> {
4049    pub const fn button(label: &'a str) -> Self {
4050        Self {
4051            kind: MenuItemKind::Button,
4052            label,
4053            href: None,
4054            danger: false,
4055            disabled: false,
4056            kbd: None,
4057            attrs: &[],
4058        }
4059    }
4060
4061    pub const fn link(label: &'a str, href: &'a str) -> Self {
4062        Self {
4063            kind: MenuItemKind::Link,
4064            href: Some(href),
4065            ..Self::button(label)
4066        }
4067    }
4068
4069    pub const fn separator() -> Self {
4070        Self {
4071            kind: MenuItemKind::Separator,
4072            label: "",
4073            href: None,
4074            danger: false,
4075            disabled: false,
4076            kbd: None,
4077            attrs: &[],
4078        }
4079    }
4080
4081    pub const fn danger(mut self) -> Self {
4082        self.danger = true;
4083        self
4084    }
4085
4086    pub const fn disabled(mut self) -> Self {
4087        self.disabled = true;
4088        self
4089    }
4090
4091    pub const fn with_kbd(mut self, kbd: &'a str) -> Self {
4092        self.kbd = Some(kbd);
4093        self
4094    }
4095
4096    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
4097        self.attrs = attrs;
4098        self
4099    }
4100
4101    pub fn class_name(&self) -> &'static str {
4102        if self.danger {
4103            "wf-menu-item danger"
4104        } else {
4105            "wf-menu-item"
4106        }
4107    }
4108}
4109
4110#[derive(Debug, Template)]
4111#[non_exhaustive]
4112#[template(path = "components/menu.html")]
4113pub struct Menu<'a> {
4114    pub items: &'a [MenuItem<'a>],
4115}
4116
4117impl<'a> Menu<'a> {
4118    pub const fn new(items: &'a [MenuItem<'a>]) -> Self {
4119        Self { items }
4120    }
4121}
4122
4123impl<'a> askama::filters::HtmlSafe for Menu<'a> {}
4124
4125#[derive(Debug, Template)]
4126#[non_exhaustive]
4127#[template(path = "components/popover.html")]
4128pub struct Popover<'a> {
4129    pub trigger_html: TrustedHtml<'a>,
4130    pub body_html: TrustedHtml<'a>,
4131    pub heading: Option<&'a str>,
4132    pub side: &'a str,
4133    pub open: bool,
4134}
4135
4136impl<'a> Popover<'a> {
4137    pub const fn new(trigger_html: TrustedHtml<'a>, body_html: TrustedHtml<'a>) -> Self {
4138        Self {
4139            trigger_html,
4140            body_html,
4141            heading: None,
4142            side: "bottom",
4143            open: false,
4144        }
4145    }
4146
4147    pub const fn with_heading(mut self, heading: &'a str) -> Self {
4148        self.heading = Some(heading);
4149        self
4150    }
4151
4152    pub const fn with_side(mut self, side: &'a str) -> Self {
4153        self.side = side;
4154        self
4155    }
4156
4157    pub const fn open(mut self) -> Self {
4158        self.open = true;
4159        self
4160    }
4161
4162    pub fn popover_class(&self) -> &'static str {
4163        if self.open {
4164            "wf-popover is-open"
4165        } else {
4166            "wf-popover"
4167        }
4168    }
4169}
4170
4171impl<'a> askama::filters::HtmlSafe for Popover<'a> {}
4172
4173#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4174pub enum ModalSize {
4175    #[default]
4176    Default,
4177    Large,
4178}
4179
4180impl ModalSize {
4181    fn class(self) -> &'static str {
4182        match self {
4183            Self::Default => "",
4184            Self::Large => " wf-modal--lg",
4185        }
4186    }
4187}
4188
4189#[derive(Debug, Template)]
4190#[non_exhaustive]
4191#[template(path = "components/modal.html")]
4192pub struct Modal<'a> {
4193    pub title: &'a str,
4194    pub body_html: TrustedHtml<'a>,
4195    pub footer_html: Option<TrustedHtml<'a>>,
4196    pub open: bool,
4197    pub size: ModalSize,
4198}
4199
4200impl<'a> Modal<'a> {
4201    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
4202        Self {
4203            title,
4204            body_html,
4205            footer_html: None,
4206            open: false,
4207            size: ModalSize::Default,
4208        }
4209    }
4210
4211    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
4212        self.footer_html = Some(footer_html);
4213        self
4214    }
4215
4216    pub const fn open(mut self) -> Self {
4217        self.open = true;
4218        self
4219    }
4220
4221    pub const fn with_size(mut self, size: ModalSize) -> Self {
4222        self.size = size;
4223        self
4224    }
4225
4226    pub const fn large(self) -> Self {
4227        self.with_size(ModalSize::Large)
4228    }
4229
4230    pub fn overlay_class(&self) -> &'static str {
4231        if self.open {
4232            "wf-overlay is-open"
4233        } else {
4234            "wf-overlay"
4235        }
4236    }
4237
4238    pub fn modal_class(&self) -> String {
4239        let open = if self.open { " is-open" } else { "" };
4240        let size = self.size.class();
4241        format!("wf-modal{open}{size}")
4242    }
4243}
4244
4245impl<'a> Default for Modal<'a> {
4246    fn default() -> Self {
4247        Self::new("", TrustedHtml::new(""))
4248    }
4249}
4250
4251impl<'a> askama::filters::HtmlSafe for Modal<'a> {}
4252
4253#[derive(Debug, Template)]
4254#[non_exhaustive]
4255#[template(path = "components/drawer.html")]
4256pub struct Drawer<'a> {
4257    pub title: &'a str,
4258    pub body_html: TrustedHtml<'a>,
4259    pub footer_html: Option<TrustedHtml<'a>>,
4260    pub open: bool,
4261    pub left: bool,
4262}
4263
4264impl<'a> Drawer<'a> {
4265    pub const fn new(title: &'a str, body_html: TrustedHtml<'a>) -> Self {
4266        Self {
4267            title,
4268            body_html,
4269            footer_html: None,
4270            open: false,
4271            left: false,
4272        }
4273    }
4274
4275    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
4276        self.footer_html = Some(footer_html);
4277        self
4278    }
4279
4280    pub const fn open(mut self) -> Self {
4281        self.open = true;
4282        self
4283    }
4284
4285    pub const fn left(mut self) -> Self {
4286        self.left = true;
4287        self
4288    }
4289
4290    pub fn overlay_class(&self) -> &'static str {
4291        if self.open {
4292            "wf-overlay is-open"
4293        } else {
4294            "wf-overlay"
4295        }
4296    }
4297
4298    pub fn drawer_class(&self) -> String {
4299        let open = if self.open { " is-open" } else { "" };
4300        let left = if self.left { " left" } else { "" };
4301        format!("wf-drawer{open}{left}")
4302    }
4303}
4304
4305impl<'a> askama::filters::HtmlSafe for Drawer<'a> {}
4306
4307#[derive(Debug, Template)]
4308#[non_exhaustive]
4309#[template(path = "components/progress.html")]
4310pub struct Progress {
4311    pub value: Option<u8>,
4312}
4313
4314impl Progress {
4315    pub const fn new(value: u8) -> Self {
4316        Self { value: Some(value) }
4317    }
4318
4319    pub const fn indeterminate() -> Self {
4320        Self { value: None }
4321    }
4322
4323    pub fn class_name(&self) -> &'static str {
4324        if self.value.is_some() {
4325            "wf-progress"
4326        } else {
4327            "wf-progress indeterminate"
4328        }
4329    }
4330
4331    pub fn bounded_value(&self) -> u8 {
4332        self.value.unwrap_or(0).min(100)
4333    }
4334}
4335
4336impl askama::filters::HtmlSafe for Progress {}
4337
4338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4339pub enum MeterColor {
4340    Accent,
4341    Ok,
4342    Warn,
4343    Error,
4344    Info,
4345}
4346
4347impl MeterColor {
4348    fn css_var(self) -> &'static str {
4349        match self {
4350            Self::Accent => "var(--accent)",
4351            Self::Ok => "var(--ok)",
4352            Self::Warn => "var(--warn)",
4353            Self::Error => "var(--err)",
4354            Self::Info => "var(--info)",
4355        }
4356    }
4357}
4358
4359#[derive(Debug, Template)]
4360#[non_exhaustive]
4361#[template(path = "components/meter.html")]
4362pub struct Meter {
4363    pub value: u8,
4364    pub width_px: Option<u16>,
4365    pub height_px: Option<u16>,
4366    pub color: Option<MeterColor>,
4367}
4368
4369impl Meter {
4370    pub const fn new(value: u8) -> Self {
4371        Self {
4372            value,
4373            width_px: None,
4374            height_px: None,
4375            color: None,
4376        }
4377    }
4378
4379    pub const fn with_size_px(mut self, width_px: u16, height_px: u16) -> Self {
4380        self.width_px = Some(width_px);
4381        self.height_px = Some(height_px);
4382        self
4383    }
4384
4385    pub const fn with_color(mut self, color: MeterColor) -> Self {
4386        self.color = Some(color);
4387        self
4388    }
4389
4390    pub fn style(&self) -> String {
4391        let mut style = String::with_capacity(72);
4392        let _ = write!(&mut style, "--meter: {}%", self.value.min(100));
4393        if let Some(width) = self.width_px {
4394            let _ = write!(&mut style, "; --meter-w: {width}px");
4395        }
4396        if let Some(height) = self.height_px {
4397            let _ = write!(&mut style, "; --meter-h: {height}px");
4398        }
4399        if let Some(color) = self.color {
4400            style.push_str("; --meter-c: ");
4401            style.push_str(color.css_var());
4402        }
4403        style
4404    }
4405}
4406
4407impl askama::filters::HtmlSafe for Meter {}
4408
4409#[derive(Debug, Template)]
4410#[non_exhaustive]
4411#[template(path = "components/code_block.html")]
4412pub struct CodeBlock<'a> {
4413    pub code: &'a str,
4414    pub language: Option<&'a str>,
4415    pub label: Option<&'a str>,
4416    pub copy_target_id: Option<&'a str>,
4417    pub attrs: &'a [HtmlAttr<'a>],
4418}
4419
4420impl<'a> CodeBlock<'a> {
4421    pub const fn new(code: &'a str) -> Self {
4422        Self {
4423            code,
4424            language: None,
4425            label: None,
4426            copy_target_id: None,
4427            attrs: &[],
4428        }
4429    }
4430
4431    pub const fn with_language(mut self, language: &'a str) -> Self {
4432        self.language = Some(language);
4433        self
4434    }
4435
4436    pub const fn with_label(mut self, label: &'a str) -> Self {
4437        self.label = Some(label);
4438        self
4439    }
4440
4441    pub const fn with_copy_target(mut self, copy_target_id: &'a str) -> Self {
4442        self.copy_target_id = Some(copy_target_id);
4443        self
4444    }
4445
4446    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
4447        self.attrs = attrs;
4448        self
4449    }
4450}
4451
4452impl<'a> askama::filters::HtmlSafe for CodeBlock<'a> {}
4453
4454#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4455pub struct SnippetTab<'a> {
4456    pub label: &'a str,
4457    pub code: &'a str,
4458    pub language: Option<&'a str>,
4459    pub active: bool,
4460}
4461
4462impl<'a> SnippetTab<'a> {
4463    pub const fn new(label: &'a str, code: &'a str) -> Self {
4464        Self {
4465            label,
4466            code,
4467            language: None,
4468            active: false,
4469        }
4470    }
4471
4472    pub const fn with_language(mut self, language: &'a str) -> Self {
4473        self.language = Some(language);
4474        self
4475    }
4476
4477    pub const fn active(mut self) -> Self {
4478        self.active = true;
4479        self
4480    }
4481
4482    pub fn tab_class(&self) -> &'static str {
4483        if self.active {
4484            "wf-snippet-tab is-active"
4485        } else {
4486            "wf-snippet-tab"
4487        }
4488    }
4489
4490    pub fn panel_class(&self) -> &'static str {
4491        if self.active {
4492            "wf-snippet-panel is-active"
4493        } else {
4494            "wf-snippet-panel"
4495        }
4496    }
4497}
4498
4499#[derive(Debug, Template)]
4500#[non_exhaustive]
4501#[template(path = "components/snippet_tabs.html")]
4502pub struct SnippetTabs<'a> {
4503    pub id: &'a str,
4504    pub tabs: &'a [SnippetTab<'a>],
4505    pub attrs: &'a [HtmlAttr<'a>],
4506}
4507
4508impl<'a> SnippetTabs<'a> {
4509    pub const fn new(id: &'a str, tabs: &'a [SnippetTab<'a>]) -> Self {
4510        Self {
4511            id,
4512            tabs,
4513            attrs: &[],
4514        }
4515    }
4516
4517    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
4518        self.attrs = attrs;
4519        self
4520    }
4521}
4522
4523impl<'a> askama::filters::HtmlSafe for SnippetTabs<'a> {}
4524
4525#[derive(Debug, Template)]
4526#[non_exhaustive]
4527#[template(path = "components/strength_meter.html")]
4528pub struct StrengthMeter<'a> {
4529    pub value: u8,
4530    pub max: u8,
4531    pub text: &'a str,
4532    pub label: Option<&'a str>,
4533    pub kind: Option<FeedbackKind>,
4534    pub live: bool,
4535}
4536
4537impl<'a> StrengthMeter<'a> {
4538    pub const fn new(value: u8, max: u8, text: &'a str) -> Self {
4539        Self {
4540            value,
4541            max,
4542            text,
4543            label: None,
4544            kind: None,
4545            live: false,
4546        }
4547    }
4548
4549    pub const fn with_label(mut self, label: &'a str) -> Self {
4550        self.label = Some(label);
4551        self
4552    }
4553
4554    pub const fn with_feedback(mut self, feedback: FeedbackKind) -> Self {
4555        self.kind = Some(feedback);
4556        self
4557    }
4558
4559    pub const fn live(mut self) -> Self {
4560        self.live = true;
4561        self
4562    }
4563
4564    pub fn class_name(&self) -> &'static str {
4565        match self.kind {
4566            Some(FeedbackKind::Info) => "wf-strength-meter is-info",
4567            Some(FeedbackKind::Ok) => "wf-strength-meter is-ok",
4568            Some(FeedbackKind::Warn) => "wf-strength-meter is-warn",
4569            Some(FeedbackKind::Error) => "wf-strength-meter is-err",
4570            None => "wf-strength-meter",
4571        }
4572    }
4573
4574    pub fn bounded_value(&self) -> u8 {
4575        self.value.min(self.max)
4576    }
4577
4578    pub fn percentage(&self) -> u8 {
4579        if self.max == 0 {
4580            0
4581        } else {
4582            ((u16::from(self.bounded_value()) * 100) / u16::from(self.max)) as u8
4583        }
4584    }
4585
4586    pub fn style(&self) -> String {
4587        format!("--strength: {}%", self.percentage())
4588    }
4589}
4590
4591impl<'a> askama::filters::HtmlSafe for StrengthMeter<'a> {}
4592
4593#[derive(Debug, Template)]
4594#[non_exhaustive]
4595#[template(path = "components/kbd.html")]
4596pub struct Kbd<'a> {
4597    pub label: &'a str,
4598}
4599
4600impl<'a> Kbd<'a> {
4601    pub const fn new(label: &'a str) -> Self {
4602        Self { label }
4603    }
4604}
4605
4606impl<'a> askama::filters::HtmlSafe for Kbd<'a> {}
4607
4608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4609pub enum SkeletonKind {
4610    Line,
4611    Title,
4612    Block,
4613}
4614
4615impl SkeletonKind {
4616    fn class(self) -> &'static str {
4617        match self {
4618            Self::Line => "line",
4619            Self::Title => "title",
4620            Self::Block => "block",
4621        }
4622    }
4623}
4624
4625#[derive(Debug, Template)]
4626#[non_exhaustive]
4627#[template(path = "components/skeleton.html")]
4628pub struct Skeleton {
4629    pub kind: SkeletonKind,
4630}
4631
4632impl Skeleton {
4633    pub const fn line() -> Self {
4634        Self {
4635            kind: SkeletonKind::Line,
4636        }
4637    }
4638
4639    pub const fn title() -> Self {
4640        Self {
4641            kind: SkeletonKind::Title,
4642        }
4643    }
4644
4645    pub const fn block() -> Self {
4646        Self {
4647            kind: SkeletonKind::Block,
4648        }
4649    }
4650
4651    pub fn class_name(&self) -> String {
4652        format!("wf-skeleton {}", self.kind.class())
4653    }
4654}
4655
4656impl askama::filters::HtmlSafe for Skeleton {}
4657
4658#[derive(Debug, Template)]
4659#[non_exhaustive]
4660#[template(path = "components/spinner.html")]
4661pub struct Spinner {
4662    pub large: bool,
4663}
4664
4665impl Spinner {
4666    pub const fn new() -> Self {
4667        Self { large: false }
4668    }
4669
4670    pub const fn large() -> Self {
4671        Self { large: true }
4672    }
4673
4674    pub fn class_name(&self) -> &'static str {
4675        if self.large {
4676            "wf-spinner lg"
4677        } else {
4678            "wf-spinner"
4679        }
4680    }
4681}
4682
4683impl Default for Spinner {
4684    fn default() -> Self {
4685        Self::new()
4686    }
4687}
4688
4689impl askama::filters::HtmlSafe for Spinner {}
4690
4691#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4692pub enum ModelineSegmentKind {
4693    Default,
4694    Chevron,
4695    Flag,
4696    Buffer,
4697    Mode,
4698    Position,
4699    Progress,
4700}
4701
4702impl ModelineSegmentKind {
4703    fn class(self) -> &'static str {
4704        match self {
4705            Self::Default => "wf-ml-seg",
4706            Self::Chevron => "wf-ml-seg wf-ml-chevron",
4707            Self::Flag => "wf-ml-seg wf-ml-flag",
4708            Self::Buffer => "wf-ml-seg wf-ml-buffer",
4709            Self::Mode => "wf-ml-seg wf-ml-mode",
4710            Self::Position => "wf-ml-seg wf-ml-pos",
4711            Self::Progress => "wf-ml-seg wf-ml-progress",
4712        }
4713    }
4714}
4715
4716#[derive(Debug, Template)]
4717#[non_exhaustive]
4718#[template(path = "components/modeline_segment.html")]
4719pub struct ModelineSegment<'a> {
4720    pub label: &'a str,
4721    pub kind: ModelineSegmentKind,
4722    pub state: Option<FeedbackKind>,
4723    pub href: Option<&'a str>,
4724    pub button: bool,
4725    pub button_type: &'a str,
4726    pub active: bool,
4727    pub kbd: Option<&'a str>,
4728    pub html: Option<TrustedHtml<'a>>,
4729    pub attrs: &'a [HtmlAttr<'a>],
4730}
4731
4732impl<'a> ModelineSegment<'a> {
4733    pub const fn text(label: &'a str) -> Self {
4734        Self {
4735            label,
4736            kind: ModelineSegmentKind::Default,
4737            state: None,
4738            href: None,
4739            button: false,
4740            button_type: "button",
4741            active: false,
4742            kbd: None,
4743            html: None,
4744            attrs: &[],
4745        }
4746    }
4747
4748    pub const fn chevron(label: &'a str) -> Self {
4749        Self {
4750            kind: ModelineSegmentKind::Chevron,
4751            ..Self::text(label)
4752        }
4753    }
4754
4755    pub const fn flag(label: &'a str) -> Self {
4756        Self {
4757            kind: ModelineSegmentKind::Flag,
4758            ..Self::text(label)
4759        }
4760    }
4761
4762    pub const fn buffer(label: &'a str) -> Self {
4763        Self {
4764            kind: ModelineSegmentKind::Buffer,
4765            ..Self::text(label)
4766        }
4767    }
4768
4769    pub const fn mode(label: &'a str) -> Self {
4770        Self {
4771            kind: ModelineSegmentKind::Mode,
4772            ..Self::text(label)
4773        }
4774    }
4775
4776    pub const fn position(label: &'a str) -> Self {
4777        Self {
4778            kind: ModelineSegmentKind::Position,
4779            ..Self::text(label)
4780        }
4781    }
4782
4783    pub const fn progress(label: &'a str) -> Self {
4784        Self {
4785            kind: ModelineSegmentKind::Progress,
4786            ..Self::text(label)
4787        }
4788    }
4789
4790    pub const fn link(label: &'a str, href: &'a str) -> Self {
4791        Self {
4792            href: Some(href),
4793            ..Self::text(label)
4794        }
4795    }
4796
4797    pub const fn button(label: &'a str) -> Self {
4798        Self {
4799            button: true,
4800            ..Self::text(label)
4801        }
4802    }
4803
4804    pub const fn with_feedback(mut self, feedback: FeedbackKind) -> Self {
4805        self.state = Some(feedback);
4806        self
4807    }
4808
4809    pub const fn active(mut self) -> Self {
4810        self.active = true;
4811        self
4812    }
4813
4814    pub const fn with_kbd(mut self, kbd: &'a str) -> Self {
4815        self.kbd = Some(kbd);
4816        self
4817    }
4818
4819    pub const fn with_html(mut self, html: TrustedHtml<'a>) -> Self {
4820        self.html = Some(html);
4821        self
4822    }
4823
4824    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
4825        self.attrs = attrs;
4826        self
4827    }
4828
4829    pub const fn with_button_type(mut self, button_type: &'a str) -> Self {
4830        self.button_type = button_type;
4831        self
4832    }
4833
4834    pub fn class_name(&self) -> String {
4835        let mut class = String::from(self.kind.class());
4836        if self.href.is_some() || self.button || !self.attrs.is_empty() {
4837            class.push_str(" is-interactive");
4838        }
4839        if self.active {
4840            class.push_str(" is-active");
4841        }
4842        if let Some(kind) = self.state {
4843            class.push_str(" is-");
4844            class.push_str(kind.class());
4845        }
4846        class
4847    }
4848}
4849
4850impl<'a> askama::filters::HtmlSafe for ModelineSegment<'a> {}
4851
4852#[derive(Debug, Template)]
4853#[non_exhaustive]
4854#[template(path = "components/modeline.html")]
4855pub struct Modeline<'a> {
4856    pub left_segments: &'a [ModelineSegment<'a>],
4857    pub right_segments: &'a [ModelineSegment<'a>],
4858    pub fill: bool,
4859    pub attrs: &'a [HtmlAttr<'a>],
4860}
4861
4862impl<'a> Modeline<'a> {
4863    pub const fn new(left_segments: &'a [ModelineSegment<'a>]) -> Self {
4864        Self {
4865            left_segments,
4866            right_segments: &[],
4867            fill: true,
4868            attrs: &[],
4869        }
4870    }
4871
4872    pub const fn with_right(mut self, right_segments: &'a [ModelineSegment<'a>]) -> Self {
4873        self.right_segments = right_segments;
4874        self
4875    }
4876
4877    pub const fn without_fill(mut self) -> Self {
4878        self.fill = false;
4879        self
4880    }
4881
4882    pub const fn with_attrs(mut self, attrs: &'a [HtmlAttr<'a>]) -> Self {
4883        self.attrs = attrs;
4884        self
4885    }
4886}
4887
4888impl<'a> askama::filters::HtmlSafe for Modeline<'a> {}
4889
4890#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4891pub struct MinibufferHistoryRow<'a> {
4892    pub time: &'a str,
4893    pub message: &'a str,
4894    pub kind: Option<FeedbackKind>,
4895}
4896
4897impl<'a> MinibufferHistoryRow<'a> {
4898    pub const fn new(time: &'a str, message: &'a str) -> Self {
4899        Self {
4900            time,
4901            message,
4902            kind: None,
4903        }
4904    }
4905
4906    pub const fn with_feedback(mut self, feedback: FeedbackKind) -> Self {
4907        self.kind = Some(feedback);
4908        self
4909    }
4910
4911    pub fn class_name(&self) -> &'static str {
4912        match self.kind {
4913            Some(FeedbackKind::Info) => "row is-info",
4914            Some(FeedbackKind::Ok) => "row is-ok",
4915            Some(FeedbackKind::Warn) => "row is-warn",
4916            Some(FeedbackKind::Error) => "row is-err",
4917            None => "row",
4918        }
4919    }
4920}
4921
4922#[derive(Debug, Template)]
4923#[non_exhaustive]
4924#[template(path = "components/minibuffer.html")]
4925pub struct Minibuffer<'a> {
4926    pub prompt: &'a str,
4927    pub message: Option<&'a str>,
4928    pub kind: Option<FeedbackKind>,
4929    pub time: Option<&'a str>,
4930    pub history: &'a [MinibufferHistoryRow<'a>],
4931}
4932
4933impl<'a> Minibuffer<'a> {
4934    pub const fn new() -> Self {
4935        Self {
4936            prompt: ">",
4937            message: None,
4938            kind: None,
4939            time: None,
4940            history: &[],
4941        }
4942    }
4943
4944    pub const fn with_prompt(mut self, prompt: &'a str) -> Self {
4945        self.prompt = prompt;
4946        self
4947    }
4948
4949    pub const fn with_message(mut self, kind: FeedbackKind, message: &'a str) -> Self {
4950        self.kind = Some(kind);
4951        self.message = Some(message);
4952        self
4953    }
4954
4955    pub const fn with_time(mut self, time: &'a str) -> Self {
4956        self.time = Some(time);
4957        self
4958    }
4959
4960    pub const fn with_history(mut self, history: &'a [MinibufferHistoryRow<'a>]) -> Self {
4961        self.history = history;
4962        self
4963    }
4964
4965    pub const fn has_history(&self) -> bool {
4966        !self.history.is_empty()
4967    }
4968
4969    pub fn message_class(&self) -> String {
4970        match self.kind {
4971            Some(kind) if self.message.is_some() => {
4972                format!("wf-minibuffer-msg is-visible is-{}", kind.class())
4973            }
4974            _ => "wf-minibuffer-msg".to_owned(),
4975        }
4976    }
4977}
4978
4979impl<'a> Default for Minibuffer<'a> {
4980    fn default() -> Self {
4981        Self::new()
4982    }
4983}
4984
4985impl<'a> askama::filters::HtmlSafe for Minibuffer<'a> {}
4986
4987#[derive(Debug, Template)]
4988#[non_exhaustive]
4989#[template(path = "components/minibuffer_echo.html")]
4990pub struct MinibufferEcho<'a> {
4991    pub kind: FeedbackKind,
4992    pub message: &'a str,
4993}
4994
4995impl<'a> MinibufferEcho<'a> {
4996    pub const fn new(kind: FeedbackKind, message: &'a str) -> Self {
4997        Self { kind, message }
4998    }
4999
5000    pub const fn info(message: &'a str) -> Self {
5001        Self::new(FeedbackKind::Info, message)
5002    }
5003
5004    pub const fn ok(message: &'a str) -> Self {
5005        Self::new(FeedbackKind::Ok, message)
5006    }
5007
5008    pub const fn warn(message: &'a str) -> Self {
5009        Self::new(FeedbackKind::Warn, message)
5010    }
5011
5012    pub const fn error(message: &'a str) -> Self {
5013        Self::new(FeedbackKind::Error, message)
5014    }
5015
5016    pub fn kind_class(&self) -> &'static str {
5017        self.kind.class()
5018    }
5019}
5020
5021impl<'a> askama::filters::HtmlSafe for MinibufferEcho<'a> {}
5022
5023#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5024pub struct FeatureItem<'a> {
5025    pub title: &'a str,
5026    pub body: &'a str,
5027}
5028
5029impl<'a> FeatureItem<'a> {
5030    pub const fn new(title: &'a str, body: &'a str) -> Self {
5031        Self { title, body }
5032    }
5033}
5034
5035#[derive(Debug, Template)]
5036#[non_exhaustive]
5037#[template(path = "components/feature_grid.html")]
5038pub struct FeatureGrid<'a> {
5039    pub items: &'a [FeatureItem<'a>],
5040}
5041
5042impl<'a> FeatureGrid<'a> {
5043    pub const fn new(items: &'a [FeatureItem<'a>]) -> Self {
5044        Self { items }
5045    }
5046}
5047
5048impl<'a> askama::filters::HtmlSafe for FeatureGrid<'a> {}
5049
5050#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5051pub struct MarketingStep<'a> {
5052    pub title: &'a str,
5053    pub body: &'a str,
5054}
5055
5056impl<'a> MarketingStep<'a> {
5057    pub const fn new(title: &'a str, body: &'a str) -> Self {
5058        Self { title, body }
5059    }
5060}
5061
5062#[derive(Debug, Template)]
5063#[non_exhaustive]
5064#[template(path = "components/marketing_step_grid.html")]
5065pub struct MarketingStepGrid<'a> {
5066    pub steps: &'a [MarketingStep<'a>],
5067}
5068
5069impl<'a> MarketingStepGrid<'a> {
5070    pub const fn new(steps: &'a [MarketingStep<'a>]) -> Self {
5071        Self { steps }
5072    }
5073}
5074
5075impl<'a> askama::filters::HtmlSafe for MarketingStepGrid<'a> {}
5076
5077#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5078pub struct PricingPlan<'a> {
5079    pub name: &'a str,
5080    pub price: &'a str,
5081    pub unit: Option<&'a str>,
5082    pub blurb: Option<&'a str>,
5083    pub featured: bool,
5084}
5085
5086impl<'a> PricingPlan<'a> {
5087    pub const fn new(name: &'a str, price: &'a str) -> Self {
5088        Self {
5089            name,
5090            price,
5091            unit: None,
5092            blurb: None,
5093            featured: false,
5094        }
5095    }
5096
5097    pub const fn with_unit(mut self, unit: &'a str) -> Self {
5098        self.unit = Some(unit);
5099        self
5100    }
5101
5102    pub const fn with_blurb(mut self, blurb: &'a str) -> Self {
5103        self.blurb = Some(blurb);
5104        self
5105    }
5106
5107    pub const fn featured(mut self) -> Self {
5108        self.featured = true;
5109        self
5110    }
5111
5112    pub fn class_name(&self) -> &'static str {
5113        if self.featured {
5114            "wf-plan is-featured"
5115        } else {
5116            "wf-plan"
5117        }
5118    }
5119}
5120
5121#[derive(Debug, Template)]
5122#[non_exhaustive]
5123#[template(path = "components/pricing_plans.html")]
5124pub struct PricingPlans<'a> {
5125    pub plans: &'a [PricingPlan<'a>],
5126}
5127
5128impl<'a> PricingPlans<'a> {
5129    pub const fn new(plans: &'a [PricingPlan<'a>]) -> Self {
5130        Self { plans }
5131    }
5132}
5133
5134impl<'a> askama::filters::HtmlSafe for PricingPlans<'a> {}
5135
5136#[derive(Debug, Template)]
5137#[non_exhaustive]
5138#[template(path = "components/testimonial.html")]
5139pub struct Testimonial<'a> {
5140    pub quote_html: TrustedHtml<'a>,
5141    pub name: &'a str,
5142    pub role: &'a str,
5143}
5144
5145impl<'a> Testimonial<'a> {
5146    pub const fn new(quote_html: TrustedHtml<'a>, name: &'a str, role: &'a str) -> Self {
5147        Self {
5148            quote_html,
5149            name,
5150            role,
5151        }
5152    }
5153}
5154
5155impl<'a> askama::filters::HtmlSafe for Testimonial<'a> {}
5156
5157#[derive(Debug, Template)]
5158#[non_exhaustive]
5159#[template(path = "components/marketing_section.html")]
5160pub struct MarketingSection<'a> {
5161    pub title: &'a str,
5162    pub content_html: TrustedHtml<'a>,
5163    pub kicker: Option<&'a str>,
5164    pub subtitle: Option<&'a str>,
5165}
5166
5167impl<'a> MarketingSection<'a> {
5168    pub const fn new(title: &'a str, content_html: TrustedHtml<'a>) -> Self {
5169        Self {
5170            title,
5171            content_html,
5172            kicker: None,
5173            subtitle: None,
5174        }
5175    }
5176
5177    pub const fn with_kicker(mut self, kicker: &'a str) -> Self {
5178        self.kicker = Some(kicker);
5179        self
5180    }
5181
5182    pub const fn with_subtitle(mut self, subtitle: &'a str) -> Self {
5183        self.subtitle = Some(subtitle);
5184        self
5185    }
5186}
5187
5188impl<'a> askama::filters::HtmlSafe for MarketingSection<'a> {}
5189
5190#[cfg(test)]
5191mod tests {
5192    use super::*;
5193
5194    #[test]
5195    fn renders_button_with_htmx_attrs() {
5196        let attrs = [HtmlAttr::hx_post("/save?next=<home>")];
5197        let html = Button::primary("Save").with_attrs(&attrs).render().unwrap();
5198
5199        assert!(html.contains(r#"class="wf-btn primary""#));
5200        assert!(html.contains(r#"hx-post="/save?next="#));
5201        assert!(!html.contains(r#"hx-post="/save?next=<home>""#));
5202    }
5203
5204    #[test]
5205    fn field_escapes_copy_and_renders_trusted_control_html() {
5206        let html = Field::new(
5207            "Email <required>",
5208            TrustedHtml::new(r#"<input class="wf-input" name="email">"#),
5209        )
5210        .with_hint("Use <work> address")
5211        .render()
5212        .unwrap();
5213
5214        assert!(html.contains("Email"));
5215        assert!(!html.contains("Email <required>"));
5216        assert!(html.contains(r#"<input class="wf-input" name="email">"#));
5217        assert!(html.contains("Use"));
5218        assert!(!html.contains("Use <work> address"));
5219    }
5220
5221    #[test]
5222    fn trusted_html_writes_without_formatter_allocation() {
5223        let mut html = String::new();
5224
5225        askama::FastWritable::write_into(
5226            &TrustedHtml::new("<strong>Ready</strong>"),
5227            &mut html,
5228            askama::NO_VALUES,
5229        )
5230        .unwrap();
5231
5232        assert_eq!(html, "<strong>Ready</strong>");
5233    }
5234
5235    #[derive(Template)]
5236    #[template(source = "{{ button }}", ext = "html")]
5237    struct NestedButton<'a> {
5238        button: Button<'a>,
5239    }
5240
5241    #[test]
5242    fn nested_components_render_as_html() {
5243        let html = NestedButton {
5244            button: Button::primary("Save"),
5245        }
5246        .render()
5247        .unwrap();
5248
5249        assert!(html.contains("<button"));
5250        assert!(!html.contains("&lt;button"));
5251    }
5252
5253    #[test]
5254    fn action_primitives_render_wave_funk_markup() {
5255        let attrs = [HtmlAttr::hx_post("/actions/archive")];
5256        let buttons = [
5257            Button::new("Left"),
5258            Button::primary("Archive").with_attrs(&attrs),
5259        ];
5260
5261        let group_html = ButtonGroup::new(&buttons).render().unwrap();
5262        let split_html = SplitButton::new(Button::primary("Run"), Button::new("More"))
5263            .render()
5264            .unwrap();
5265        let icon_html = IconButton::new(TrustedHtml::new("&times;"), "Close")
5266            .with_variant(ButtonVariant::Ghost)
5267            .render()
5268            .unwrap();
5269
5270        assert!(group_html.contains(r#"class="wf-btn-group""#));
5271        assert!(group_html.contains(r#"hx-post="/actions/archive""#));
5272        assert!(split_html.contains(r#"class="wf-btn-split""#));
5273        assert!(split_html.contains(r#"class="wf-btn caret""#));
5274        assert!(icon_html.contains(r#"class="wf-icon-btn ghost""#));
5275        assert!(icon_html.contains(r#"aria-label="Close""#));
5276        assert!(icon_html.contains("&times;"));
5277    }
5278
5279    #[test]
5280    fn text_form_primitives_escape_copy_and_attrs() {
5281        let attrs = [HtmlAttr::hx_get("/validate/email")];
5282        let input_html = Input::email("email")
5283            .with_value("sandeep<wavefunk>")
5284            .with_placeholder("Email <address>")
5285            .with_attrs(&attrs)
5286            .render()
5287            .unwrap();
5288        let textarea_html = Textarea::new("notes")
5289            .with_value("Hello <team>")
5290            .with_placeholder("Notes <optional>")
5291            .render()
5292            .unwrap();
5293        let options = [
5294            SelectOption::new("starter", "Starter"),
5295            SelectOption::new("pro", "Pro <team>").selected(),
5296        ];
5297        let select_html = Select::new("plan", &options).render().unwrap();
5298
5299        assert!(input_html.contains(r#"class="wf-input""#));
5300        assert!(input_html.contains(r#"type="email""#));
5301        assert!(input_html.contains(r#"hx-get="/validate/email""#));
5302        assert!(!input_html.contains("sandeep<wavefunk>"));
5303        assert!(!input_html.contains("Email <address>"));
5304        assert!(textarea_html.contains(r#"class="wf-textarea""#));
5305        assert!(!textarea_html.contains("Hello <team>"));
5306        assert!(select_html.contains(r#"class="wf-select""#));
5307        assert!(select_html.contains(r#"value="pro" selected"#));
5308        assert!(!select_html.contains("Pro <team>"));
5309    }
5310
5311    #[test]
5312    fn grouped_choice_and_range_primitives_render_expected_classes() {
5313        let input_html = Input::url("site_url").render().unwrap();
5314        let group_html = InputGroup::new(TrustedHtml::new(&input_html))
5315            .with_prefix("https://")
5316            .with_suffix(".wavefunk.test")
5317            .render()
5318            .unwrap();
5319        let checkbox_html = CheckRow::checkbox("terms", "yes", "Accept <terms>")
5320            .checked()
5321            .render()
5322            .unwrap();
5323        let radio_html = CheckRow::radio("plan", "pro", "Pro").render().unwrap();
5324        let switch_html = Switch::new("enabled").checked().render().unwrap();
5325        let range_html = Range::new("volume")
5326            .with_bounds("0", "100")
5327            .with_value("50")
5328            .render()
5329            .unwrap();
5330        let field_html = Field::new("URL", TrustedHtml::new(&group_html))
5331            .with_state(FieldState::Success)
5332            .render()
5333            .unwrap();
5334
5335        assert!(group_html.contains(r#"class="wf-input-group""#));
5336        assert!(group_html.contains(r#"class="wf-input-addon">https://"#));
5337        assert!(checkbox_html.contains(r#"class="wf-check-row""#));
5338        assert!(checkbox_html.contains(r#"type="checkbox""#));
5339        assert!(checkbox_html.contains("checked"));
5340        assert!(!checkbox_html.contains("Accept <terms>"));
5341        assert!(radio_html.contains(r#"type="radio""#));
5342        assert!(switch_html.contains(r#"class="wf-switch""#));
5343        assert!(switch_html.contains("checked"));
5344        assert!(range_html.contains(r#"class="wf-range""#));
5345        assert!(range_html.contains(r#"min="0""#));
5346        assert!(field_html.contains(r#"class="wf-field is-success""#));
5347    }
5348
5349    #[test]
5350    fn layout_navigation_and_data_primitives_render_expected_markup() {
5351        let panel = Panel::new("Deployments", TrustedHtml::new("<p>Ready</p>"))
5352            .with_action(TrustedHtml::new(
5353                r#"<a class="wf-panel-link" href="/all">All</a>"#,
5354            ))
5355            .render()
5356            .unwrap();
5357        let card = Card::new("Project <alpha>", TrustedHtml::new("<p>Live</p>"))
5358            .with_kicker("Status")
5359            .raised()
5360            .render()
5361            .unwrap();
5362        let stats = [Stat::new("Requests", "42").with_unit("rpm")];
5363        let stat_row = StatRow::new(&stats).render().unwrap();
5364        let badge = Badge::muted("beta").render().unwrap();
5365        let avatar = Avatar::new("SN").accent().render().unwrap();
5366        let crumbs = [
5367            BreadcrumbItem::link("Projects", "/projects"),
5368            BreadcrumbItem::current("Wavefunk <UI>"),
5369        ];
5370        let breadcrumbs = Breadcrumbs::new(&crumbs).render().unwrap();
5371        let tabs = [
5372            TabItem::link("Overview", "/").active(),
5373            TabItem::link("Settings", "/settings"),
5374        ];
5375        let tab_html = Tabs::new(&tabs).render().unwrap();
5376        let segments = [
5377            SegmentOption::new("List", "list").active(),
5378            SegmentOption::new("Grid", "grid"),
5379        ];
5380        let seg_html = SegmentedControl::new(&segments).render().unwrap();
5381        let pages = [
5382            PageLink::link("1", "/page/1").active(),
5383            PageLink::ellipsis(),
5384            PageLink::disabled("Next"),
5385        ];
5386        let pagination = Pagination::new(&pages).render().unwrap();
5387        let nav_section = NavSection::new("Workspace").render().unwrap();
5388        let nav_item = NavItem::new("Dashboard", "/").active().with_count("3");
5389        let topbar = Topbar::new(TrustedHtml::new(&breadcrumbs), TrustedHtml::new(&badge))
5390            .render()
5391            .unwrap();
5392        let statusbar = Statusbar::new("Connected", "v0.1").render().unwrap();
5393        let empty = EmptyState::new("No hooks", "Create a hook to start.")
5394            .with_glyph(TrustedHtml::new("&empty;"))
5395            .bordered()
5396            .render()
5397            .unwrap();
5398        let table_headers = [TableHeader::new("Name"), TableHeader::numeric("Runs")];
5399        let table_cells = [TableCell::strong("Build <main>"), TableCell::numeric("12")];
5400        let table_rows = [TableRow::new(&table_cells).selected()];
5401        let table = Table::new(&table_headers, &table_rows)
5402            .interactive()
5403            .render()
5404            .unwrap();
5405        let dl_items = [DefinitionItem::new("Runtime", "Rust <stable>")];
5406        let dl = DefinitionList::new(&dl_items).render().unwrap();
5407        let grid = Grid::new(TrustedHtml::new(&card))
5408            .with_columns(2)
5409            .render()
5410            .unwrap();
5411        let split = Split::new(TrustedHtml::new(&panel))
5412            .vertical()
5413            .render()
5414            .unwrap();
5415
5416        assert!(panel.contains(r#"class="wf-panel""#));
5417        assert!(card.contains(r#"class="wf-card is-raised""#));
5418        assert!(!card.contains("Project <alpha>"));
5419        assert!(stat_row.contains(r#"class="wf-stat-row""#));
5420        assert!(badge.contains(r#"class="wf-badge muted""#));
5421        assert!(avatar.contains(r#"class="wf-avatar accent""#));
5422        assert!(breadcrumbs.contains(r#"class="wf-crumbs""#));
5423        assert!(!breadcrumbs.contains("Wavefunk <UI>"));
5424        assert!(tab_html.contains(r#"class="wf-tabs""#));
5425        assert!(seg_html.contains(r#"class="wf-seg""#));
5426        assert!(pagination.contains(r#"class="wf-pagination""#));
5427        assert!(nav_section.contains(r#"class="wf-nav-section""#));
5428        assert!(
5429            nav_item
5430                .render()
5431                .unwrap()
5432                .contains(r#"class="wf-nav-item is-active""#)
5433        );
5434        assert!(topbar.contains(r#"class="wf-topbar""#));
5435        assert!(statusbar.contains(r#"class="wf-statusbar wf-hair""#));
5436        assert!(empty.contains(r#"class="wf-empty bordered""#));
5437        assert!(table.contains(r#"class="wf-table is-interactive""#));
5438        assert!(!table.contains("Build <main>"));
5439        assert!(dl.contains(r#"class="wf-dl""#));
5440        assert!(!dl.contains("Rust <stable>"));
5441        assert!(grid.contains(r#"class="wf-grid cols-2""#));
5442        assert!(split.contains(r#"class="wf-split vertical""#));
5443    }
5444
5445    #[test]
5446    fn page_header_supports_title_meta_back_and_action_slots() {
5447        let primary = Button::primary("Create").render().unwrap();
5448        let secondary = Button::new("Export").render().unwrap();
5449        let header = PageHeader::new("Deployments <prod>")
5450            .with_subtitle("Filtered by team <ops>")
5451            .with_back("/settings", "Settings")
5452            .with_meta(TrustedHtml::new(
5453                r#"<span class="wf-badge muted">12</span>"#,
5454            ))
5455            .with_primary(TrustedHtml::new(&primary))
5456            .with_secondary(TrustedHtml::new(&secondary))
5457            .render()
5458            .unwrap();
5459
5460        assert!(header.contains(r#"class="wf-pageheader""#));
5461        assert!(header.contains(r#"class="wf-pageheader-main""#));
5462        assert!(header.contains(r#"<a class="wf-backlink" href="/settings">"#));
5463        assert!(header.contains(">Settings<"));
5464        assert!(header.contains(r#"class="wf-pagetitle""#));
5465        assert!(!header.contains("Deployments <prod>"));
5466        assert!(header.contains(r#"class="wf-pageheader-subtitle""#));
5467        assert!(!header.contains("Filtered by team <ops>"));
5468        assert!(header.contains(r#"<span class="wf-badge muted">12</span>"#));
5469        assert!(header.contains(r#"class="wf-pageheader-actions""#));
5470        assert!(header.contains(">Create<"));
5471        assert!(header.contains(">Export<"));
5472    }
5473
5474    #[test]
5475    fn feedback_overlay_and_loading_primitives_render_expected_markup() {
5476        let callout = Callout::new(FeedbackKind::Warn, TrustedHtml::new("<p>Heads up</p>"))
5477            .with_title("Warning")
5478            .render()
5479            .unwrap();
5480        let toast = Toast::new(FeedbackKind::Ok, "Saved <now>")
5481            .render()
5482            .unwrap();
5483        let toast_host = ToastHost::new().render().unwrap();
5484        let tooltip = Tooltip::new("Copy id", TrustedHtml::new(r#"<button>copy</button>"#))
5485            .render()
5486            .unwrap();
5487        let menu_items = [
5488            MenuItem::button("Open"),
5489            MenuItem::link("Settings", "/settings"),
5490            MenuItem::separator(),
5491            MenuItem::button("Delete").danger(),
5492        ];
5493        let menu = Menu::new(&menu_items).render().unwrap();
5494        let popover = Popover::new(
5495            TrustedHtml::new(r#"<button data-popover-toggle>Open</button>"#),
5496            TrustedHtml::new(&menu),
5497        )
5498        .with_heading("Menu")
5499        .open()
5500        .render()
5501        .unwrap();
5502        let modal = Modal::new("Confirm", TrustedHtml::new("<p>Continue?</p>"))
5503            .with_footer(TrustedHtml::new(
5504                r#"<button class="wf-btn primary">Confirm</button>"#,
5505            ))
5506            .open()
5507            .render()
5508            .unwrap();
5509        let drawer = Drawer::new("Details", TrustedHtml::new("<p>Side sheet</p>"))
5510            .left()
5511            .open()
5512            .render()
5513            .unwrap();
5514        let skeleton = Skeleton::title().render().unwrap();
5515        let spinner = Spinner::large().render().unwrap();
5516        let minibuffer = Minibuffer::new()
5517            .with_message(FeedbackKind::Info, "Queued <job>")
5518            .with_time("09:41")
5519            .render()
5520            .unwrap();
5521        let minibuffer_echo = MinibufferEcho::warn("Queued <job>").render().unwrap();
5522
5523        assert!(callout.contains(r#"class="wf-callout warn""#));
5524        assert!(toast.contains(r#"class="wf-toast ok""#));
5525        assert!(!toast.contains("Saved <now>"));
5526        assert!(toast_host.contains(r#"class="wf-toast-host""#));
5527        assert!(tooltip.contains(r#"class="wf-tooltip""#));
5528        assert!(tooltip.contains(r#"data-tip="Copy id""#));
5529        assert!(menu.contains(r#"class="wf-menu""#));
5530        assert!(menu.contains(r#"class="wf-menu-sep""#));
5531        assert!(popover.contains(r#"class="wf-popover is-open""#));
5532        assert!(modal.contains(r#"class="wf-modal is-open""#));
5533        assert!(modal.contains(r#"class="wf-overlay is-open""#));
5534        assert!(modal.contains(r#"data-wf-dismiss="overlay""#));
5535        assert!(drawer.contains(r#"class="wf-drawer is-open left""#));
5536        assert!(drawer.contains(r#"data-wf-dismiss="overlay""#));
5537        assert!(skeleton.contains(r#"class="wf-skeleton title""#));
5538        assert!(spinner.contains(r#"class="wf-spinner lg""#));
5539        assert!(minibuffer.contains(r#"class="wf-minibuffer""#));
5540        assert!(minibuffer.contains("data-wf-echo"));
5541        assert!(!minibuffer.contains("Queued <job>"));
5542        assert!(minibuffer_echo.contains(r#"hidden"#));
5543        assert!(minibuffer_echo.contains(r#"data-wf-echo-kind="warn""#));
5544        assert!(minibuffer_echo.contains(r#"data-wf-echo-message="Queued "#));
5545        assert!(!minibuffer_echo.contains("Queued <job>"));
5546    }
5547
5548    #[test]
5549    fn modal_size_and_spacing_utilities_cover_large_overlay_layouts() {
5550        let modal = Modal::new("Edit record", TrustedHtml::new("<p>Large form</p>"))
5551            .large()
5552            .open()
5553            .render()
5554            .unwrap();
5555        let components_css = include_str!("../static/wavefunk/css/04-components.css");
5556        let utilities_css = include_str!("../static/wavefunk/css/05-utilities.css");
5557
5558        assert!(modal.contains(r#"class="wf-modal is-open wf-modal--lg""#));
5559        assert!(components_css.contains(".wf-modal--lg"));
5560        assert!(utilities_css.contains(".wf-mb-1 { margin-bottom: 4px; }"));
5561        assert!(utilities_css.contains(".wf-mb-8 { margin-bottom: 32px; }"));
5562        assert!(utilities_css.contains(".wf-ml-2 { margin-left: 8px; }"));
5563    }
5564
5565    #[test]
5566    fn form_composition_and_dropzone_components_render_expected_markup() {
5567        let input_html = Input::email("email")
5568            .with_placeholder("you@example.test")
5569            .render()
5570            .unwrap();
5571        let field_html = Field::new("Email", TrustedHtml::new(&input_html))
5572            .with_hint("Use <work> address")
5573            .render()
5574            .unwrap();
5575        let actions_html = FormActions::new(TrustedHtml::new(
5576            r#"<button class="wf-btn primary">Save</button>"#,
5577        ))
5578        .with_secondary(TrustedHtml::new(
5579            r#"<button class="wf-btn">Cancel</button>"#,
5580        ))
5581        .render()
5582        .unwrap();
5583        let section_html = FormSection::new("Profile <setup>", TrustedHtml::new(&field_html))
5584            .with_description("Shown to teammates <public>")
5585            .render()
5586            .unwrap();
5587        let attrs = [HtmlAttr::hx_post("/profile")];
5588        let form_html = Form::new(TrustedHtml::new(&section_html))
5589            .with_action("/profile/save?next=<home>")
5590            .with_method("post")
5591            .with_attrs(&attrs)
5592            .render()
5593            .unwrap();
5594        let dropzone_attrs = [HtmlAttr::new("data-intent", "avatar <upload>")];
5595        let dropzone_html = Dropzone::new("avatar")
5596            .with_title("Drop avatar <image>")
5597            .with_hint("PNG or JPG <2MB>")
5598            .with_accept("image/png,image/jpeg")
5599            .with_attrs(&dropzone_attrs)
5600            .multiple()
5601            .disabled()
5602            .dragover()
5603            .render()
5604            .unwrap();
5605
5606        assert!(actions_html.contains(r#"class="wf-form-actions""#));
5607        assert!(section_html.contains(r#"class="wf-form-section""#));
5608        assert!(!section_html.contains("Profile <setup>"));
5609        assert!(!section_html.contains("Shown to teammates <public>"));
5610        assert!(form_html.contains(r#"<form class="wf-form""#));
5611        assert!(form_html.contains(r#"method="post""#));
5612        assert!(form_html.contains(r#"hx-post="/profile""#));
5613        assert!(!form_html.contains(r#"action="/profile/save?next=<home>""#));
5614        assert!(dropzone_html.contains(r#"class="wf-dropzone is-dragover is-disabled""#));
5615        assert!(dropzone_html.contains("data-upload-zone"));
5616        assert!(dropzone_html.contains(r#"type="file""#));
5617        assert!(dropzone_html.contains(r#"multiple"#));
5618        assert!(dropzone_html.contains(r#"disabled"#));
5619        assert!(dropzone_html.contains(r#"accept="image/png,image/jpeg""#));
5620        assert!(dropzone_html.contains(r#"data-intent="avatar "#));
5621        assert!(!dropzone_html.contains(r#"data-intent="avatar <upload>""#));
5622        assert!(!dropzone_html.contains("Drop avatar <image>"));
5623        assert!(!dropzone_html.contains("PNG or JPG <2MB>"));
5624    }
5625
5626    #[test]
5627    fn generated_form_building_blocks_render_generic_schema_shapes() {
5628        let title = Input::new("title").render().unwrap();
5629        let title_field = Field::new("Title", TrustedHtml::new(&title))
5630            .render()
5631            .unwrap();
5632        let object = ObjectFieldset::new("Metadata", TrustedHtml::new(&title_field))
5633            .with_description("Nested object fields")
5634            .render()
5635            .unwrap();
5636        let item = RepeatableItem::new("Link 1", TrustedHtml::new(&title_field))
5637            .with_actions(TrustedHtml::new(
5638                r#"<button class="wf-btn sm">Remove</button>"#,
5639            ))
5640            .render()
5641            .unwrap();
5642        let array = RepeatableArray::new("Links", TrustedHtml::new(&item))
5643            .with_description("Zero or more external links.")
5644            .with_action(TrustedHtml::new(
5645                r#"<button class="wf-btn sm">Add link</button>"#,
5646            ))
5647            .render()
5648            .unwrap();
5649        let upload = CurrentUpload::new("Hero image", "/media/hero.jpg", "hero.jpg")
5650            .with_meta("1200x630 JPG")
5651            .with_thumbnail(TrustedHtml::new(r#"<img src="/media/hero.jpg" alt="">"#))
5652            .render()
5653            .unwrap();
5654        let options = [
5655            SelectOption::new("home", "Home"),
5656            SelectOption::new("about", "About").selected(),
5657        ];
5658        let select = Select::new("related_page", &options).render().unwrap();
5659        let reference = ReferenceSelect::new("Related page", TrustedHtml::new(&select))
5660            .with_hint("Search and choose another record.")
5661            .render()
5662            .unwrap();
5663        let markdown = MarkdownTextarea::new("body")
5664            .with_value("# Hello")
5665            .with_rows(8)
5666            .render()
5667            .unwrap();
5668        let richtext = RichTextHost::new("body-editor", "body_html")
5669            .with_value("<p>Hello</p>")
5670            .with_toolbar(TrustedHtml::new(
5671                r#"<button class="wf-btn sm">Bold</button>"#,
5672            ))
5673            .render()
5674            .unwrap();
5675
5676        assert!(object.contains(r#"<fieldset class="wf-object-fieldset">"#));
5677        assert!(object.contains(r#"<legend class="wf-object-legend">Metadata</legend>"#));
5678        assert!(array.contains(r#"class="wf-repeatable-array""#));
5679        assert!(array.contains(r#"class="wf-repeatable-item""#));
5680        assert!(upload.contains(r#"class="wf-current-upload""#));
5681        assert!(upload.contains(r#"<a href="/media/hero.jpg">hero.jpg</a>"#));
5682        assert!(reference.contains(r#"class="wf-reference-select""#));
5683        assert!(markdown.contains(r#"class="wf-textarea wf-markdown-textarea""#));
5684        assert!(markdown.contains("data-wf-markdown"));
5685        assert!(richtext.contains(r#"class="wf-richtext""#));
5686        assert!(richtext.contains("data-wf-richtext"));
5687        assert!(richtext.contains(r#"data-wf-richtext-modal-host"#));
5688    }
5689
5690    #[test]
5691    fn table_workflow_components_support_sorting_actions_and_chrome() {
5692        let _source_compatible_header = TableHeader {
5693            label: "Legacy",
5694            numeric: false,
5695        };
5696        let _source_compatible_cell = TableCell {
5697            text: "Legacy",
5698            numeric: false,
5699            strong: false,
5700            muted: false,
5701        };
5702        let headers = [
5703            DataTableHeader::new("Name").sortable("name", SortDirection::Ascending),
5704            DataTableHeader::numeric("Runs").with_width(TableColumnWidth::Small),
5705            DataTableHeader::new("Actions").action_column(),
5706        ];
5707        let actions = IconButton::new(TrustedHtml::new("&times;"), "Stop")
5708            .with_variant(ButtonVariant::Danger)
5709            .render()
5710            .unwrap();
5711        let row_cells = [
5712            DataTableCell::strong("Build <main>"),
5713            DataTableCell::numeric("12"),
5714            DataTableCell::html(TrustedHtml::new(&actions)),
5715        ];
5716        let rows = [DataTableRow::new(&row_cells).selected()];
5717        let filter_html = Input::new("q")
5718            .with_size(ControlSize::Small)
5719            .with_placeholder("Search")
5720            .render()
5721            .unwrap();
5722        let bulk_html = Button::new("Delete").render().unwrap();
5723        let table_html = DataTable::new(&headers, &rows)
5724            .interactive()
5725            .sticky()
5726            .pin_last()
5727            .render()
5728            .unwrap();
5729        let wrap_html = TableWrap::new(TrustedHtml::new(&table_html))
5730            .with_filterbar(TrustedHtml::new(&filter_html))
5731            .with_bulkbar("1 selected", TrustedHtml::new(&bulk_html))
5732            .with_footer(TrustedHtml::new("Showing 1-1 of 1"))
5733            .render()
5734            .unwrap();
5735
5736        assert!(table_html.contains(r#"class="wf-sort-h is-active""#));
5737        assert!(table_html.contains(r#"data-sort-key="name""#));
5738        assert!(table_html.contains(r#"class="wf-sort-arrow">^"#));
5739        assert!(table_html.contains(r#"class="wf-col-sm num""#));
5740        assert!(table_html.contains(r#"class="wf-col-act""#));
5741        assert!(table_html.contains("&times;"));
5742        assert!(!table_html.contains("Build <main>"));
5743        assert!(wrap_html.contains(r#"class="wf-tablewrap""#));
5744        assert!(wrap_html.contains(r#"class="wf-filterbar""#));
5745        assert!(wrap_html.contains(r#"class="wf-bulkbar""#));
5746        assert!(wrap_html.contains(r#"class="wf-tablefoot""#));
5747    }
5748
5749    #[test]
5750    fn resource_table_chrome_components_compose_filter_bulk_footer_and_selection() {
5751        let filter_input = Input::search("q")
5752            .with_placeholder("Search resources")
5753            .with_size(ControlSize::Small)
5754            .render()
5755            .unwrap();
5756        let filter_action = Button::new("Refresh").render().unwrap();
5757        let filterbar = FilterBar::new(TrustedHtml::new(&filter_input))
5758            .with_actions(TrustedHtml::new(&filter_action))
5759            .render()
5760            .unwrap();
5761        let bulk_action = Button::new("Delete")
5762            .with_variant(ButtonVariant::Danger)
5763            .render()
5764            .unwrap();
5765        let bulkbar = BulkActionBar::new("2 selected", TrustedHtml::new(&bulk_action))
5766            .render()
5767            .unwrap();
5768        let footer_action = Pagination::new(&[
5769            PageLink::link("1", "/page/1").active(),
5770            PageLink::link("2", "/page/2"),
5771        ])
5772        .render()
5773        .unwrap();
5774        let footer = TableFooter::new(TrustedHtml::new("Showing 1-2 of 8"))
5775            .with_actions(TrustedHtml::new(&footer_action))
5776            .render()
5777            .unwrap();
5778        let selector = RowSelect::new("selected", "build", "Select Build")
5779            .checked()
5780            .render()
5781            .unwrap();
5782        let headers = [
5783            DataTableHeader::new("").with_width(TableColumnWidth::Checkbox),
5784            DataTableHeader::sorted("Name", "name", SortDirection::Ascending),
5785        ];
5786        let cells = [
5787            DataTableCell::html(TrustedHtml::new(&selector)),
5788            DataTableCell::strong("Build"),
5789        ];
5790        let rows = [DataTableRow::new(&cells).selected()];
5791        let table = DataTable::new(&headers, &rows)
5792            .interactive()
5793            .render()
5794            .unwrap();
5795        let wrap = TableWrap::new(TrustedHtml::new(&table))
5796            .with_filterbar_component(TrustedHtml::new(&filterbar))
5797            .with_bulkbar_component(TrustedHtml::new(&bulkbar))
5798            .with_footer_component(TrustedHtml::new(&footer))
5799            .render()
5800            .unwrap();
5801
5802        assert!(filterbar.contains(r#"class="wf-filterbar""#));
5803        assert!(filterbar.contains(r#"class="wf-filterbar-actions""#));
5804        assert!(bulkbar.contains(r#"class="wf-bulkbar""#));
5805        assert!(bulkbar.contains(r#"class="wf-sel-count">2 selected"#));
5806        assert!(footer.contains(r#"class="wf-tablefoot""#));
5807        assert!(footer.contains(r#"class="wf-tablefoot-actions""#));
5808        assert!(selector.contains(r#"class="wf-check wf-rowselect""#));
5809        assert!(selector.contains(r#"aria-label="Select Build""#));
5810        assert!(selector.contains("checked"));
5811        assert!(table.contains(r#"data-sort-key="name""#));
5812        assert!(wrap.matches(r#"class="wf-filterbar""#).count() == 1);
5813        assert!(wrap.matches(r#"class="wf-bulkbar""#).count() == 1);
5814        assert!(wrap.matches(r#"class="wf-tablefoot""#).count() == 1);
5815    }
5816
5817    #[test]
5818    fn progress_stepper_and_disclosure_components_render_expected_markup() {
5819        let progress = Progress::new(60).render().unwrap();
5820        let indeterminate = Progress::indeterminate().render().unwrap();
5821        let meter = Meter::new(75)
5822            .with_size_px(96, 6)
5823            .with_color(MeterColor::Ok)
5824            .render()
5825            .unwrap();
5826        let kbd = Kbd::new("Ctrl <K>").render().unwrap();
5827        let steps = [
5828            StepItem::new("Account").done(),
5829            StepItem::new("Profile <public>")
5830                .active()
5831                .with_href("/profile"),
5832            StepItem::new("Invite"),
5833        ];
5834        let stepper = Stepper::new(&steps).render().unwrap();
5835        let accordion_items = [
5836            AccordionItem::new("What is <UI>?", TrustedHtml::new("<p>Typed</p>")).open(),
5837            AccordionItem::new("Can it htmx?", TrustedHtml::new("<p>Yes</p>")),
5838        ];
5839        let accordion = Accordion::new(&accordion_items).render().unwrap();
5840        let faq_items = [FaqItem::new(
5841            "Why typed?",
5842            TrustedHtml::new("<p>To preserve semver.</p>"),
5843        )];
5844        let faq = Faq::new(&faq_items).render().unwrap();
5845
5846        assert!(progress.contains(r#"class="wf-progress""#));
5847        assert!(progress.contains(r#"style="--progress: 60%""#));
5848        assert!(indeterminate.contains(r#"class="wf-progress indeterminate""#));
5849        assert!(meter.contains(
5850            r#"style="--meter: 75%; --meter-w: 96px; --meter-h: 6px; --meter-c: var(--ok)""#
5851        ));
5852        assert!(kbd.contains(r#"class="wf-kbd""#));
5853        assert!(!kbd.contains("Ctrl <K>"));
5854        assert!(stepper.contains(r#"class="wf-step is-done""#));
5855        assert!(stepper.contains(r#"aria-current="step""#));
5856        assert!(!stepper.contains("Profile <public>"));
5857        assert!(accordion.contains(r#"class="wf-accordion""#));
5858        assert!(accordion.contains(r#"<details class="wf-accordion-item" open>"#));
5859        assert!(!accordion.contains("What is <UI>?"));
5860        assert!(faq.contains(r#"class="wf-faq""#));
5861        assert!(faq.contains("<p>To preserve semver.</p>"));
5862    }
5863
5864    #[test]
5865    fn identity_brand_and_operational_components_render_expected_markup() {
5866        let avatars = [
5867            Avatar::new("SN").with_image("/avatar.png").accent(),
5868            Avatar::new("WF").with_size(AvatarSize::Small),
5869        ];
5870        let avatar_group = AvatarGroup::new(&avatars).render().unwrap();
5871        let full_user = UserButton::new("Wave Funk", "team@example.test", Avatar::new("WF"))
5872            .render()
5873            .unwrap();
5874        let user = UserButton::new(
5875            "Sandeep <Nambiar>",
5876            "sandeep@example.test",
5877            Avatar::new("SN"),
5878        )
5879        .compact()
5880        .render()
5881        .unwrap();
5882        let wordmark = Wordmark::new("Wave <Funk>")
5883            .with_mark(TrustedHtml::new(r#"<svg class="wf-mark"></svg>"#))
5884            .render()
5885            .unwrap();
5886        let ranks = [RankRow::new("Builds <main>", "42", 72)];
5887        let rank_list = RankList::new(&ranks).render().unwrap();
5888        let feed_rows = [FeedRow::new("09:41", "Deploy <prod>", "Released <v1>")];
5889        let feed = Feed::new(&feed_rows).render().unwrap();
5890        let timeline_items =
5891            [
5892                TimelineItem::new("09:42", "Queued <job>", TrustedHtml::new("<p>Pending</p>"))
5893                    .active(),
5894            ];
5895        let timeline = Timeline::new(&timeline_items).render().unwrap();
5896        let tree_children = [TreeItem::file("components.rs").active()];
5897        let tree_child_html = TreeView::new(&tree_children).nested().render().unwrap();
5898        let tree_items = [TreeItem::folder("src <root>")
5899            .collapsed()
5900            .with_children(TrustedHtml::new(&tree_child_html))];
5901        let tree = TreeView::new(&tree_items).render().unwrap();
5902        let framed = Framed::new(TrustedHtml::new("<code>cargo test</code>"))
5903            .dense()
5904            .dashed()
5905            .render()
5906            .unwrap();
5907
5908        assert!(avatar_group.contains(r#"class="wf-avatar-group""#));
5909        assert!(avatar_group.contains(r#"<img src="/avatar.png" alt="SN">"#));
5910        assert!(full_user.contains(r#"class="wf-user""#));
5911        assert!(!full_user.contains(r#"class="wf-user compact""#));
5912        assert!(user.contains(r#"class="wf-user compact""#));
5913        assert!(!user.contains("Sandeep <Nambiar>"));
5914        assert!(wordmark.contains(r#"class="wf-wordmark""#));
5915        assert!(wordmark.contains(r#"<svg class="wf-mark"></svg>"#));
5916        assert!(!wordmark.contains("Wave <Funk>"));
5917        assert!(rank_list.contains(r#"class="wf-rank""#));
5918        assert!(rank_list.contains(r#"style="width: 72%""#));
5919        assert!(!rank_list.contains("Builds <main>"));
5920        assert!(feed.contains(r#"class="wf-feed""#));
5921        assert!(!feed.contains("Deploy <prod>"));
5922        assert!(!feed.contains("Released <v1>"));
5923        assert!(timeline.contains(r#"class="wf-timeline-item is-active""#));
5924        assert!(!timeline.contains("Queued <job>"));
5925        assert!(tree.contains(r#"class="wf-tree""#));
5926        assert!(tree.contains(r#"class="is-collapsed""#));
5927        assert!(!tree.contains("src <root>"));
5928        assert!(framed.contains(r#"class="wf-framed dense dashed""#));
5929    }
5930
5931    #[test]
5932    fn settings_and_admin_workflow_primitives_render_generic_markup() {
5933        let input = Input::email("email").render().unwrap();
5934        let save = Button::primary("Save")
5935            .with_button_type("submit")
5936            .render()
5937            .unwrap();
5938        let row = InlineFormRow::new("Notification email", TrustedHtml::new(&input))
5939            .with_hint("Used for account notices <private>")
5940            .with_action(TrustedHtml::new(&save))
5941            .render()
5942            .unwrap();
5943        let copy = CopyableValue::new("Webhook URL", "webhook-url", "https://example.test/hook")
5944            .with_button_label("Copy URL")
5945            .render()
5946            .unwrap();
5947        let statuses = [
5948            CredentialStatusItem::ok("Mail", "Configured"),
5949            CredentialStatusItem::warn("Backups", "Rotation due"),
5950        ];
5951        let status_list = CredentialStatusList::new(&statuses).render().unwrap();
5952        let confirm = ConfirmAction::new("Delete workspace", "/settings/delete")
5953            .with_message("This cannot be undone.")
5954            .with_confirm("Delete this workspace?")
5955            .render()
5956            .unwrap();
5957        let section_body = format!("{row}{copy}{status_list}{confirm}");
5958        let section = SettingsSection::new("Workspace settings", TrustedHtml::new(&section_body))
5959            .with_description("Operational settings for this app.")
5960            .danger()
5961            .render()
5962            .unwrap();
5963
5964        assert!(row.contains(r#"class="wf-inline-form-row""#));
5965        assert!(!row.contains("account notices <private>"));
5966        assert!(copy.contains(r#"class="wf-copyable""#));
5967        assert!(copy.contains(r#"id="webhook-url""#));
5968        assert!(copy.contains(r##"data-wf-copy="#webhook-url""##));
5969        assert!(copy.contains(">Copy URL<"));
5970        assert!(status_list.contains(r#"class="wf-credential-list""#));
5971        assert!(status_list.contains(r#"class="wf-tag ok""#));
5972        assert!(status_list.contains(r#"class="wf-tag warn""#));
5973        assert!(confirm.contains(
5974            r#"<form class="wf-confirm-action" action="/settings/delete" method="post">"#
5975        ));
5976        assert!(confirm.contains(r#"hx-confirm="Delete this workspace?""#));
5977        assert!(confirm.contains(r#"class="wf-btn danger""#));
5978        assert!(section.contains(r#"class="wf-panel wf-settings-section is-danger""#));
5979        assert!(section.contains("Operational settings for this app."));
5980    }
5981
5982    #[test]
5983    fn split_shell_and_form_panel_render_generic_setup_surfaces() {
5984        let actions = Button::primary("Continue").render().unwrap();
5985        let panel = FormPanel::new(
5986            "Setup <workspace>",
5987            TrustedHtml::new(r#"<form class="wf-form">Fields</form>"#),
5988        )
5989        .with_subtitle("Use generic product copy <only>")
5990        .with_actions(TrustedHtml::new(&actions))
5991        .render()
5992        .unwrap();
5993        let attrs = [HtmlAttr::new("data-surface", "setup <flow>")];
5994        let shell = SplitShell::new(TrustedHtml::new(&panel))
5995            .with_top(TrustedHtml::new(
5996                r#"<a class="wf-btn ghost" href="/">Back</a>"#,
5997            ))
5998            .with_visual(TrustedHtml::new(r#"<pre aria-label="preview">wave</pre>"#))
5999            .with_footer(TrustedHtml::new(r#"<div class="wf-statusbar">Ready</div>"#))
6000            .with_mode("light")
6001            .mode_locked()
6002            .with_asset_base_path("/assets/wavefunk")
6003            .with_attrs(&attrs)
6004            .render()
6005            .unwrap();
6006
6007        assert!(panel.contains(r#"class="wf-form-panel""#));
6008        assert!(!panel.contains("Setup <workspace>"));
6009        assert!(!panel.contains("generic product copy <only>"));
6010        assert!(panel.contains(r#"<form class="wf-form">Fields</form>"#));
6011        assert!(shell.contains(r#"class="wf-split-shell""#));
6012        assert!(shell.contains(r#"data-mode="light""#));
6013        assert!(shell.contains(r#"data-mode-locked"#));
6014        assert!(shell.contains(r#"data-wf-asset-base="/assets/wavefunk""#));
6015        assert!(shell.contains(r#"data-surface="setup "#));
6016        assert!(!shell.contains(r#"data-surface="setup <flow>""#));
6017        assert!(shell.contains(r#"<pre aria-label="preview">wave</pre>"#));
6018    }
6019
6020    #[test]
6021    fn modeline_minibuffer_history_context_switcher_and_sidenav_are_generic() {
6022        let toggle_attrs = [HtmlAttr::new("data-mode-toggle", "")];
6023        let left = [
6024            ModelineSegment::chevron("WF"),
6025            ModelineSegment::buffer("workspace.rs"),
6026            ModelineSegment::button("Mode").with_attrs(&toggle_attrs),
6027        ];
6028        let right = [
6029            ModelineSegment::position("L12:C4"),
6030            ModelineSegment::text("Ready").with_feedback(FeedbackKind::Ok),
6031        ];
6032        let modeline = Modeline::new(&left).with_right(&right).render().unwrap();
6033        let history =
6034            [MinibufferHistoryRow::new("09:41", "Saved <draft>").with_feedback(FeedbackKind::Ok)];
6035        let minibuffer = Minibuffer::new()
6036            .with_prompt("wf")
6037            .with_message(FeedbackKind::Info, "Queued <job>")
6038            .with_history(&history)
6039            .render()
6040            .unwrap();
6041        let switcher_items = [
6042            ContextSwitcherItem::link("Production <east>", "/contexts/prod")
6043                .with_meta("3 apps")
6044                .active(),
6045            ContextSwitcherItem::link("Sandbox", "/contexts/sandbox")
6046                .with_badge(TrustedHtml::new(r#"<span class="wf-tag">test</span>"#)),
6047        ];
6048        let switcher = ContextSwitcher::new("Workspace", "Production", &switcher_items)
6049            .with_meta(TrustedHtml::new(r#"<span class="wf-tag ok">live</span>"#))
6050            .open()
6051            .render()
6052            .unwrap();
6053        let side_items = [
6054            SidenavItem::link("Overview", "/overview").active(),
6055            SidenavItem::link("Reports <beta>", "/reports")
6056                .muted()
6057                .with_badge("Soon"),
6058            SidenavItem::link("Billing", "/billing")
6059                .disabled()
6060                .with_coming_soon("coming soon"),
6061        ];
6062        let side_sections = [SidenavSection::new("Manage <workspace>", &side_items)];
6063        let sidenav = Sidenav::new(&side_sections).render().unwrap();
6064        let embedded_sidenav = Sidenav::new(&side_sections).embedded().render().unwrap();
6065
6066        assert!(modeline.contains(r#"class="wf-modeline""#));
6067        assert!(modeline.contains(r#"class="wf-ml-seg wf-ml-chevron""#));
6068        assert!(modeline.contains(r#"data-mode-toggle="""#));
6069        assert!(modeline.contains(r#"class="wf-ml-seg wf-ml-pos""#));
6070        assert!(modeline.contains(r#"class="wf-ml-seg is-ok""#));
6071        assert!(modeline.contains(r#"class="wf-ml-fill""#));
6072        assert!(minibuffer.contains(r#"class="wf-minibuffer-history""#));
6073        assert!(!minibuffer.contains("Queued <job>"));
6074        assert!(!minibuffer.contains("Saved <draft>"));
6075        assert!(switcher.contains(r#"class="wf-context-switcher""#));
6076        assert!(switcher.contains(r#"<details class="wf-context-switcher" open>"#));
6077        assert!(!switcher.contains("Production <east>"));
6078        assert!(switcher.contains(r#"<span class="wf-tag">test</span>"#));
6079        assert!(sidenav.contains(r#"class="wf-sidenav""#));
6080        assert!(sidenav.contains(r#"class="wf-sidenav-item is-active""#));
6081        assert!(sidenav.contains(r#"class="wf-sidenav-item is-muted""#));
6082        assert!(sidenav.contains(r#"aria-disabled="true""#));
6083        assert!(!sidenav.contains("Manage <workspace>"));
6084        assert!(!sidenav.contains("Reports <beta>"));
6085        assert!(sidenav.contains(r#"<nav class="wf-sidenav""#));
6086        assert!(embedded_sidenav.contains(r#"<div class="wf-sidenav""#));
6087        assert!(!embedded_sidenav.contains(r#"<nav class="wf-sidenav""#));
6088    }
6089
6090    #[test]
6091    fn secret_checklist_code_grid_snippets_and_strength_meter_are_product_neutral() {
6092        let secret = SecretValue::new("Recovery token", "recovery-token", "tok_<secret>")
6093            .with_warning("Shown once <store it>")
6094            .with_help(TrustedHtml::new(
6095                "<strong>Store this value securely.</strong>",
6096            ))
6097            .render()
6098            .unwrap();
6099        let checklist_items = [
6100            ChecklistItem::ok("DNS configured <edge>").with_description("Records verified."),
6101            ChecklistItem::warn("Webhook retry").with_status_label("review"),
6102        ];
6103        let checklist = Checklist::new(&checklist_items).render().unwrap();
6104        let codes = ["ABCD-EFGH", "IJKL<MNOP>"];
6105        let code_grid = CodeGrid::new(&codes)
6106            .with_label("One-time codes <backup>")
6107            .render()
6108            .unwrap();
6109        let block = CodeBlock::new("cargo add wavefunk-ui <latest>")
6110            .with_label("Install")
6111            .with_language("shell")
6112            .with_copy_target("install-command")
6113            .render()
6114            .unwrap();
6115        let tabs = [
6116            SnippetTab::new("Rust", r#"let value = "<typed>";"#)
6117                .with_language("rust")
6118                .active(),
6119            SnippetTab::new("Shell", "cargo test").with_language("shell"),
6120        ];
6121        let snippets = SnippetTabs::new("quickstart", &tabs).render().unwrap();
6122        let strength = StrengthMeter::new(3, 4, "Strong <enough>")
6123            .with_label("Key strength")
6124            .with_feedback(FeedbackKind::Ok)
6125            .live()
6126            .render()
6127            .unwrap();
6128
6129        assert!(secret.contains(r#"class="wf-secret-value""#));
6130        assert!(secret.contains(r##"data-wf-copy="#recovery-token""##));
6131        assert!(!secret.contains("data-wf-copy-value"));
6132        assert!(secret.contains("********"));
6133        assert!(!secret.contains("tok_<secret>"));
6134        assert!(!secret.contains("Shown once <store it>"));
6135        assert!(secret.contains("<strong>Store this value securely.</strong>"));
6136        let copyable_masked = SecretValue::new("Raw token", "raw-token", "raw-secret")
6137            .copy_raw_value()
6138            .render()
6139            .unwrap();
6140        assert!(copyable_masked.contains(r#"data-wf-copy-value="raw-secret""#));
6141        assert!(checklist.contains(r#"class="wf-checklist""#));
6142        assert!(checklist.contains(r#"class="wf-checklist-item is-ok""#));
6143        assert!(checklist.contains(r#"class="wf-checklist-item is-warn""#));
6144        assert!(!checklist.contains("DNS configured <edge>"));
6145        assert!(code_grid.contains(r#"class="wf-code-grid""#));
6146        assert!(!code_grid.contains("IJKL<MNOP>"));
6147        assert!(!code_grid.contains("One-time codes <backup>"));
6148        assert!(block.contains(r#"class="wf-code-block""#));
6149        assert!(block.contains(r#"data-language="shell""#));
6150        assert!(block.contains(r##"data-wf-copy="#install-command""##));
6151        assert!(!block.contains("wavefunk-ui <latest>"));
6152        assert!(snippets.contains(r#"class="wf-snippet-tabs""#));
6153        assert!(snippets.contains(r#"role="tablist""#));
6154        assert!(snippets.contains(r##"data-wf-snippet-tab="#quickstart-panel-1""##));
6155        assert!(snippets.contains(r#"aria-controls="quickstart-panel-1""#));
6156        assert!(snippets.contains(r#"id="quickstart-panel-2""#));
6157        assert!(snippets.contains(r#"hidden"#));
6158        assert!(!snippets.contains(r#"let value = "<typed>";"#));
6159        assert!(strength.contains(r#"class="wf-strength-meter is-ok""#));
6160        assert!(strength.contains(r#"role="progressbar""#));
6161        assert!(strength.contains(r#"aria-valuenow="3""#));
6162        assert!(strength.contains(r#"aria-valuemax="4""#));
6163        assert!(strength.contains(r#"style="--strength: 75%""#));
6164        assert!(!strength.contains("Strong <enough>"));
6165    }
6166
6167    #[test]
6168    fn marketing_primitives_render_stable_typed_sections() {
6169        let features = [
6170            FeatureItem::new("Typed <APIs>", "No struct literal churn."),
6171            FeatureItem::new("Embedded assets", "Self-contained binaries."),
6172        ];
6173        let feature_grid = FeatureGrid::new(&features).render().unwrap();
6174        let steps = [
6175            MarketingStep::new("Install", "Add the crate."),
6176            MarketingStep::new("Render", "Use Askama templates."),
6177        ];
6178        let step_grid = MarketingStepGrid::new(&steps).render().unwrap();
6179        let plans = [
6180            PricingPlan::new("Starter", "$9")
6181                .with_blurb("For small teams.")
6182                .featured(),
6183            PricingPlan::new("Scale", "$29"),
6184        ];
6185        let pricing = PricingPlans::new(&plans).render().unwrap();
6186        let testimonial = Testimonial::new(
6187            TrustedHtml::new("<p>Fast to wire.</p>"),
6188            "Operator <one>",
6189            "Founder",
6190        )
6191        .render()
6192        .unwrap();
6193        let section = MarketingSection::new("Component <system>", TrustedHtml::new(&feature_grid))
6194            .with_kicker("Wave Funk")
6195            .with_subtitle("Typed primitives for Rust apps.")
6196            .render()
6197            .unwrap();
6198
6199        assert!(feature_grid.contains(r#"class="mk-features""#));
6200        assert!(!feature_grid.contains("Typed <APIs>"));
6201        assert!(step_grid.contains(r#"class="mk-steps""#));
6202        assert!(pricing.contains(r#"class="wf-plans""#));
6203        assert!(pricing.contains(r#"class="wf-plan is-featured""#));
6204        assert!(testimonial.contains(r#"class="wf-testimonial""#));
6205        assert!(!testimonial.contains("Operator <one>"));
6206        assert!(section.contains(r#"class="mk-sect""#));
6207        assert!(!section.contains("Component <system>"));
6208    }
6209}