Skip to main content

pepl_ui/components/
interactive.rs

1//! Interactive component builders — Button, TextInput.
2//!
3//! These are leaf components with no children. They handle user interactions
4//! via action references (`on_tap`) or lambda callbacks (`on_change`).
5
6use crate::accessibility;
7use crate::prop_value::PropValue;
8use crate::surface::SurfaceNode;
9
10// ── Button Variant Enum ───────────────────────────────────────────────────────
11
12/// Visual style for a Button.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ButtonVariant {
15    Filled,
16    Outlined,
17    Text,
18}
19
20impl ButtonVariant {
21    fn as_str(self) -> &'static str {
22        match self {
23            Self::Filled => "filled",
24            Self::Outlined => "outlined",
25            Self::Text => "text",
26        }
27    }
28}
29
30// ── Keyboard Type Enum ────────────────────────────────────────────────────────
31
32/// Virtual keyboard type for a TextInput.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum KeyboardType {
35    Text,
36    Number,
37    Email,
38    Phone,
39    Url,
40}
41
42impl KeyboardType {
43    fn as_str(self) -> &'static str {
44        match self {
45            Self::Text => "text",
46            Self::Number => "number",
47            Self::Email => "email",
48            Self::Phone => "phone",
49            Self::Url => "url",
50        }
51    }
52}
53
54// ── ButtonBuilder ─────────────────────────────────────────────────────────────
55
56/// Builder for a Button component.
57///
58/// Required: `label` (String), `on_tap` (ActionRef).
59/// Optional: `variant`, `icon`, `disabled`, `loading`.
60pub struct ButtonBuilder {
61    label: String,
62    on_tap: PropValue,
63    variant: Option<ButtonVariant>,
64    icon: Option<String>,
65    disabled: Option<bool>,
66    loading: Option<bool>,
67}
68
69impl ButtonBuilder {
70    /// Create a new ButtonBuilder with required props.
71    ///
72    /// `on_tap` must be a `PropValue::ActionRef` — use `PropValue::action()` or
73    /// `PropValue::action_with_args()`.
74    pub fn new(label: impl Into<String>, on_tap: PropValue) -> Self {
75        Self {
76            label: label.into(),
77            on_tap,
78            variant: None,
79            icon: None,
80            disabled: None,
81            loading: None,
82        }
83    }
84
85    pub fn variant(mut self, variant: ButtonVariant) -> Self {
86        self.variant = Some(variant);
87        self
88    }
89
90    pub fn icon(mut self, icon: impl Into<String>) -> Self {
91        self.icon = Some(icon.into());
92        self
93    }
94
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = Some(disabled);
97        self
98    }
99
100    pub fn loading(mut self, loading: bool) -> Self {
101        self.loading = Some(loading);
102        self
103    }
104
105    pub fn build(self) -> SurfaceNode {
106        let mut node = SurfaceNode::new("Button");
107        node.set_prop("label", PropValue::String(self.label));
108        node.set_prop("on_tap", self.on_tap);
109        if let Some(variant) = self.variant {
110            node.set_prop("variant", PropValue::String(variant.as_str().to_string()));
111        }
112        if let Some(icon) = self.icon {
113            node.set_prop("icon", PropValue::String(icon));
114        }
115        if let Some(disabled) = self.disabled {
116            node.set_prop("disabled", PropValue::Bool(disabled));
117        }
118        if let Some(loading) = self.loading {
119            node.set_prop("loading", PropValue::Bool(loading));
120        }
121        accessibility::ensure_accessible(&mut node);
122        node
123    }
124}
125
126// ── TextInputBuilder ──────────────────────────────────────────────────────────
127
128/// Builder for a TextInput component.
129///
130/// Required: `value` (String), `on_change` (Lambda).
131/// Optional: `placeholder`, `label`, `keyboard`, `max_length`, `multiline`.
132pub struct TextInputBuilder {
133    value: String,
134    on_change: PropValue,
135    placeholder: Option<String>,
136    label: Option<String>,
137    keyboard: Option<KeyboardType>,
138    max_length: Option<f64>,
139    multiline: Option<bool>,
140}
141
142impl TextInputBuilder {
143    /// Create a new TextInputBuilder with required props.
144    ///
145    /// `on_change` must be a `PropValue::Lambda` — use `PropValue::lambda(id)`.
146    pub fn new(value: impl Into<String>, on_change: PropValue) -> Self {
147        Self {
148            value: value.into(),
149            on_change,
150            placeholder: None,
151            label: None,
152            keyboard: None,
153            max_length: None,
154            multiline: None,
155        }
156    }
157
158    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
159        self.placeholder = Some(placeholder.into());
160        self
161    }
162
163    pub fn label(mut self, label: impl Into<String>) -> Self {
164        self.label = Some(label.into());
165        self
166    }
167
168    pub fn keyboard(mut self, keyboard: KeyboardType) -> Self {
169        self.keyboard = Some(keyboard);
170        self
171    }
172
173    pub fn max_length(mut self, max_length: f64) -> Self {
174        self.max_length = Some(max_length);
175        self
176    }
177
178    pub fn multiline(mut self, multiline: bool) -> Self {
179        self.multiline = Some(multiline);
180        self
181    }
182
183    pub fn build(self) -> SurfaceNode {
184        let mut node = SurfaceNode::new("TextInput");
185        node.set_prop("value", PropValue::String(self.value));
186        node.set_prop("on_change", self.on_change);
187        if let Some(placeholder) = self.placeholder {
188            node.set_prop("placeholder", PropValue::String(placeholder));
189        }
190        if let Some(label) = self.label {
191            node.set_prop("label", PropValue::String(label));
192        }
193        if let Some(keyboard) = self.keyboard {
194            node.set_prop("keyboard", PropValue::String(keyboard.as_str().to_string()));
195        }
196        if let Some(max_length) = self.max_length {
197            node.set_prop("max_length", PropValue::Number(max_length));
198        }
199        if let Some(multiline) = self.multiline {
200            node.set_prop("multiline", PropValue::Bool(multiline));
201        }
202        accessibility::ensure_accessible(&mut node);
203        node
204    }
205}
206
207// ── Validation ────────────────────────────────────────────────────────────────
208
209/// Validate an interactive component node (Button or TextInput).
210pub fn validate_interactive_node(node: &SurfaceNode) -> Vec<String> {
211    match node.component_type.as_str() {
212        "Button" => validate_button(node),
213        "TextInput" => validate_text_input(node),
214        _ => vec![format!(
215            "Unknown interactive component: {}",
216            node.component_type
217        )],
218    }
219}
220
221fn validate_button(node: &SurfaceNode) -> Vec<String> {
222    let mut errors = Vec::new();
223
224    // Required: label (string)
225    match node.props.get("label") {
226        Some(PropValue::String(_)) => {}
227        Some(other) => errors.push(format!(
228            "Button.label: expected string, got {}",
229            other.type_name()
230        )),
231        None => errors.push("Button.label: required prop missing".to_string()),
232    }
233
234    // Required: on_tap (action)
235    match node.props.get("on_tap") {
236        Some(PropValue::ActionRef { .. }) => {}
237        Some(other) => errors.push(format!(
238            "Button.on_tap: expected action, got {}",
239            other.type_name()
240        )),
241        None => errors.push("Button.on_tap: required prop missing".to_string()),
242    }
243
244    // Optional: variant (string enum)
245    if let Some(prop) = node.props.get("variant") {
246        match prop {
247            PropValue::String(s) if matches!(s.as_str(), "filled" | "outlined" | "text") => {}
248            _ => errors.push(format!(
249                "Button.variant: expected one of [filled, outlined, text], got {:?}",
250                prop
251            )),
252        }
253    }
254
255    // Optional: icon (string)
256    if let Some(prop) = node.props.get("icon") {
257        if !matches!(prop, PropValue::String(_)) {
258            errors.push(format!(
259                "Button.icon: expected string, got {}",
260                prop.type_name()
261            ));
262        }
263    }
264
265    // Optional: disabled (bool)
266    if let Some(prop) = node.props.get("disabled") {
267        if !matches!(prop, PropValue::Bool(_)) {
268            errors.push(format!(
269                "Button.disabled: expected bool, got {}",
270                prop.type_name()
271            ));
272        }
273    }
274
275    // Optional: loading (bool)
276    if let Some(prop) = node.props.get("loading") {
277        if !matches!(prop, PropValue::Bool(_)) {
278            errors.push(format!(
279                "Button.loading: expected bool, got {}",
280                prop.type_name()
281            ));
282        }
283    }
284
285    // No children
286    if !node.children.is_empty() {
287        errors.push(format!(
288            "Button: does not accept children, but got {}",
289            node.children.len()
290        ));
291    }
292
293    // Optional: accessible (record)
294    if let Some(prop) = node.props.get("accessible") {
295        errors.extend(accessibility::validate_accessible_prop("Button", prop));
296    }
297
298    // Unknown props
299    for key in node.props.keys() {
300        if !matches!(
301            key.as_str(),
302            "label" | "on_tap" | "variant" | "icon" | "disabled" | "loading" | "accessible"
303        ) {
304            errors.push(format!("Button: unknown prop '{key}'"));
305        }
306    }
307
308    errors
309}
310
311fn validate_text_input(node: &SurfaceNode) -> Vec<String> {
312    let mut errors = Vec::new();
313
314    // Required: value (string)
315    match node.props.get("value") {
316        Some(PropValue::String(_)) => {}
317        Some(other) => errors.push(format!(
318            "TextInput.value: expected string, got {}",
319            other.type_name()
320        )),
321        None => errors.push("TextInput.value: required prop missing".to_string()),
322    }
323
324    // Required: on_change (lambda)
325    match node.props.get("on_change") {
326        Some(PropValue::Lambda { .. }) => {}
327        Some(other) => errors.push(format!(
328            "TextInput.on_change: expected lambda, got {}",
329            other.type_name()
330        )),
331        None => errors.push("TextInput.on_change: required prop missing".to_string()),
332    }
333
334    // Optional: placeholder (string)
335    if let Some(prop) = node.props.get("placeholder") {
336        if !matches!(prop, PropValue::String(_)) {
337            errors.push(format!(
338                "TextInput.placeholder: expected string, got {}",
339                prop.type_name()
340            ));
341        }
342    }
343
344    // Optional: label (string)
345    if let Some(prop) = node.props.get("label") {
346        if !matches!(prop, PropValue::String(_)) {
347            errors.push(format!(
348                "TextInput.label: expected string, got {}",
349                prop.type_name()
350            ));
351        }
352    }
353
354    // Optional: keyboard (string enum)
355    if let Some(prop) = node.props.get("keyboard") {
356        match prop {
357            PropValue::String(s)
358                if matches!(s.as_str(), "text" | "number" | "email" | "phone" | "url") => {}
359            _ => errors.push(format!(
360                "TextInput.keyboard: expected one of [text, number, email, phone, url], got {:?}",
361                prop
362            )),
363        }
364    }
365
366    // Optional: max_length (number)
367    if let Some(prop) = node.props.get("max_length") {
368        if !matches!(prop, PropValue::Number(_)) {
369            errors.push(format!(
370                "TextInput.max_length: expected number, got {}",
371                prop.type_name()
372            ));
373        }
374    }
375
376    // Optional: multiline (bool)
377    if let Some(prop) = node.props.get("multiline") {
378        if !matches!(prop, PropValue::Bool(_)) {
379            errors.push(format!(
380                "TextInput.multiline: expected bool, got {}",
381                prop.type_name()
382            ));
383        }
384    }
385
386    // No children
387    if !node.children.is_empty() {
388        errors.push(format!(
389            "TextInput: does not accept children, but got {}",
390            node.children.len()
391        ));
392    }
393
394    // Optional: accessible (record)
395    if let Some(prop) = node.props.get("accessible") {
396        errors.extend(accessibility::validate_accessible_prop("TextInput", prop));
397    }
398
399    // Unknown props
400    for key in node.props.keys() {
401        if !matches!(
402            key.as_str(),
403            "value"
404                | "on_change"
405                | "placeholder"
406                | "label"
407                | "keyboard"
408                | "max_length"
409                | "multiline"
410                | "accessible"
411        ) {
412            errors.push(format!("TextInput: unknown prop '{key}'"));
413        }
414    }
415
416    errors
417}