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