Skip to main content

tablo_core/schema/fields/
custom.rs

1//! App-defined field controls: the [`Control`] trait, what it renders from
2//! ([`ControlInput`]), and the built-in [`Toggle`] written against it.
3
4use tablo_ui::checkbox as ui_checkbox;
5use topcoat::{context::Cx, view::*};
6
7/// Renders a field's input from a [`ControlInput`] and its stored value for display, using a hidden
8/// field for a state that submits nothing.
9///
10/// ```rust
11/// # #[derive(Debug, Clone, toasty::Model)]
12/// # struct Theme { #[key] #[auto] id: uuid::Uuid, accent: String }
13/// # use tablo_core::{Control, ControlInput, Field};
14/// # use topcoat::{context::Cx, view::*};
15/// struct Color;
16///
17/// impl Control for Color {
18///     fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
19///         let attrs = input.attributes(cx);
20///         view! { cx => <input type="color" (attrs)> }.boxed()
21///     }
22/// }
23///
24/// Field::custom(Theme::fields().accent(), Color);
25/// ```
26pub trait Control: Send + Sync {
27    fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a>;
28
29    /// Shows the stored value, as text by default.
30    fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
31        let text = value.to_string();
32        view! {
33            cx =>
34            <div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
35                (text)
36            </div>
37        }
38        .boxed()
39    }
40}
41
42/// What a [`Control`] renders for one form: the field's key, its current
43/// value, and its validation state.
44#[derive(Debug, Clone)]
45pub struct ControlInput {
46    name: String,
47    value: Option<String>,
48    required: bool,
49    invalid: bool,
50    described_by: Option<String>,
51}
52
53impl ControlInput {
54    pub(crate) fn new(
55        name: &str,
56        value: Option<&str>,
57        required: bool,
58        invalid: bool,
59        described_by: Option<String>,
60    ) -> Self {
61        Self {
62            name: name.to_string(),
63            value: value.map(str::to_string),
64            required,
65            invalid,
66            described_by,
67        }
68    }
69
70    pub fn name(&self) -> &str {
71        &self.name
72    }
73
74    pub fn value(&self) -> Option<&str> {
75        self.value.as_deref()
76    }
77
78    pub fn required(&self) -> bool {
79        self.required
80    }
81
82    pub fn invalid(&self) -> bool {
83        self.invalid
84    }
85
86    pub fn described_by(&self) -> Option<&str> {
87        self.described_by.as_deref()
88    }
89
90    pub fn attributes(&self, cx: &Cx) -> Attributes {
91        let required = self.required;
92        attributes! {
93            cx =>
94            id=(self.name.clone())
95            name=(self.name.clone())
96            value=(self.value.clone())
97            required=(required)
98            aria-required=(required.then_some("true"))
99            aria-invalid=(if self.invalid { "true" } else { "false" })
100            aria-describedby=(self.described_by.clone())
101        }
102    }
103}
104
105/// Renders a `bool` field as a checkbox preceded by a hidden `false`, displaying as `"Yes"`/`"No"`.
106#[derive(Debug, Clone, Copy, Default)]
107pub struct Toggle;
108
109impl Control for Toggle {
110    fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
111        let name = input.name().to_string();
112        let checked = input.value().is_some_and(|v| v.trim() == "true");
113        let invalid = if input.invalid() { "true" } else { "false" };
114        let described_by = input.described_by().map(str::to_string);
115        view! {
116            cx =>
117            <input type="hidden" name=(name.clone()) value="false">
118            ui_checkbox(
119                attrs: attributes! {
120                    id=(name.clone())
121                    name=(name)
122                    value="true"
123                    checked=(checked)
124                    aria-invalid=(invalid)
125                    aria-describedby=(described_by)
126                }
127            )
128        }
129        .boxed()
130    }
131
132    fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
133        let text = if value.trim() == "true" { "Yes" } else { "No" };
134        view! { cx => <div class="text-sm text-foreground">(text)</div> }.boxed()
135    }
136}