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