Skip to main content

ferro_json_ui/
component.rs

1//! Component catalog for JSON-UI.
2//!
3//! Defines the available UI components with typed props. Each component
4//! uses serde's tagged enum representation so JSON includes `"type": "Card"`.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::action::Action;
10
11/// Shared size enum for components (Button, Badge, Avatar, Input).
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum Size {
15    Xs,
16    Sm,
17    #[default]
18    Default,
19    Lg,
20}
21
22/// Icon placement relative to button label.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum IconPosition {
26    #[default]
27    Left,
28    Right,
29}
30
31/// Sort direction for table columns.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum SortDirection {
35    #[default]
36    Asc,
37    Desc,
38}
39
40/// Separator orientation.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum Orientation {
44    #[default]
45    Horizontal,
46    Vertical,
47}
48
49/// Button visual variants (aligned to shadcn/ui).
50#[derive(
51    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
52)]
53#[serde(rename_all = "snake_case")]
54#[strum(serialize_all = "snake_case")]
55pub enum ButtonVariant {
56    #[default]
57    Default,
58    Secondary,
59    Destructive,
60    Outline,
61    Ghost,
62    Link,
63}
64
65/// Input field types.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum InputType {
69    #[default]
70    Text,
71    Email,
72    Password,
73    Number,
74    Textarea,
75    Hidden,
76    Date,
77    Time,
78    Url,
79    Tel,
80    Search,
81    File,
82}
83
84/// Alert visual variants.
85#[derive(
86    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
87)]
88#[serde(rename_all = "snake_case")]
89#[strum(serialize_all = "snake_case")]
90pub enum AlertVariant {
91    #[default]
92    Info,
93    Success,
94    Warning,
95    Error,
96}
97
98/// Badge visual variants (aligned to shadcn/ui).
99#[derive(
100    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
101)]
102#[serde(rename_all = "snake_case")]
103#[strum(serialize_all = "snake_case")]
104pub enum BadgeVariant {
105    #[default]
106    Default,
107    Secondary,
108    Destructive,
109    Outline,
110}
111
112/// Text element types for semantic HTML rendering.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "snake_case")]
115pub enum TextElement {
116    #[default]
117    P,
118    H1,
119    H2,
120    H3,
121    Span,
122    Div,
123    Section,
124}
125
126/// Column display format for tables.
127///
128/// `Badge` cells expect the row value to be an object `{variant, label}` matching
129/// [`BadgeProps`]. Other variants are display hints layered over plain cell text.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum ColumnFormat {
133    Date,
134    DateTime,
135    Currency,
136    Boolean,
137    Badge,
138    /// Cell value is an image URL string; rendered as an `<img>` thumbnail.
139    Image,
140}
141
142/// Table column definition.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
144pub struct Column {
145    pub key: String,
146    pub label: String,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub format: Option<ColumnFormat>,
149}
150
151/// Select option (value + label pair).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153pub struct SelectOption {
154    pub value: String,
155    pub label: String,
156}
157
158/// Visual variant for Card chrome.
159///
160/// - `Bordered` (default): `border + bg-card + shadow-sm` with `p-4`.
161///   Dashboard cards in dense layouts.
162/// - `Elevated`: `bg-card + shadow-md` (no border) with `p-8`.
163///   Auth pages, error pages, standalone marketing cards.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "snake_case")]
166pub enum CardVariant {
167    #[default]
168    Bordered,
169    Elevated,
170}
171
172/// Props for Card component.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
174pub struct CardProps {
175    pub title: String,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub description: Option<String>,
178    /// Optional muted secondary line rendered immediately below the title and
179    /// above the description. Pattern: name → role, customer → staff,
180    /// title → category. Visually `text-sm text-text-muted`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub subtitle: Option<String>,
183    /// Optional small badge text rendered alongside the title. Visually a
184    /// Badge-styled pill inside the Card chrome — for status indicators,
185    /// counters, countdown labels, etc. Independent of the title hierarchy.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub badge: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub max_width: Option<FormMaxWidth>,
190    /// IDs of footer elements (resolved against `Spec.elements`).
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub footer: Vec<String>,
193    #[serde(default)]
194    pub variant: CardVariant,
195}
196
197/// Props for Table component.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
199pub struct TableProps {
200    pub columns: Vec<Column>,
201    pub data_path: String,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub row_actions: Option<Vec<Action>>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub empty_message: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub sortable: Option<bool>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub sort_column: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub sort_direction: Option<SortDirection>,
212}
213
214/// Maximum width constraint for form containers.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum FormMaxWidth {
218    #[default]
219    Default,
220    Narrow,
221    Wide,
222}
223
224/// Props for Form component.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
226pub struct FormProps {
227    pub action: Action,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub method: Option<crate::action::HttpMethod>,
230    /// Form guard type. When set, the runtime JS disables the submit button
231    /// until the guard condition is met. Value: `"number-gt-0"` — at least
232    /// one number input must have value > 0.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub guard: Option<String>,
235    /// Optional max-width constraint for the form container.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub max_width: Option<FormMaxWidth>,
238    /// Optional HTML `id` attribute for the rendered `<form>`. Pair with a
239    /// Button's `form` prop to submit this form from a button placed outside
240    /// it (e.g. in a PageHeader actions slot).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub id: Option<String>,
243    /// HTML form `enctype` attribute. Set to `"multipart/form-data"` for forms
244    /// carrying a file input. Without this, the browser default encoding
245    /// (`application/x-www-form-urlencoded`) is used and file inputs are sent
246    /// as plain text rather than a multipart body.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub enctype: Option<String>,
249}
250
251/// HTML button type attribute.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum ButtonType {
255    #[default]
256    Button,
257    Submit,
258}
259
260/// Props for Button component.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
262pub struct ButtonProps {
263    pub label: String,
264    #[serde(default)]
265    pub variant: ButtonVariant,
266    #[serde(default)]
267    pub size: Size,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub disabled: Option<bool>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub icon: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub icon_position: Option<IconPosition>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub button_type: Option<ButtonType>,
276    /// HTML5 `form` attribute. Lets a submit button rendered outside its
277    /// target `<form>` (e.g. in a PageHeader actions slot) still submit
278    /// that form, by matching the form's `id`.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub form: Option<String>,
281}
282
283/// Props for Input component.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
285pub struct InputProps {
286    /// Form field name for data binding.
287    pub field: String,
288    pub label: String,
289    #[serde(default)]
290    pub input_type: InputType,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub placeholder: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub required: Option<bool>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub disabled: Option<bool>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub error: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub description: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub default_value: Option<String>,
303    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub data_path: Option<String>,
306    /// HTML step attribute for number inputs (e.g., "any", "0.01").
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub step: Option<String>,
309    /// HTML datalist id for autocomplete suggestions.
310    /// When set, renders `list="..."` on the input and a companion `<datalist>`
311    /// whose options come from a view data key matching this id.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub list: Option<String>,
314    /// HTML `accept` attribute for `input_type = "file"`. Comma-separated MIME
315    /// types or extensions (e.g. `"image/jpeg,image/png,image/webp"`). Browser-
316    /// side filter hint only — server-side MIME validation is the consumer's
317    /// responsibility (the spec layer does not enforce file content type).
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub accept: Option<String>,
320}
321
322/// Props for RichTextEditor leaf element — rendered by the Quill 2.0.3 plugin.
323///
324/// The plugin emits a container div (`<div data-ferro-quill ...>`) and a hidden
325/// input that receives the editor's HTML on every text-change event. The form
326/// handler receives standard `field=<html>` POST data on submit.
327///
328/// # Security
329/// The editor produces user-controlled HTML. Sanitization on submit is the
330/// consumer's responsibility — handle this in the form handler before
331/// persisting (e.g. via `ammonia`).
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
333pub struct RichTextEditorProps {
334    pub field: String,
335    pub label: String,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub placeholder: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub default_value: Option<String>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub data_path: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub error: Option<String>,
344}
345
346/// Props for Select component.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
348pub struct SelectProps {
349    /// Form field name for data binding.
350    pub field: String,
351    pub label: String,
352    pub options: Vec<SelectOption>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub placeholder: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub required: Option<bool>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub disabled: Option<bool>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub error: Option<String>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub description: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub default_value: Option<String>,
365    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub data_path: Option<String>,
368}
369
370/// Props for Alert component.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
372pub struct AlertProps {
373    pub message: String,
374    #[serde(default)]
375    pub variant: AlertVariant,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub title: Option<String>,
378}
379
380/// Props for Badge component.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct BadgeProps {
383    pub label: String,
384    #[serde(default)]
385    pub variant: BadgeVariant,
386}
387
388/// Props for Modal component.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
390pub struct ModalProps {
391    pub id: String,
392    pub title: String,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub description: Option<String>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub trigger_label: Option<String>,
397    /// IDs of footer elements (resolved against `Spec.elements`).
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub footer: Vec<String>,
400}
401
402/// Props for Text component.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
404pub struct TextProps {
405    pub content: String,
406    #[serde(default)]
407    pub element: TextElement,
408}
409
410/// Props for Checkbox component.
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
412pub struct CheckboxProps {
413    /// Form field name for data binding.
414    pub field: String,
415    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
416    /// Required for multi-value checkbox groups (same name, different values).
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub value: Option<String>,
419    pub label: String,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub description: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub checked: Option<bool>,
424    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub data_path: Option<String>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub required: Option<bool>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub disabled: Option<bool>,
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub error: Option<String>,
433}
434
435/// Props for CheckboxList component — multi-select checkbox group.
436///
437/// Each checked option submits as `field=value`. Options may be supplied
438/// statically via `options` or resolved at render time from `options_path`.
439/// Pre-selected values are read from `selected_path` (a `Vec<String>`).
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
441pub struct CheckboxListProps {
442    /// Shared form field name; each checkbox submits as `field=value`.
443    pub field: String,
444    /// Static options list. When empty and `options_path` is set, options are
445    /// resolved from the data at render time.
446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
447    pub options: Vec<SelectOption>,
448    /// Data path to an array of `{value, label}` objects for data-driven options.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub options_path: Option<String>,
451    /// Data path to a `Vec<String>` of pre-selected values.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub selected_path: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub label: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub description: Option<String>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub disabled: Option<bool>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462}
463
464/// Props for Switch component.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
466pub struct SwitchProps {
467    /// Form field name for data binding.
468    pub field: String,
469    pub label: String,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub description: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub checked: Option<bool>,
474    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub data_path: Option<String>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub required: Option<bool>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub disabled: Option<bool>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub error: Option<String>,
483    /// Auto-submit action. When set, the switch renders inside a minimal
484    /// form and submits on change.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub action: Option<Action>,
487    /// When true, applies `scale-75 origin-left` CSS to the switch container
488    /// for compact inline display (e.g. per-row settings toggles).
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub compact: Option<bool>,
491}
492
493/// Props for Separator component.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
495pub struct SeparatorProps {
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub orientation: Option<Orientation>,
498}
499
500/// A single item in a description list.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
502pub struct DescriptionItem {
503    pub label: String,
504    pub value: String,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub format: Option<ColumnFormat>,
507}
508
509/// Props for DescriptionList component.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
511pub struct DescriptionListProps {
512    #[serde(default, skip_serializing_if = "Vec::is_empty")]
513    pub items: Vec<DescriptionItem>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub columns: Option<u8>,
516    /// Optional data-path override of `items`. When set, the renderer
517    /// resolves the array at this path and decodes each entry as a
518    /// `DescriptionItem`.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub data_path: Option<String>,
521}
522
523/// A single tab within a Tabs component.
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
525pub struct Tab {
526    pub value: String,
527    pub label: String,
528    /// IDs of elements rendered inside this tab's panel.
529    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530    pub children: Vec<String>,
531}
532
533/// Props for Tabs component.
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
535pub struct TabsProps {
536    pub default_tab: String,
537    pub tabs: Vec<Tab>,
538}
539
540/// A single item in a breadcrumb trail.
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542pub struct BreadcrumbItem {
543    pub label: String,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub url: Option<String>,
546}
547
548/// Props for Breadcrumb component.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
550pub struct BreadcrumbProps {
551    pub items: Vec<BreadcrumbItem>,
552}
553
554/// Props for Pagination component.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
556pub struct PaginationProps {
557    pub current_page: u32,
558    pub per_page: u32,
559    pub total: u32,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub base_url: Option<String>,
562}
563
564/// Props for Progress component.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
566pub struct ProgressProps {
567    /// Percentage value (0-100).
568    pub value: u8,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub max: Option<u8>,
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub label: Option<String>,
573}
574
575/// Props for Image component.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
577pub struct ImageProps {
578    #[serde(default)]
579    pub src: String,
580    pub alt: String,
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub aspect_ratio: Option<String>,
583    /// Optional label shown in a skeleton placeholder that sits behind the
584    /// image. When the image fails to load (or is still being generated),
585    /// the `<img>` is hidden via `onerror` and the placeholder remains
586    /// visible, keeping the container at its aspect-ratio size.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub placeholder_label: Option<String>,
589    /// Server-rendered inline SVG string. When set, the SVG is emitted verbatim
590    /// inside a `<div aria-label="{alt}">` wrapper; no `<img>` tag is produced.
591    ///
592    /// # Safety
593    /// Content is NOT sanitized. The SVG string is emitted into the response
594    /// verbatim. Pass only server-constructed SVG (e.g. bar charts, QR codes).
595    /// Do NOT pass untrusted input. `alt` is required and is HTML-escaped.
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub inline_svg: Option<String>,
598    /// Optional data-path override of `src`. When set, the renderer resolves
599    /// the value at this path against handler data and uses it as the
600    /// `<img src>`. Falls back to `src` when missing or non-string.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub data_path: Option<String>,
603}
604
605impl ImageProps {
606    /// Convenience constructor for inline-SVG images. `src` is set to the
607    /// empty string; the renderer takes the SVG path when `inline_svg` is `Some`.
608    ///
609    /// # Safety
610    /// `svg` is emitted verbatim. See [`ImageProps::inline_svg`] for the trust model.
611    pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
612        Self {
613            src: String::new(),
614            alt: alt.into(),
615            aspect_ratio: None,
616            placeholder_label: None,
617            inline_svg: Some(svg.into()),
618            data_path: None,
619        }
620    }
621}
622
623/// Props for Avatar component.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625pub struct AvatarProps {
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub src: Option<String>,
628    pub alt: String,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub fallback: Option<String>,
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub size: Option<Size>,
633}
634
635/// Props for Skeleton loading placeholder.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
637pub struct SkeletonProps {
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub width: Option<String>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub height: Option<String>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub rounded: Option<bool>,
644}
645
646/// Props for the `RawHtml` component — server-injected HTML island.
647///
648/// # Safety
649/// `html` is emitted into the response VERBATIM with NO sanitization. The
650/// component exists to bridge server-rendered HTML fragments (e.g. a status
651/// pill, a link badge) into a v2 spec where a first-class component would
652/// be over-engineering.
653///
654/// Sanitization is the CONSUMER's responsibility — pass only server-
655/// constructed HTML, or run untrusted input through a sanitiser (e.g.
656/// `ammonia`) in the handler before embedding. This mirrors
657/// `RichTextEditorProps` discipline (see component.rs).
658///
659/// For richer widgets (interactive forms, charts, OAuth flows), use the
660/// first-class plugin system (`JsonUiPlugin`) instead — see
661/// `docs/src/json-ui/plugins.md`.
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
663pub struct RawHtmlProps {
664    /// Server-constructed HTML emitted verbatim. NOT sanitized.
665    #[serde(default)]
666    pub html: String,
667}
668
669/// Props for the `StreamText` component — SSE token stream renderer.
670///
671/// Connects to `sse_url` via the browser `EventSource` API and appends arriving
672/// tokens as plain text nodes. The SSE endpoint MUST emit `event: done` on
673/// completion to prevent `EventSource` auto-reconnect.
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
675pub struct StreamTextProps {
676    /// URL of the server-sent-events endpoint that streams tokens.
677    /// Must emit `event: done` on completion.
678    #[serde(default)]
679    pub sse_url: String,
680    /// Text shown inside the content area before the first token arrives.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub placeholder: Option<String>,
683    /// Status text shown while the stream is open.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub loading_text: Option<String>,
686}
687
688/// Toast visual variants.
689#[derive(
690    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
691)]
692#[serde(rename_all = "snake_case")]
693#[strum(serialize_all = "snake_case")]
694pub enum ToastVariant {
695    #[default]
696    Info,
697    Success,
698    Warning,
699    Error,
700}
701
702/// A single item in a checklist.
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704pub struct ChecklistItem {
705    pub label: String,
706    #[serde(default)]
707    pub checked: bool,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub href: Option<String>,
710}
711
712/// A single item in a notification dropdown.
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
714pub struct NotificationItem {
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub icon: Option<String>,
717    pub text: String,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub timestamp: Option<String>,
720    #[serde(default)]
721    pub read: bool,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub action_url: Option<String>,
724}
725
726/// A single navigation item in the sidebar.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
728pub struct SidebarNavItem {
729    pub label: String,
730    pub href: String,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub icon: Option<String>,
733    #[serde(default)]
734    pub active: bool,
735    /// When true, the item renders as a muted, non-clickable `<span>`
736    /// instead of an `<a>` — useful for "coming soon" placeholders.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub disabled: Option<bool>,
739}
740
741/// A collapsible group in the sidebar.
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
743pub struct SidebarGroup {
744    pub label: String,
745    #[serde(default)]
746    pub collapsed: bool,
747    pub items: Vec<SidebarNavItem>,
748}
749
750/// Props for StatCard component (live-updatable metric card).
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
752pub struct StatCardProps {
753    pub label: String,
754    pub value: String,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub icon: Option<String>,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub subtitle: Option<String>,
759    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub sse_target: Option<String>,
762    /// Resolves the initial displayed value from handler data at render time.
763    /// Format: `/segment/segment` (same JSON-pointer as `data::resolve_path`).
764    /// Falls back to `value` when missing or non-string. Mirrors
765    /// `ImageProps.data_path` / `DescriptionListProps.data_path`.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub value_path: Option<String>,
768}
769
770/// Props for Checklist component.
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772pub struct ChecklistProps {
773    pub title: String,
774    pub items: Vec<ChecklistItem>,
775    #[serde(default = "default_true")]
776    pub dismissible: bool,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub dismiss_label: Option<String>,
779    /// Server-side state persistence key for this checklist.
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub data_key: Option<String>,
782}
783
784fn default_true() -> bool {
785    true
786}
787
788/// Props for Toast component (declarative notification intent).
789///
790/// The JS runtime reads data attributes from the rendered element to
791/// display the toast. Timeouts and dismissal are handled client-side.
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
793pub struct ToastProps {
794    pub message: String,
795    #[serde(default)]
796    pub variant: ToastVariant,
797    /// Seconds before auto-dismiss. Default 5.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub timeout: Option<u32>,
800    #[serde(default = "default_true")]
801    pub dismissible: bool,
802}
803
804/// Props for NotificationDropdown component.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
806pub struct NotificationDropdownProps {
807    pub notifications: Vec<NotificationItem>,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub empty_text: Option<String>,
810}
811
812/// Props for Sidebar component.
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
814pub struct SidebarProps {
815    #[serde(default, skip_serializing_if = "Vec::is_empty")]
816    pub fixed_top: Vec<SidebarNavItem>,
817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
818    pub groups: Vec<SidebarGroup>,
819    #[serde(default, skip_serializing_if = "Vec::is_empty")]
820    pub fixed_bottom: Vec<SidebarNavItem>,
821}
822
823/// Props for Header component.
824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
825pub struct HeaderProps {
826    pub business_name: String,
827    /// Unread notification count for badge display.
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub notification_count: Option<u32>,
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub user_name: Option<String>,
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub user_avatar: Option<String>,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub logout_url: Option<String>,
836}
837
838/// Gap size for Grid layout.
839#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
840#[serde(rename_all = "snake_case")]
841pub enum GapSize {
842    None,
843    Sm,
844    #[default]
845    Md,
846    Lg,
847    Xl,
848}
849
850/// Props for Grid component — multi-column layout.
851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
852pub struct GridProps {
853    /// Number of columns (1-12) at base (mobile) viewport.
854    #[serde(default = "default_grid_columns")]
855    pub columns: u8,
856    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub md_columns: Option<u8>,
859    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub lg_columns: Option<u8>,
862    /// Gap between grid items.
863    #[serde(default)]
864    pub gap: GapSize,
865    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
866    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub scrollable: Option<bool>,
869}
870
871fn default_grid_columns() -> u8 {
872    2
873}
874
875/// Props for Collapsible section — expandable `<details>`/`<summary>`.
876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
877pub struct CollapsibleProps {
878    pub title: String,
879    #[serde(default)]
880    pub expanded: bool,
881}
882
883/// Props for EmptyState component — standardized empty view.
884#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
885pub struct EmptyStateProps {
886    pub title: String,
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub description: Option<String>,
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub action: Option<Action>,
891    #[serde(default, skip_serializing_if = "Option::is_none")]
892    pub action_label: Option<String>,
893}
894
895/// Layout variant for form sections.
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
897#[serde(rename_all = "snake_case")]
898pub enum FormSectionLayout {
899    #[default]
900    Stacked,
901    TwoColumn,
902}
903
904/// Props for FormSection component — visual grouping within forms.
905#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
906pub struct FormSectionProps {
907    pub title: String,
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub description: Option<String>,
910    /// Optional layout variant. Defaults to stacked (single column).
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub layout: Option<FormSectionLayout>,
913}
914
915/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
916#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
917pub struct PageHeaderProps {
918    pub title: String,
919    #[serde(default, skip_serializing_if = "Vec::is_empty")]
920    pub breadcrumb: Vec<BreadcrumbItem>,
921    /// IDs of action button elements rendered to the right of the title.
922    #[serde(
923        default,
924        deserialize_with = "deserialize_actions_lax",
925        skip_serializing_if = "Vec::is_empty"
926    )]
927    pub actions: Vec<String>,
928}
929
930/// Props for ButtonGroup component -- horizontal button row with consistent gap.
931#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
932pub struct ButtonGroupProps {
933    /// Gap between buttons. Defaults to small spacing.
934    #[serde(default)]
935    pub gap: GapSize,
936}
937
938/// Props for SegmentedControl — a tightly-packed cluster of toggle/nav links
939/// rendered as a single bordered group with no gap between segments.
940///
941/// Items come either as a literal `items` array or from runtime data via
942/// `data_path` (controller-built). At least one of the two must be supplied;
943/// `items` wins when both are present.
944///
945/// Visual model: rounded outer container with a single border, internal
946/// dividers between segments, one segment marked `active=true` and styled
947/// distinctly. The label can be the literal segment text (e.g. "Oggi") or a
948/// glyph (e.g. "←", "→"). Each segment carries an optional `aria_label`
949/// override for accessibility on glyph-only segments.
950///
951/// Use cases captured by this primitive: date scroll clusters (prev/today/next),
952/// view toggles (Day/Month, List/Grid), pagination steppers, mode switchers.
953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
954pub struct SegmentedControlProps {
955    /// Literal items list. Skipped when empty; `data_path` is the fallback.
956    #[serde(default, skip_serializing_if = "Vec::is_empty")]
957    pub items: Vec<SegmentedItem>,
958    /// JSON Pointer into runtime data resolving to an array of `SegmentedItem`s.
959    /// Used when items shape depends on per-request data.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub data_path: Option<String>,
962    /// Visual size — defaults to `default`.
963    #[serde(default)]
964    pub size: Size,
965    /// Accessible label for the group (`<div role="tablist" aria-label="...">`).
966    /// Omit when the surrounding context already announces purpose.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub aria_label: Option<String>,
969}
970
971/// One segment of a `SegmentedControl`.
972#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
973pub struct SegmentedItem {
974    /// Visible label or glyph.
975    pub label: String,
976    /// Destination URL — segments render as `<a href>` so they work without JS.
977    pub href: String,
978    /// Active segment (one per group, typically). Highlighted, `aria-selected=true`.
979    #[serde(default)]
980    pub active: bool,
981    /// Optional accessible label override — useful when `label` is a glyph
982    /// like "←" or "→" that screen readers cannot pronounce.
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub aria_label: Option<String>,
985}
986
987/// Props for SidebarLayout — a two-column layout with a sticky vertical nav
988/// on the left and a main content slot on the right. Replaces the common
989/// pattern of opener/closer `RawHtml` blocks faking asymmetric grids.
990///
991/// The element's `children` IDs render inside the main slot. Each child is
992/// expected to carry its own `visible` rule keyed against `active` (typically
993/// `{ path: "/active_tab", operator: "eq", value: "<slug>" }`) so only the
994/// matching section is in the DOM at a time.
995///
996/// On mobile (below `md`), the sidebar collapses into a horizontally
997/// scrollable strip above the main content, and the asymmetric grid layout
998/// flattens to a single column.
999///
1000/// Use cases: settings pages with many sections, account dashboards,
1001/// onboarding wizards with persistent navigation, admin consoles.
1002#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1003pub struct SidebarLayoutProps {
1004    /// Literal sidebar items. Skipped when empty; `data_path` is the fallback.
1005    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1006    pub items: Vec<SidebarLayoutItem>,
1007    /// JSON Pointer into runtime data resolving to an array of `SidebarLayoutItem`s.
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub data_path: Option<String>,
1010    /// Slug of the currently-active item. Matched against `SidebarLayoutItem.slug`.
1011    /// Typically bound via `{ "$data": "/active_tab" }`.
1012    pub active: String,
1013    /// Accessible label for the nav (`<nav aria-label="...">`).
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub aria_label: Option<String>,
1016}
1017
1018/// One sidebar nav item in a `SidebarLayout`.
1019#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1020pub struct SidebarLayoutItem {
1021    /// Item identifier — matched against `SidebarLayoutProps.active` to determine
1022    /// which item is highlighted.
1023    pub slug: String,
1024    /// Visible label.
1025    pub label: String,
1026    /// Destination URL. Typically `"?tab={slug}"` for query-driven routing,
1027    /// but can be any absolute or relative URL.
1028    pub url: String,
1029}
1030
1031/// Props for DetailPage component -- opinionated resource-detail skeleton.
1032///
1033/// Renders a PageHeader (title + breadcrumb + actions), an info Card
1034/// wrapping the `info` slot IDs (typically a Badge plus a DescriptionList),
1035/// and `Element.children` as stacked sections below the card (tabs,
1036/// related-resource lists, action panels). Centralizes the visual contract
1037/// every dashboard detail page follows so per-page rebuilds cannot drift
1038/// from the canonical shape.
1039#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1040pub struct DetailPageProps {
1041    pub title: String,
1042    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1043    pub breadcrumb: Vec<BreadcrumbItem>,
1044    /// IDs of action button elements rendered to the right of the title.
1045    #[serde(
1046        default,
1047        deserialize_with = "deserialize_actions_lax",
1048        skip_serializing_if = "Vec::is_empty"
1049    )]
1050    pub actions: Vec<String>,
1051    /// IDs of elements rendered inside the info Card
1052    /// (typically a Badge and a DescriptionList). Omit to skip the card.
1053    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1054    pub info: Vec<String>,
1055}
1056
1057/// A single action item in a dropdown menu.
1058#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1059pub struct DropdownMenuAction {
1060    pub label: String,
1061    pub action: Action,
1062    #[serde(default)]
1063    pub destructive: bool,
1064    /// When set, this item is only emitted in a DataTable row when the row's
1065    /// `visible_if` field is truthy (true / non-zero number / non-empty string /
1066    /// non-empty array or object). An absent or falsy field hides the item —
1067    /// fail-closed so a typo in the view spec cannot leak an action onto every
1068    /// row. Outside DataTable contexts (e.g. standalone `DropdownMenu` element)
1069    /// the field is ignored.
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub visible_if: Option<String>,
1072}
1073
1074/// Props for DropdownMenu component — trigger button with absolutely-positioned action panel.
1075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1076pub struct DropdownMenuProps {
1077    pub menu_id: String,
1078    pub trigger_label: String,
1079    pub items: Vec<DropdownMenuAction>,
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub trigger_variant: Option<ButtonVariant>,
1082}
1083
1084/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
1085/// mobile card fallback, and empty state.
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1087pub struct DataTableProps {
1088    pub columns: Vec<Column>,
1089    pub data_path: String,
1090    #[serde(default, skip_serializing_if = "Option::is_none")]
1091    pub row_actions: Option<Vec<DropdownMenuAction>>,
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub empty_message: Option<String>,
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub row_key: Option<String>,
1096    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub row_href: Option<String>,
1099}
1100
1101/// Props for MediaCardGrid — a responsive card grid backed by a data array.
1102/// Mirrors DataTable's row_key/row_actions/data_path contract but renders
1103/// cards with an optional screenshot image instead of table rows.
1104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1105pub struct MediaCardGridProps {
1106    pub data_path: String,
1107    /// Key in each row object whose value becomes the card title.
1108    pub title_key: String,
1109    /// Key for the subtitle/URL line below the title.
1110    #[serde(default, skip_serializing_if = "Option::is_none")]
1111    pub description_key: Option<String>,
1112    /// Key for the screenshot image URL. No image rendered when absent or empty.
1113    #[serde(default, skip_serializing_if = "Option::is_none")]
1114    pub image_key: Option<String>,
1115    /// Key for the URL the image links to (opens in new tab).
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub image_href_key: Option<String>,
1118    /// CSS aspect-ratio value for the image (default "4/5").
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub image_aspect_ratio: Option<String>,
1121    /// CSS object-position for the cropped image: "top" | "center" | "bottom"
1122    /// (or any valid object-position value). Default "center".
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub image_position: Option<String>,
1125    /// Key for the footer badge label text.
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub badge_key: Option<String>,
1128    /// Key for the badge variant string: "outline" | "destructive" | "default".
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub badge_variant_key: Option<String>,
1131    /// Key used for {row_key} substitution in row_action URLs.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub row_key: Option<String>,
1134    #[serde(default, skip_serializing_if = "Option::is_none")]
1135    pub row_actions: Option<Vec<DropdownMenuAction>>,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub empty_message: Option<String>,
1138    /// Number of columns in the grid (default 3).
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub columns: Option<u8>,
1141}
1142
1143/// Props for a single column (lane) in a KanbanBoard.
1144///
1145/// A column is structure: its `id` is the lane key matched against each
1146/// item's `group_by` value, and `title` is the lane header. `count` and
1147/// `children` are only honored by static specs that set neither
1148/// `KanbanBoardProps.items_path` nor `group_by`; in the data-bound path the
1149/// renderer computes the count and renders cards from `items_path`.
1150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1151pub struct KanbanColumnProps {
1152    pub id: String,
1153    pub title: String,
1154    #[serde(default)]
1155    pub count: u32,
1156    /// IDs of elements rendered inside this column (static specs only).
1157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1158    pub children: Vec<String>,
1159}
1160
1161/// Props for KanbanBoard — horizontal scrollable columns on desktop, tab-based
1162/// on mobile.
1163///
1164/// A kanban is fixed lanes plus items sorted into them by a status field.
1165/// `columns` is structure only (lane `id` + `title`) and is always rendered —
1166/// an empty lane still shows its header and a zero count. Card content is
1167/// data-bound: `items_path` resolves a flat array of entity objects, each
1168/// bucketed into the column whose `id` equals the item's `group_by` value,
1169/// then rendered as a card via the `card_*` / `row_*` bindings. This is the
1170/// same prescribed-card + field-key convention used by `DataTable` and
1171/// `MediaCardGrid`. For fully-custom card structure, template the cards with
1172/// the `$each` directive inside a `KanbanColumn` instead.
1173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1174pub struct KanbanBoardProps {
1175    /// Lane structure — `id` + `title`. Always rendered.
1176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1177    pub columns: Vec<KanbanColumnProps>,
1178    /// JSON-Pointer to a flat array of entity objects. Each item is bucketed
1179    /// into the column whose `id` equals the item's `group_by` value.
1180    #[serde(default, skip_serializing_if = "Option::is_none")]
1181    pub items_path: Option<String>,
1182    /// Field on each item that selects its lane: `column.id == item[group_by]`.
1183    #[serde(default, skip_serializing_if = "Option::is_none")]
1184    pub group_by: Option<String>,
1185    /// Item field whose value becomes the card title.
1186    #[serde(default, skip_serializing_if = "Option::is_none")]
1187    pub card_title_key: Option<String>,
1188    /// Item field whose value becomes the card subtitle/description.
1189    #[serde(default, skip_serializing_if = "Option::is_none")]
1190    pub card_description_key: Option<String>,
1191    /// Per-card dropdown actions. `{row_key}` / `{id}` interpolate from the
1192    /// item, matching `DataTable` / `MediaCardGrid`.
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub row_actions: Option<Vec<DropdownMenuAction>>,
1195    /// Item field used for `{row_key}` substitution in action URLs
1196    /// (defaults to `id`).
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub row_key: Option<String>,
1199    #[serde(default, skip_serializing_if = "Option::is_none")]
1200    pub mobile_default_column: Option<String>,
1201    /// Placeholder text shown inside empty lanes. When `None`, empty lanes
1202    /// render no placeholder (back-compat). Provide a short, neutral message —
1203    /// e.g. "Nessun ordine", "Nothing here".
1204    #[serde(default, skip_serializing_if = "Option::is_none")]
1205    pub empty_label: Option<String>,
1206}
1207
1208/// Props for a calendar day cell.
1209///
1210/// Renders a single day in a month grid with today highlight,
1211/// out-of-month muting, and event count indicator.
1212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1213pub struct CalendarCellProps {
1214    pub day: u8,
1215    #[serde(default)]
1216    pub is_today: bool,
1217    #[serde(default)]
1218    pub is_current_month: bool,
1219    #[serde(default)]
1220    pub event_count: u32,
1221    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1222    /// When non-empty, colored dots are rendered instead of plain primary dots.
1223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1224    pub dot_colors: Vec<String>,
1225}
1226
1227/// Visual variant for action cards.
1228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1229#[serde(rename_all = "snake_case")]
1230pub enum ActionCardVariant {
1231    #[default]
1232    Default,
1233    Setup,
1234    Danger,
1235}
1236
1237/// Props for a horizontal action card with variant-colored left border.
1238///
1239/// Renders icon + title + description + chevron in a clickable row.
1240/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1242pub struct ActionCardProps {
1243    pub title: String,
1244    pub description: String,
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub icon: Option<String>,
1247    #[serde(default)]
1248    pub variant: ActionCardVariant,
1249    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub href: Option<String>,
1252}
1253
1254/// Props for a touch-friendly product tile with quantity controls.
1255///
1256/// Renders product name, price, and +/- buttons that drive a hidden input
1257/// via JS. Used for POS-style product selection during order creation.
1258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1259pub struct ProductTileProps {
1260    pub product_id: String,
1261    pub name: String,
1262    pub price: String,
1263    pub field: String,
1264    #[serde(default, skip_serializing_if = "Option::is_none")]
1265    pub default_quantity: Option<u32>,
1266}
1267
1268/// Lax deserializer for PageHeader.actions. Per D-19/F6:
1269/// Accepts: missing field (via #[serde(default)]), null, [], empty string "",
1270/// and array of strings. Rejects: non-empty strings, arrays of non-strings.
1271/// This loosens the wire-format contract for actions only — other Vec<String>
1272/// ID-slot fields (e.g. CardProps.footer) remain strict.
1273fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1274    d: D,
1275) -> Result<Vec<String>, D::Error> {
1276    use serde::de::Error;
1277    let v = serde_json::Value::deserialize(d)?;
1278    match v {
1279        serde_json::Value::Null => Ok(Vec::new()),
1280        serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1281        serde_json::Value::Array(arr) => arr
1282            .into_iter()
1283            .map(|item| {
1284                item.as_str()
1285                    .map(String::from)
1286                    .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1287            })
1288            .collect(),
1289        other => Err(D::Error::custom(format!(
1290            "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1291        ))),
1292    }
1293}
1294
1295#[cfg(test)]
1296mod schema_smoke_tests {
1297    //! Runtime `schema_for!` smoke tests per D-32.
1298    //!
1299    //! Each test asserts that the generated JSON Schema for the given Props
1300    //! struct is a non-empty JSON object with a populated `properties` field.
1301    //! This proves the `JsonSchema` derive executes without panic on every
1302    //! surviving Props struct — a compile-time `#[derive(JsonSchema)]` alone
1303    //! does not prove the generated code runs.
1304    //!
1305    //! One `#[test]` per type for clear failure localization.
1306
1307    use super::*;
1308
1309    fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1310        let schema = schemars::schema_for!(T);
1311        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1312        assert!(
1313            value.is_object(),
1314            "{type_label}: schema must be a JSON object"
1315        );
1316        let props = value
1317            .get("properties")
1318            .and_then(|p| p.as_object())
1319            .map(|o| !o.is_empty())
1320            .unwrap_or(false);
1321        assert!(
1322            props,
1323            "{type_label}: schema must have a non-empty `properties` field"
1324        );
1325    }
1326
1327    #[test]
1328    fn schema_for_card_props_generates() {
1329        assert_schema_nonempty_object::<CardProps>("CardProps");
1330    }
1331
1332    #[test]
1333    fn schema_for_table_props_generates() {
1334        assert_schema_nonempty_object::<TableProps>("TableProps");
1335    }
1336
1337    #[test]
1338    fn schema_for_form_props_generates() {
1339        assert_schema_nonempty_object::<FormProps>("FormProps");
1340    }
1341
1342    #[test]
1343    fn schema_for_button_props_generates() {
1344        assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1345    }
1346
1347    #[test]
1348    fn schema_for_input_props_generates() {
1349        assert_schema_nonempty_object::<InputProps>("InputProps");
1350    }
1351
1352    #[test]
1353    fn schema_for_select_props_generates() {
1354        assert_schema_nonempty_object::<SelectProps>("SelectProps");
1355    }
1356
1357    #[test]
1358    fn schema_for_alert_props_generates() {
1359        assert_schema_nonempty_object::<AlertProps>("AlertProps");
1360    }
1361
1362    #[test]
1363    fn schema_for_badge_props_generates() {
1364        assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1365    }
1366
1367    #[test]
1368    fn schema_for_modal_props_generates() {
1369        assert_schema_nonempty_object::<ModalProps>("ModalProps");
1370    }
1371
1372    #[test]
1373    fn schema_for_text_props_generates() {
1374        assert_schema_nonempty_object::<TextProps>("TextProps");
1375    }
1376
1377    #[test]
1378    fn schema_for_checkbox_props_generates() {
1379        assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1380    }
1381
1382    #[test]
1383    fn schema_for_switch_props_generates() {
1384        assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1385    }
1386
1387    #[test]
1388    fn schema_for_separator_props_generates() {
1389        assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1390    }
1391
1392    #[test]
1393    fn schema_for_description_list_props_generates() {
1394        assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1395    }
1396
1397    #[test]
1398    fn schema_for_tab_generates() {
1399        assert_schema_nonempty_object::<Tab>("Tab");
1400    }
1401
1402    #[test]
1403    fn schema_for_tabs_props_generates() {
1404        assert_schema_nonempty_object::<TabsProps>("TabsProps");
1405    }
1406
1407    #[test]
1408    fn schema_for_breadcrumb_props_generates() {
1409        assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1410    }
1411
1412    #[test]
1413    fn schema_for_pagination_props_generates() {
1414        assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1415    }
1416
1417    #[test]
1418    fn schema_for_progress_props_generates() {
1419        assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1420    }
1421
1422    #[test]
1423    fn schema_for_image_props_generates() {
1424        assert_schema_nonempty_object::<ImageProps>("ImageProps");
1425    }
1426
1427    #[test]
1428    fn image_inline_svg_factory_roundtrips_via_serde() {
1429        let p = ImageProps::inline_svg("<svg/>", "alt");
1430        let json = serde_json::to_value(&p).expect("serialization must not fail");
1431        let parsed: ImageProps =
1432            serde_json::from_value(json).expect("deserialization must not fail");
1433        assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1434        assert_eq!(parsed.alt, "alt");
1435        assert_eq!(parsed.src, "");
1436    }
1437
1438    #[test]
1439    fn schema_for_avatar_props_generates() {
1440        assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1441    }
1442
1443    #[test]
1444    fn schema_for_skeleton_props_generates() {
1445        assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1446    }
1447
1448    #[test]
1449    fn schema_for_stat_card_props_generates() {
1450        assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1451    }
1452
1453    #[test]
1454    fn schema_for_checklist_props_generates() {
1455        assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1456    }
1457
1458    #[test]
1459    fn schema_for_toast_props_generates() {
1460        assert_schema_nonempty_object::<ToastProps>("ToastProps");
1461    }
1462
1463    #[test]
1464    fn schema_for_notification_dropdown_props_generates() {
1465        assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1466    }
1467
1468    #[test]
1469    fn schema_for_sidebar_props_generates() {
1470        assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1471    }
1472
1473    #[test]
1474    fn schema_for_header_props_generates() {
1475        assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1476    }
1477
1478    #[test]
1479    fn schema_for_grid_props_generates() {
1480        assert_schema_nonempty_object::<GridProps>("GridProps");
1481    }
1482
1483    #[test]
1484    fn schema_for_collapsible_props_generates() {
1485        assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1486    }
1487
1488    #[test]
1489    fn schema_for_empty_state_props_generates() {
1490        assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1491    }
1492
1493    #[test]
1494    fn schema_for_form_section_props_generates() {
1495        assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1496    }
1497
1498    #[test]
1499    fn schema_for_page_header_props_generates() {
1500        assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1501    }
1502
1503    #[test]
1504    fn schema_for_button_group_props_generates() {
1505        assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1506    }
1507
1508    #[test]
1509    fn schema_for_dropdown_menu_action_generates() {
1510        assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1511    }
1512
1513    #[test]
1514    fn schema_for_dropdown_menu_props_generates() {
1515        assert_schema_nonempty_object::<DropdownMenuProps>("DropdownMenuProps");
1516    }
1517
1518    #[test]
1519    fn schema_for_data_table_props_generates() {
1520        assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1521    }
1522
1523    #[test]
1524    fn schema_for_kanban_column_props_generates() {
1525        assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1526    }
1527
1528    #[test]
1529    fn schema_for_kanban_board_props_generates() {
1530        assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1531    }
1532
1533    #[test]
1534    fn schema_for_calendar_cell_props_generates() {
1535        assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1536    }
1537
1538    #[test]
1539    fn schema_for_action_card_props_generates() {
1540        assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1541    }
1542
1543    #[test]
1544    fn schema_for_product_tile_props_generates() {
1545        assert_schema_nonempty_object::<ProductTileProps>("ProductTileProps");
1546    }
1547
1548    #[test]
1549    fn card_props_round_trips_footer() {
1550        let original = CardProps {
1551            title: "Hero".to_string(),
1552            description: None,
1553            subtitle: None,
1554            badge: None,
1555            max_width: None,
1556            footer: vec!["btn1".to_string(), "btn2".to_string()],
1557            variant: CardVariant::Bordered,
1558        };
1559        let json = serde_json::to_string(&original).unwrap();
1560        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1561        assert_eq!(original.footer, parsed.footer);
1562    }
1563
1564    #[test]
1565    fn tab_round_trips_children() {
1566        let original = Tab {
1567            value: "overview".to_string(),
1568            label: "Overview".to_string(),
1569            children: vec!["panel1".to_string()],
1570        };
1571        let json = serde_json::to_string(&original).unwrap();
1572        let parsed: Tab = serde_json::from_str(&json).unwrap();
1573        assert_eq!(original.children, parsed.children);
1574    }
1575
1576    #[test]
1577    fn card_props_omits_empty_footer_in_json() {
1578        let card = CardProps {
1579            title: "Card".to_string(),
1580            description: None,
1581            subtitle: None,
1582            badge: None,
1583            max_width: None,
1584            footer: Vec::new(),
1585            variant: CardVariant::Bordered,
1586        };
1587        let json = serde_json::to_string(&card).unwrap();
1588        assert!(
1589            !json.contains("\"footer\""),
1590            "empty footer must be skipped, got: {json}"
1591        );
1592    }
1593
1594    #[test]
1595    fn card_props_round_trips_badge() {
1596        let original = CardProps {
1597            title: "Hero".to_string(),
1598            description: None,
1599            subtitle: None,
1600            badge: Some("Scade tra 9m".to_string()),
1601            max_width: None,
1602            footer: Vec::new(),
1603            variant: CardVariant::Bordered,
1604        };
1605        let json = serde_json::to_string(&original).unwrap();
1606        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1607        assert_eq!(original.badge, parsed.badge);
1608    }
1609
1610    #[test]
1611    fn card_props_omits_empty_badge_in_json() {
1612        let card = CardProps {
1613            title: "Card".to_string(),
1614            description: None,
1615            subtitle: None,
1616            badge: None,
1617            max_width: None,
1618            footer: Vec::new(),
1619            variant: CardVariant::Bordered,
1620        };
1621        let json = serde_json::to_string(&card).unwrap();
1622        assert!(
1623            !json.contains("\"badge\""),
1624            "empty badge must be skipped, got: {json}"
1625        );
1626    }
1627
1628    #[test]
1629    fn card_props_round_trips_subtitle() {
1630        let original = CardProps {
1631            title: "Hero".to_string(),
1632            description: None,
1633            subtitle: Some("Marco Rossi".to_string()),
1634            badge: None,
1635            max_width: None,
1636            footer: Vec::new(),
1637            variant: CardVariant::Bordered,
1638        };
1639        let json = serde_json::to_string(&original).unwrap();
1640        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1641        assert_eq!(original.subtitle, parsed.subtitle);
1642    }
1643
1644    #[test]
1645    fn card_props_omits_empty_subtitle_in_json() {
1646        let card = CardProps {
1647            title: "Card".to_string(),
1648            description: None,
1649            subtitle: None,
1650            badge: None,
1651            max_width: None,
1652            footer: Vec::new(),
1653            variant: CardVariant::Bordered,
1654        };
1655        let json = serde_json::to_string(&card).unwrap();
1656        assert!(
1657            !json.contains("\"subtitle\""),
1658            "empty subtitle must be skipped, got: {json}"
1659        );
1660    }
1661
1662    #[test]
1663    fn card_props_schema_includes_badge() {
1664        let schema = schemars::schema_for!(CardProps);
1665        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1666        let props = value
1667            .get("properties")
1668            .and_then(|p| p.as_object())
1669            .expect("schema has a properties object");
1670        assert!(
1671            props.contains_key("badge"),
1672            "CardProps schema must expose a `badge` property; got keys: {:?}",
1673            props.keys().collect::<Vec<_>>()
1674        );
1675        // `badge: Option<String>` — schemars 1.x emits either {"type": ["string","null"]}
1676        // or a {"type":"string"} entry inside a oneOf/anyOf branch. We only assert
1677        // presence + that the rendered schema mentions a string somewhere under
1678        // the badge entry, which is robust to either encoding.
1679        let badge_schema = props.get("badge").expect("badge entry");
1680        let badge_json = badge_schema.to_string();
1681        assert!(
1682            badge_json.contains("\"string\""),
1683            "badge schema entry must mention string type; got: {badge_json}"
1684        );
1685    }
1686
1687    #[test]
1688    fn card_props_schema_includes_subtitle() {
1689        let schema = schemars::schema_for!(CardProps);
1690        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1691        let props = value
1692            .get("properties")
1693            .and_then(|p| p.as_object())
1694            .expect("schema has a properties object");
1695        assert!(
1696            props.contains_key("subtitle"),
1697            "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1698            props.keys().collect::<Vec<_>>()
1699        );
1700        // Same robustness note as `card_props_schema_includes_badge` —
1701        // `subtitle: Option<String>` may surface as type-union or oneOf depending
1702        // on the schemars version. Assert string is mentioned in the rendered
1703        // entry rather than locking down the exact null encoding.
1704        let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1705        let subtitle_json = subtitle_schema.to_string();
1706        assert!(
1707            subtitle_json.contains("\"string\""),
1708            "subtitle schema entry must mention string type; got: {subtitle_json}"
1709        );
1710    }
1711
1712    #[test]
1713    fn schema_for_checkbox_list_props_generates() {
1714        assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
1715    }
1716
1717    #[test]
1718    fn checkbox_list_props_serde_roundtrip() {
1719        let json = serde_json::json!({
1720            "field": "services",
1721            "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
1722            "selected_path": "/preselected"
1723        });
1724        let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
1725        assert_eq!(parsed.field, "services");
1726        assert_eq!(parsed.options.len(), 2);
1727        assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
1728        let reserialized = serde_json::to_value(&parsed).expect("encode");
1729        // None/empty fields are omitted by serde.
1730        assert!(reserialized.get("label").is_none());
1731        assert!(reserialized.get("disabled").is_none());
1732    }
1733
1734    #[test]
1735    fn schema_for_rich_text_editor_props_generates() {
1736        assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
1737    }
1738
1739    #[test]
1740    fn rich_text_editor_props_serde_roundtrip() {
1741        let json = serde_json::json!({
1742            "field": "body",
1743            "label": "Body"
1744        });
1745        let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
1746        assert_eq!(parsed.field, "body");
1747        assert_eq!(parsed.label, "Body");
1748        assert!(parsed.placeholder.is_none());
1749        assert!(parsed.default_value.is_none());
1750        assert!(parsed.data_path.is_none());
1751        assert!(parsed.error.is_none());
1752        let reserialized = serde_json::to_value(&parsed).expect("encode");
1753        // Optional None fields are omitted.
1754        assert!(reserialized.get("placeholder").is_none());
1755        assert!(reserialized.get("error").is_none());
1756    }
1757}
1758
1759#[cfg(test)]
1760mod strum_tests {
1761    use super::*;
1762
1763    /// Assert AsRef<str> matches serde JSON wire format for every variant of
1764    /// AlertVariant, BadgeVariant, ButtonVariant, and ToastVariant.
1765    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
1766    #[test]
1767    fn variant_enums_strum_matches_serde_wire_format() {
1768        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
1769            for v in variants {
1770                let json = serde_json::to_string(v).expect("serialize");
1771                let json_stripped = json.trim_matches('"');
1772                assert_eq!(
1773                    v.as_ref(),
1774                    json_stripped,
1775                    "strum AsRefStr drifted from serde for {label} variant"
1776                );
1777            }
1778        }
1779        check(
1780            &[
1781                AlertVariant::Info,
1782                AlertVariant::Success,
1783                AlertVariant::Warning,
1784                AlertVariant::Error,
1785            ],
1786            "AlertVariant",
1787        );
1788        check(
1789            &[
1790                BadgeVariant::Default,
1791                BadgeVariant::Secondary,
1792                BadgeVariant::Destructive,
1793                BadgeVariant::Outline,
1794            ],
1795            "BadgeVariant",
1796        );
1797        check(
1798            &[
1799                ButtonVariant::Default,
1800                ButtonVariant::Secondary,
1801                ButtonVariant::Destructive,
1802                ButtonVariant::Outline,
1803                ButtonVariant::Ghost,
1804                ButtonVariant::Link,
1805            ],
1806            "ButtonVariant",
1807        );
1808        check(
1809            &[
1810                ToastVariant::Info,
1811                ToastVariant::Success,
1812                ToastVariant::Warning,
1813                ToastVariant::Error,
1814            ],
1815            "ToastVariant",
1816        );
1817    }
1818
1819    #[test]
1820    fn alert_variant_as_ref_str_matches_wire_format() {
1821        assert_eq!(AlertVariant::Success.as_ref(), "success");
1822        assert_eq!(AlertVariant::Warning.as_ref(), "warning");
1823        assert_eq!(AlertVariant::Info.as_ref(), "info");
1824        assert_eq!(AlertVariant::Error.as_ref(), "error");
1825    }
1826}
1827
1828#[cfg(test)]
1829mod card_variant_tests {
1830    use super::*;
1831
1832    #[test]
1833    fn card_variant_default_is_bordered() {
1834        assert_eq!(CardVariant::default(), CardVariant::Bordered);
1835    }
1836
1837    #[test]
1838    fn card_variant_serializes_snake_case() {
1839        assert_eq!(
1840            serde_json::to_value(CardVariant::Bordered).unwrap(),
1841            serde_json::json!("bordered")
1842        );
1843        assert_eq!(
1844            serde_json::to_value(CardVariant::Elevated).unwrap(),
1845            serde_json::json!("elevated")
1846        );
1847    }
1848
1849    #[test]
1850    fn card_variant_deserializes_snake_case() {
1851        assert_eq!(
1852            serde_json::from_value::<CardVariant>(serde_json::json!("bordered")).unwrap(),
1853            CardVariant::Bordered
1854        );
1855        assert_eq!(
1856            serde_json::from_value::<CardVariant>(serde_json::json!("elevated")).unwrap(),
1857            CardVariant::Elevated
1858        );
1859    }
1860
1861    #[test]
1862    fn card_props_without_variant_defaults_to_bordered() {
1863        let v = serde_json::json!({"title": "x"});
1864        let p: CardProps = serde_json::from_value(v).unwrap();
1865        assert_eq!(p.variant, CardVariant::Bordered);
1866    }
1867
1868    #[test]
1869    fn card_props_with_elevated_variant() {
1870        let v = serde_json::json!({"title": "x", "variant": "elevated"});
1871        let p: CardProps = serde_json::from_value(v).unwrap();
1872        assert_eq!(p.variant, CardVariant::Elevated);
1873    }
1874
1875    #[test]
1876    fn card_props_roundtrip_preserves_variant() {
1877        let p = CardProps {
1878            title: "x".into(),
1879            description: None,
1880            subtitle: None,
1881            badge: None,
1882            max_width: None,
1883            footer: vec![],
1884            variant: CardVariant::Elevated,
1885        };
1886        let j = serde_json::to_value(&p).unwrap();
1887        let back: CardProps = serde_json::from_value(j).unwrap();
1888        assert_eq!(back.variant, CardVariant::Elevated);
1889    }
1890}
1891
1892#[cfg(test)]
1893mod kanban_board_props_tests {
1894    use super::*;
1895
1896    #[test]
1897    fn kanban_board_props_serde_static_columns() {
1898        let v = serde_json::json!({
1899            "columns": [{"title": "To Do", "id": "todo", "count": 0}]
1900        });
1901        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1902        assert_eq!(p.columns.len(), 1);
1903        assert!(p.items_path.is_none());
1904        assert!(p.group_by.is_none());
1905    }
1906
1907    #[test]
1908    fn kanban_board_props_serde_data_bound() {
1909        let v = serde_json::json!({
1910            "columns": [{"title": "Open", "id": "open"}],
1911            "items_path": "/data/order",
1912            "group_by": "status",
1913            "card_title_key": "name"
1914        });
1915        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1916        assert_eq!(p.columns.len(), 1);
1917        assert_eq!(p.items_path.as_deref(), Some("/data/order"));
1918        assert_eq!(p.group_by.as_deref(), Some("status"));
1919        assert_eq!(p.card_title_key.as_deref(), Some("name"));
1920    }
1921
1922    #[test]
1923    fn kanban_board_props_serde_neither() {
1924        let v = serde_json::json!({});
1925        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1926        assert!(p.columns.is_empty());
1927        assert!(p.items_path.is_none());
1928        assert!(p.group_by.is_none());
1929    }
1930
1931    #[test]
1932    fn kanban_board_props_empty_columns_skipped_on_serialize() {
1933        let p = KanbanBoardProps {
1934            columns: vec![],
1935            items_path: Some("/data/order".into()),
1936            group_by: Some("status".into()),
1937            card_title_key: None,
1938            card_description_key: None,
1939            row_actions: None,
1940            row_key: None,
1941            mobile_default_column: None,
1942            empty_label: None,
1943        };
1944        let j = serde_json::to_value(&p).unwrap();
1945        assert!(
1946            j.get("columns").is_none(),
1947            "empty columns must be skipped, got: {j}"
1948        );
1949        assert_eq!(
1950            j.get("items_path").and_then(|v| v.as_str()),
1951            Some("/data/order")
1952        );
1953    }
1954}
1955
1956#[cfg(test)]
1957mod page_header_actions_tests {
1958    use super::*;
1959
1960    #[test]
1961    fn page_header_actions_missing_field() {
1962        let v = serde_json::json!({"title": "X"});
1963        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1964        assert!(p.actions.is_empty());
1965    }
1966
1967    #[test]
1968    fn page_header_actions_null() {
1969        let v = serde_json::json!({"title": "X", "actions": null});
1970        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1971        assert!(p.actions.is_empty());
1972    }
1973
1974    #[test]
1975    fn page_header_actions_empty_string() {
1976        let v = serde_json::json!({"title": "X", "actions": ""});
1977        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1978        assert!(p.actions.is_empty());
1979    }
1980
1981    #[test]
1982    fn page_header_actions_empty_array() {
1983        let v = serde_json::json!({"title": "X", "actions": []});
1984        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1985        assert!(p.actions.is_empty());
1986    }
1987
1988    #[test]
1989    fn page_header_actions_non_empty_array() {
1990        let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
1991        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1992        assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
1993    }
1994
1995    #[test]
1996    fn page_header_actions_non_empty_string_rejected() {
1997        let v = serde_json::json!({"title": "X", "actions": "not-empty"});
1998        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
1999        assert!(result.is_err(), "non-empty string must be rejected");
2000    }
2001
2002    #[test]
2003    fn page_header_actions_non_string_array_rejected() {
2004        let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2005        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2006        assert!(result.is_err(), "array of non-strings must be rejected");
2007    }
2008}