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`] and [`Switch`] written against it.
3
4use tablo_ui::{checkbox as ui_checkbox, switch as ui_switch};
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    disabled: bool,
44}
45
46impl ControlInput {
47    pub(crate) fn new(
48        name: &str,
49        id: &str,
50        value: Option<&str>,
51        required: bool,
52        invalid: bool,
53        described_by: Option<String>,
54    ) -> Self {
55        Self {
56            name: name.to_string(),
57            id: id.to_string(),
58            value: value.map(str::to_string),
59            required,
60            invalid,
61            described_by,
62            disabled: false,
63        }
64    }
65
66    pub(crate) fn disabled(mut self, disabled: bool) -> Self {
67        self.disabled = disabled;
68        self
69    }
70
71    pub fn name(&self) -> &str {
72        &self.name
73    }
74
75    /// The control's DOM id, which its label names: the key, prefixed when its form shares the
76    /// page with another, such as an action's input in a dialog.
77    pub fn id(&self) -> &str {
78        &self.id
79    }
80
81    pub fn value(&self) -> Option<&str> {
82        self.value.as_deref()
83    }
84
85    pub fn required(&self) -> bool {
86        self.required
87    }
88
89    pub fn invalid(&self) -> bool {
90        self.invalid
91    }
92
93    pub fn described_by(&self) -> Option<&str> {
94        self.described_by.as_deref()
95    }
96
97    /// Whether the control renders disabled: the server ignores what it posts.
98    pub fn is_disabled(&self) -> bool {
99        self.disabled
100    }
101
102    pub fn attributes(&self, cx: &Cx) -> Attributes {
103        let required = self.required;
104        attributes! {
105            cx =>
106            id=(self.id.clone())
107            name=(self.name.clone())
108            value=(self.value.clone())
109            required=(required)
110            disabled=(self.disabled)
111            aria-required=(required.then_some("true"))
112            aria-invalid=(if self.invalid { "true" } else { "false" })
113            aria-describedby=(self.described_by.clone())
114        }
115    }
116}
117
118/// Renders a `bool` field as a checkbox preceded by a hidden `false`.
119#[derive(Debug, Clone, Copy, Default)]
120pub struct Toggle;
121
122impl Control for Toggle {
123    fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
124        boolean(cx, input, false)
125    }
126}
127
128/// Renders a `bool` field as a switch, a checkbox with the `switch` role drawn as an on/off
129/// track, preceded by a hidden `false`: what [`CustomField::switch`](crate::CustomField::switch)
130/// renders.
131#[derive(Debug, Clone, Copy, Default)]
132pub struct Switch;
133
134impl Control for Switch {
135    fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
136        boolean(cx, input, true)
137    }
138}
139
140/// A `bool` field's checkbox, or its switch when `switch`, after the hidden `false` it posts
141/// unchecked.
142fn boolean<'a>(cx: &'a Cx, input: ControlInput, switch: bool) -> BoxView<'a> {
143    let name = input.name().to_string();
144    let id = input.id().to_string();
145    let checked = input.value().is_some_and(|v| v.trim() == "true");
146    let invalid = if input.invalid() { "true" } else { "false" };
147    let described_by = input.described_by().map(str::to_string);
148    let disabled = input.is_disabled();
149    // Only an accepted box is required: the browser then refuses it unchecked.
150    let required = input.required();
151    let attrs = attributes! {
152        cx =>
153        id=(id)
154        name=(name.clone())
155        value="true"
156        checked=(checked)
157        required=(required)
158        aria-required=(required.then_some("true"))
159        disabled=(disabled)
160        aria-invalid=(invalid)
161        aria-describedby=(described_by)
162    };
163    view! {
164        cx =>
165        <input type="hidden" name=(name) value="false" disabled=(disabled)>
166        if switch {
167            ui_switch(attrs: attrs)
168        } else {
169            ui_checkbox(attrs: attrs)
170        }
171    }
172    .boxed()
173}