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`], using a hidden field for a state that submits
8/// nothing. A detail page shows the value through a [`Column`](crate::extend::Column) instead.
9///
10/// ```rust
11/// # #[derive(Debug, Clone, toasty::Model)]
12/// # struct Theme { #[key] #[auto] id: uuid::Uuid, accent: String }
13/// # use tablo_core::{
14/// #     Field,
15/// #     extend::{Control, ControlInput},
16/// # };
17/// # use topcoat::{context::Cx, view::*};
18/// struct Color;
19///
20/// impl Control for Color {
21///     fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
22///         let attrs = input.attributes(cx);
23///         view! { cx => <input type="color" (attrs)> }.boxed()
24///     }
25/// }
26///
27/// Field::custom(Theme::fields().accent(), Color);
28/// ```
29pub trait Control: Send + Sync {
30    fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a>;
31}
32
33/// What a [`Control`] renders for one form: the field's key, its DOM id, its current value, and
34/// its validation state.
35#[derive(Debug, Clone)]
36pub struct ControlInput {
37    name: String,
38    id: String,
39    value: Option<String>,
40    required: bool,
41    invalid: bool,
42    described_by: Option<String>,
43}
44
45impl ControlInput {
46    pub(crate) fn new(
47        name: &str,
48        id: &str,
49        value: Option<&str>,
50        required: bool,
51        invalid: bool,
52        described_by: Option<String>,
53    ) -> Self {
54        Self {
55            name: name.to_string(),
56            id: id.to_string(),
57            value: value.map(str::to_string),
58            required,
59            invalid,
60            described_by,
61        }
62    }
63
64    pub fn name(&self) -> &str {
65        &self.name
66    }
67
68    /// The control's DOM id, which its label names: the key, prefixed when its form shares the
69    /// page with another, such as an action's input in a dialog.
70    pub fn id(&self) -> &str {
71        &self.id
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.id.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`.
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 id = input.id().to_string();
113        let checked = input.value().is_some_and(|v| v.trim() == "true");
114        let invalid = if input.invalid() { "true" } else { "false" };
115        let described_by = input.described_by().map(str::to_string);
116        view! {
117            cx =>
118            <input type="hidden" name=(name.clone()) value="false">
119            ui_checkbox(
120                attrs: attributes! {
121                    id=(id)
122                    name=(name)
123                    value="true"
124                    checked=(checked)
125                    aria-invalid=(invalid)
126                    aria-describedby=(described_by)
127                }
128            )
129        }
130        .boxed()
131    }
132}