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