Skip to main content

pepl_ui/components/
feedback.rs

1//! Feedback component builders — Modal, Toast.
2//!
3//! Modal is a container component (accepts children via second brace block).
4//! Toast is a leaf notification component.
5
6use crate::accessibility;
7use crate::prop_value::PropValue;
8use crate::surface::SurfaceNode;
9
10// ── Toast Type Enum ───────────────────────────────────────────────────────────
11
12/// Visual style for a Toast notification.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ToastType {
15    Info,
16    Success,
17    Warning,
18    Error,
19}
20
21impl ToastType {
22    fn as_str(self) -> &'static str {
23        match self {
24            Self::Info => "info",
25            Self::Success => "success",
26            Self::Warning => "warning",
27            Self::Error => "error",
28        }
29    }
30}
31
32// ── ModalBuilder ──────────────────────────────────────────────────────────────
33
34/// Builder for a Modal component.
35///
36/// Required: `visible` (Bool), `on_dismiss` (ActionRef).
37/// Optional: `title` (String).
38/// Accepts children (content inside the modal).
39pub struct ModalBuilder {
40    visible: bool,
41    on_dismiss: PropValue,
42    title: Option<String>,
43    children: Vec<SurfaceNode>,
44}
45
46impl ModalBuilder {
47    /// Create a new ModalBuilder with required props.
48    ///
49    /// `on_dismiss` must be a `PropValue::ActionRef` — use `PropValue::action()`.
50    pub fn new(visible: bool, on_dismiss: PropValue) -> Self {
51        Self {
52            visible,
53            on_dismiss,
54            title: None,
55            children: Vec::new(),
56        }
57    }
58
59    pub fn title(mut self, title: impl Into<String>) -> Self {
60        self.title = Some(title.into());
61        self
62    }
63
64    /// Add a child node to the modal's content.
65    pub fn child(mut self, child: SurfaceNode) -> Self {
66        self.children.push(child);
67        self
68    }
69
70    pub fn build(self) -> SurfaceNode {
71        let mut node = SurfaceNode::new("Modal");
72        node.set_prop("visible", PropValue::Bool(self.visible));
73        node.set_prop("on_dismiss", self.on_dismiss);
74        if let Some(title) = self.title {
75            node.set_prop("title", PropValue::String(title));
76        }
77        for child in self.children {
78            node.add_child(child);
79        }
80        accessibility::ensure_accessible(&mut node);
81        node
82    }
83}
84
85// ── ToastBuilder ──────────────────────────────────────────────────────────────
86
87/// Builder for a Toast component.
88///
89/// Required: `message` (String).
90/// Optional: `duration` (Number), `toast_type` (string enum).
91pub struct ToastBuilder {
92    message: String,
93    duration: Option<f64>,
94    toast_type: Option<ToastType>,
95}
96
97impl ToastBuilder {
98    /// Create a new ToastBuilder with the required message.
99    pub fn new(message: impl Into<String>) -> Self {
100        Self {
101            message: message.into(),
102            duration: None,
103            toast_type: None,
104        }
105    }
106
107    /// Set the duration in milliseconds.
108    pub fn duration(mut self, duration: f64) -> Self {
109        self.duration = Some(duration);
110        self
111    }
112
113    /// Set the toast type (info, success, warning, error).
114    pub fn toast_type(mut self, toast_type: ToastType) -> Self {
115        self.toast_type = Some(toast_type);
116        self
117    }
118
119    pub fn build(self) -> SurfaceNode {
120        let mut node = SurfaceNode::new("Toast");
121        node.set_prop("message", PropValue::String(self.message));
122        if let Some(duration) = self.duration {
123            node.set_prop("duration", PropValue::Number(duration));
124        }
125        if let Some(toast_type) = self.toast_type {
126            node.set_prop("type", PropValue::String(toast_type.as_str().to_string()));
127        }
128        accessibility::ensure_accessible(&mut node);
129        node
130    }
131}
132
133// ── Validation ────────────────────────────────────────────────────────────────
134
135/// Validate a feedback component node (Modal or Toast).
136pub fn validate_feedback_node(node: &SurfaceNode) -> Vec<String> {
137    match node.component_type.as_str() {
138        "Modal" => validate_modal(node),
139        "Toast" => validate_toast(node),
140        _ => vec![format!(
141            "Unknown feedback component: {}",
142            node.component_type
143        )],
144    }
145}
146
147fn validate_modal(node: &SurfaceNode) -> Vec<String> {
148    let mut errors = Vec::new();
149
150    // Required: visible (bool)
151    match node.props.get("visible") {
152        Some(PropValue::Bool(_)) => {}
153        Some(other) => errors.push(format!(
154            "Modal.visible: expected bool, got {}",
155            other.type_name()
156        )),
157        None => errors.push("Modal.visible: required prop missing".to_string()),
158    }
159
160    // Required: on_dismiss (action)
161    match node.props.get("on_dismiss") {
162        Some(PropValue::ActionRef { .. }) => {}
163        Some(other) => errors.push(format!(
164            "Modal.on_dismiss: expected action, got {}",
165            other.type_name()
166        )),
167        None => errors.push("Modal.on_dismiss: required prop missing".to_string()),
168    }
169
170    // Optional: title (string)
171    if let Some(prop) = node.props.get("title") {
172        if !matches!(prop, PropValue::String(_)) {
173            errors.push(format!(
174                "Modal.title: expected string, got {}",
175                prop.type_name()
176            ));
177        }
178    }
179
180    // Children are allowed (Modal is a container)
181
182    // Optional: accessible (record)
183    if let Some(prop) = node.props.get("accessible") {
184        errors.extend(accessibility::validate_accessible_prop("Modal", prop));
185    }
186
187    // Unknown props
188    for key in node.props.keys() {
189        if !matches!(
190            key.as_str(),
191            "visible" | "on_dismiss" | "title" | "accessible"
192        ) {
193            errors.push(format!("Modal: unknown prop '{key}'"));
194        }
195    }
196
197    errors
198}
199
200fn validate_toast(node: &SurfaceNode) -> Vec<String> {
201    let mut errors = Vec::new();
202
203    // Required: message (string)
204    match node.props.get("message") {
205        Some(PropValue::String(_)) => {}
206        Some(other) => errors.push(format!(
207            "Toast.message: expected string, got {}",
208            other.type_name()
209        )),
210        None => errors.push("Toast.message: required prop missing".to_string()),
211    }
212
213    // Optional: duration (number)
214    if let Some(prop) = node.props.get("duration") {
215        if !matches!(prop, PropValue::Number(_)) {
216            errors.push(format!(
217                "Toast.duration: expected number, got {}",
218                prop.type_name()
219            ));
220        }
221    }
222
223    // Optional: type (string enum)
224    if let Some(prop) = node.props.get("type") {
225        match prop {
226            PropValue::String(s)
227                if matches!(s.as_str(), "info" | "success" | "warning" | "error") => {}
228            _ => errors.push(format!(
229                "Toast.type: expected one of [info, success, warning, error], got {:?}",
230                prop
231            )),
232        }
233    }
234
235    // No children
236    if !node.children.is_empty() {
237        errors.push(format!(
238            "Toast: does not accept children, but got {}",
239            node.children.len()
240        ));
241    }
242
243    // Optional: accessible (record)
244    if let Some(prop) = node.props.get("accessible") {
245        errors.extend(accessibility::validate_accessible_prop("Toast", prop));
246    }
247
248    // Unknown props
249    for key in node.props.keys() {
250        if !matches!(key.as_str(), "message" | "duration" | "type" | "accessible") {
251            errors.push(format!("Toast: unknown prop '{key}'"));
252        }
253    }
254
255    errors
256}