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/// A single action in an `ActionGroup`'s ordered item list.
939///
940/// Inline items (non-destructive, within `max_inline`) render as buttons.
941/// The `destructive` flag forces the item into the overflow kebab and renders
942/// it last regardless of its position in `items`.
943///
944/// `visible_if` is a fail-closed row gate (same semantics as
945/// `DropdownMenuAction.visible_if`): when set, the item is hidden unless
946/// `row[field]` is truthy. An absent or falsy field hides the item — a typo
947/// in the field name cannot leak an action.
948#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
949pub struct ActionItem {
950    pub label: String,
951    pub action: Action,
952    /// When true, this item is forced into the overflow kebab and rendered last,
953    /// regardless of position in `items`. Does not count toward `max_inline`.
954    #[serde(default)]
955    pub destructive: bool,
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub variant: Option<ButtonVariant>,
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub icon: Option<String>,
960    /// Fail-closed row gate (same semantics as `DropdownMenuAction.visible_if`).
961    /// When set, the item is only shown when `row[visible_if]` is truthy.
962    /// Absent/falsy field hides the item.
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub visible_if: Option<String>,
965}
966
967/// Props for `ActionGroup` — ordered action list rendering inline buttons (up to
968/// `max_inline`) plus a trailing overflow kebab for the remainder. Destructive
969/// items are always in the kebab, rendered last, regardless of input order.
970///
971/// Input order determines button priority: the first item in `items` is the
972/// primary action and renders first inline. Use `variant` on an item to control
973/// button styling.
974///
975/// The overflow kebab is hidden entirely when nothing overflows (≤ `max_inline`
976/// non-destructive items and zero destructive items).
977#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
978pub struct ActionGroupProps {
979    pub items: Vec<ActionItem>,
980    /// ID pairing the overflow popover to its trigger button. Required; callers
981    /// must supply a unique value per page to prevent DOM id collisions.
982    pub menu_id: String,
983    /// Maximum non-destructive items rendered inline (default 2).
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub max_inline: Option<u8>,
986    /// Aria-label for the overflow trigger button (default "Azioni").
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub overflow_label: Option<String>,
989    /// Key used for `{row_key}` substitution in action URLs (DataTable / Kanban context).
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub row_key: Option<String>,
992}
993
994/// Props for SegmentedControl — a tightly-packed cluster of toggle/nav links
995/// rendered as a single bordered group with no gap between segments.
996///
997/// Items come either as a literal `items` array or from runtime data via
998/// `data_path` (controller-built). At least one of the two must be supplied;
999/// `items` wins when both are present.
1000///
1001/// Visual model: rounded outer container with a single border, internal
1002/// dividers between segments, one segment marked `active=true` and styled
1003/// distinctly. The label can be the literal segment text (e.g. "Oggi") or a
1004/// glyph (e.g. "←", "→"). Each segment carries an optional `aria_label`
1005/// override for accessibility on glyph-only segments.
1006///
1007/// Use cases captured by this primitive: date scroll clusters (prev/today/next),
1008/// view toggles (Day/Month, List/Grid), pagination steppers, mode switchers.
1009#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1010pub struct SegmentedControlProps {
1011    /// Literal items list. Skipped when empty; `data_path` is the fallback.
1012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1013    pub items: Vec<SegmentedItem>,
1014    /// JSON Pointer into runtime data resolving to an array of `SegmentedItem`s.
1015    /// Used when items shape depends on per-request data.
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub data_path: Option<String>,
1018    /// Visual size — defaults to `default`.
1019    #[serde(default)]
1020    pub size: Size,
1021    /// Accessible label for the group (`<div role="tablist" aria-label="...">`).
1022    /// Omit when the surrounding context already announces purpose.
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub aria_label: Option<String>,
1025}
1026
1027/// One segment of a `SegmentedControl`.
1028#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1029pub struct SegmentedItem {
1030    /// Visible label or glyph.
1031    pub label: String,
1032    /// Destination URL — segments render as `<a href>` so they work without JS.
1033    pub href: String,
1034    /// Active segment (one per group, typically). Highlighted, `aria-selected=true`.
1035    #[serde(default)]
1036    pub active: bool,
1037    /// Optional accessible label override — useful when `label` is a glyph
1038    /// like "←" or "→" that screen readers cannot pronounce.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub aria_label: Option<String>,
1041}
1042
1043/// Props for SidebarLayout — a two-column layout with a sticky vertical nav
1044/// on the left and a main content slot on the right. Replaces the common
1045/// pattern of opener/closer `RawHtml` blocks faking asymmetric grids.
1046///
1047/// The element's `children` IDs render inside the main slot. Each child is
1048/// expected to carry its own `visible` rule keyed against `active` (typically
1049/// `{ path: "/active_tab", operator: "eq", value: "<slug>" }`) so only the
1050/// matching section is in the DOM at a time.
1051///
1052/// On mobile (below `md`), the sidebar collapses into a horizontally
1053/// scrollable strip above the main content, and the asymmetric grid layout
1054/// flattens to a single column.
1055///
1056/// Use cases: settings pages with many sections, account dashboards,
1057/// onboarding wizards with persistent navigation, admin consoles.
1058#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1059pub struct SidebarLayoutProps {
1060    /// Literal sidebar items. Skipped when empty; `data_path` is the fallback.
1061    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1062    pub items: Vec<SidebarLayoutItem>,
1063    /// JSON Pointer into runtime data resolving to an array of `SidebarLayoutItem`s.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub data_path: Option<String>,
1066    /// Slug of the currently-active item. Matched against `SidebarLayoutItem.slug`.
1067    /// Typically bound via `{ "$data": "/active_tab" }`.
1068    pub active: String,
1069    /// Accessible label for the nav (`<nav aria-label="...">`).
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub aria_label: Option<String>,
1072}
1073
1074/// One sidebar nav item in a `SidebarLayout`.
1075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1076pub struct SidebarLayoutItem {
1077    /// Item identifier — matched against `SidebarLayoutProps.active` to determine
1078    /// which item is highlighted.
1079    pub slug: String,
1080    /// Visible label.
1081    pub label: String,
1082    /// Destination URL. Typically `"?tab={slug}"` for query-driven routing,
1083    /// but can be any absolute or relative URL.
1084    pub url: String,
1085}
1086
1087/// Props for DetailPage component -- opinionated resource-detail skeleton.
1088///
1089/// Renders a PageHeader (title + breadcrumb + actions), an info Card
1090/// wrapping the `info` slot IDs (typically a Badge plus a DescriptionList),
1091/// and `Element.children` as stacked sections below the card (tabs,
1092/// related-resource lists, action panels). Centralizes the visual contract
1093/// every dashboard detail page follows so per-page rebuilds cannot drift
1094/// from the canonical shape.
1095#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1096pub struct DetailPageProps {
1097    pub title: String,
1098    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1099    pub breadcrumb: Vec<BreadcrumbItem>,
1100    /// IDs of action button elements rendered to the right of the title.
1101    #[serde(
1102        default,
1103        deserialize_with = "deserialize_actions_lax",
1104        skip_serializing_if = "Vec::is_empty"
1105    )]
1106    pub actions: Vec<String>,
1107    /// IDs of elements rendered inside the info Card
1108    /// (typically a Badge and a DescriptionList). Omit to skip the card.
1109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1110    pub info: Vec<String>,
1111}
1112
1113/// A single action item in a dropdown menu.
1114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1115pub struct DropdownMenuAction {
1116    pub label: String,
1117    pub action: Action,
1118    #[serde(default)]
1119    pub destructive: bool,
1120    /// When set, this item is only emitted in a DataTable row when the row's
1121    /// `visible_if` field is truthy (true / non-zero number / non-empty string /
1122    /// non-empty array or object). An absent or falsy field hides the item —
1123    /// fail-closed so a typo in the view spec cannot leak an action onto every
1124    /// row. Outside DataTable contexts (e.g. standalone `DropdownMenu` element)
1125    /// the field is ignored.
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub visible_if: Option<String>,
1128}
1129
1130/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
1131/// mobile card fallback, and empty state.
1132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1133pub struct DataTableProps {
1134    pub columns: Vec<Column>,
1135    pub data_path: String,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub row_actions: Option<Vec<DropdownMenuAction>>,
1138    #[serde(default, skip_serializing_if = "Option::is_none")]
1139    pub empty_message: Option<String>,
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub row_key: Option<String>,
1142    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub row_href: Option<String>,
1145}
1146
1147/// Props for MediaCardGrid — a responsive card grid backed by a data array.
1148/// Mirrors DataTable's row_key/row_actions/data_path contract but renders
1149/// cards with an optional screenshot image instead of table rows.
1150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1151pub struct MediaCardGridProps {
1152    pub data_path: String,
1153    /// Key in each row object whose value becomes the card title.
1154    pub title_key: String,
1155    /// Key for the subtitle/URL line below the title.
1156    #[serde(default, skip_serializing_if = "Option::is_none")]
1157    pub description_key: Option<String>,
1158    /// Key for the screenshot image URL. No image rendered when absent or empty.
1159    #[serde(default, skip_serializing_if = "Option::is_none")]
1160    pub image_key: Option<String>,
1161    /// Key for the URL the image links to (opens in new tab).
1162    #[serde(default, skip_serializing_if = "Option::is_none")]
1163    pub image_href_key: Option<String>,
1164    /// CSS aspect-ratio value for the image (default "4/5").
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    pub image_aspect_ratio: Option<String>,
1167    /// CSS object-position for the cropped image: "top" | "center" | "bottom"
1168    /// (or any valid object-position value). Default "center".
1169    #[serde(default, skip_serializing_if = "Option::is_none")]
1170    pub image_position: Option<String>,
1171    /// Key for the footer badge label text.
1172    #[serde(default, skip_serializing_if = "Option::is_none")]
1173    pub badge_key: Option<String>,
1174    /// Key for the badge variant string: "outline" | "destructive" | "default".
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub badge_variant_key: Option<String>,
1177    /// Key used for {row_key} substitution in row_action URLs.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub row_key: Option<String>,
1180    #[serde(default, skip_serializing_if = "Option::is_none")]
1181    pub row_actions: Option<Vec<DropdownMenuAction>>,
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub empty_message: Option<String>,
1184    /// Number of columns in the grid (default 3).
1185    #[serde(default, skip_serializing_if = "Option::is_none")]
1186    pub columns: Option<u8>,
1187}
1188
1189/// Props for a single column (lane) in a KanbanBoard.
1190///
1191/// A column is structure: its `id` is the lane key matched against each
1192/// item's `group_by` value, and `title` is the lane header. `count` and
1193/// `children` are only honored by static specs that set neither
1194/// `KanbanBoardProps.items_path` nor `group_by`; in the data-bound path the
1195/// renderer computes the count and renders cards from `items_path`.
1196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1197pub struct KanbanColumnProps {
1198    pub id: String,
1199    pub title: String,
1200    #[serde(default)]
1201    pub count: u32,
1202    /// IDs of elements rendered inside this column (static specs only).
1203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1204    pub children: Vec<String>,
1205}
1206
1207/// Props for KanbanBoard — horizontal scrollable columns on desktop, tab-based
1208/// on mobile.
1209///
1210/// A kanban is fixed lanes plus items sorted into them by a status field.
1211/// `columns` is structure only (lane `id` + `title`) and is always rendered —
1212/// an empty lane still shows its header and a zero count. Card content is
1213/// data-bound: `items_path` resolves a flat array of entity objects, each
1214/// bucketed into the column whose `id` equals the item's `group_by` value,
1215/// then rendered as a card via the `card_*` / `row_*` bindings. This is the
1216/// same prescribed-card + field-key convention used by `DataTable` and
1217/// `MediaCardGrid`. For fully-custom card structure, template the cards with
1218/// the `$each` directive inside a `KanbanColumn` instead.
1219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1220pub struct KanbanBoardProps {
1221    /// Lane structure — `id` + `title`. Always rendered.
1222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1223    pub columns: Vec<KanbanColumnProps>,
1224    /// JSON-Pointer to a flat array of entity objects. Each item is bucketed
1225    /// into the column whose `id` equals the item's `group_by` value.
1226    #[serde(default, skip_serializing_if = "Option::is_none")]
1227    pub items_path: Option<String>,
1228    /// Field on each item that selects its lane: `column.id == item[group_by]`.
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub group_by: Option<String>,
1231    /// Item field whose value becomes the card title.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub card_title_key: Option<String>,
1234    /// Item field whose value becomes the card subtitle/description.
1235    #[serde(default, skip_serializing_if = "Option::is_none")]
1236    pub card_description_key: Option<String>,
1237    /// Per-card dropdown actions. `{row_key}` / `{id}` interpolate from the
1238    /// item, matching `DataTable` / `MediaCardGrid`.
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    pub row_actions: Option<Vec<DropdownMenuAction>>,
1241    /// Item field used for `{row_key}` substitution in action URLs
1242    /// (defaults to `id`).
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    pub row_key: Option<String>,
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub mobile_default_column: Option<String>,
1247    /// Placeholder text shown inside empty lanes. When `None`, empty lanes
1248    /// render no placeholder (back-compat). Provide a short, neutral message —
1249    /// e.g. "Nessun ordine", "Nothing here".
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub empty_label: Option<String>,
1252}
1253
1254/// Props for a calendar day cell.
1255///
1256/// Renders a single day in a month grid with today highlight,
1257/// out-of-month muting, and event count indicator.
1258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1259pub struct CalendarCellProps {
1260    pub day: u8,
1261    #[serde(default)]
1262    pub is_today: bool,
1263    #[serde(default)]
1264    pub is_current_month: bool,
1265    #[serde(default)]
1266    pub event_count: u32,
1267    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1268    /// When non-empty, colored dots are rendered instead of plain primary dots.
1269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1270    pub dot_colors: Vec<String>,
1271}
1272
1273/// Visual variant for action cards.
1274#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1275#[serde(rename_all = "snake_case")]
1276pub enum ActionCardVariant {
1277    #[default]
1278    Default,
1279    Setup,
1280    Danger,
1281}
1282
1283/// Props for a horizontal action card with variant-colored left border.
1284///
1285/// Renders icon + title + description + chevron in a clickable row.
1286/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1288pub struct ActionCardProps {
1289    pub title: String,
1290    pub description: String,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub icon: Option<String>,
1293    #[serde(default)]
1294    pub variant: ActionCardVariant,
1295    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1296    #[serde(default, skip_serializing_if = "Option::is_none")]
1297    pub href: Option<String>,
1298}
1299
1300/// Props for a touch-friendly product tile with quantity controls.
1301///
1302/// Renders product name, price, and +/- buttons that drive a hidden input
1303/// via JS. Used for POS-style product selection during order creation.
1304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1305pub struct ProductTileProps {
1306    pub product_id: String,
1307    pub name: String,
1308    pub price: String,
1309    pub field: String,
1310    #[serde(default, skip_serializing_if = "Option::is_none")]
1311    pub default_quantity: Option<u32>,
1312}
1313
1314/// Lax deserializer for PageHeader.actions. Per D-19/F6:
1315/// Accepts: missing field (via #[serde(default)]), null, [], empty string "",
1316/// and array of strings. Rejects: non-empty strings, arrays of non-strings.
1317/// This loosens the wire-format contract for actions only — other Vec<String>
1318/// ID-slot fields (e.g. CardProps.footer) remain strict.
1319fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1320    d: D,
1321) -> Result<Vec<String>, D::Error> {
1322    use serde::de::Error;
1323    let v = serde_json::Value::deserialize(d)?;
1324    match v {
1325        serde_json::Value::Null => Ok(Vec::new()),
1326        serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1327        serde_json::Value::Array(arr) => arr
1328            .into_iter()
1329            .map(|item| {
1330                item.as_str()
1331                    .map(String::from)
1332                    .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1333            })
1334            .collect(),
1335        other => Err(D::Error::custom(format!(
1336            "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1337        ))),
1338    }
1339}
1340
1341#[cfg(test)]
1342mod schema_smoke_tests {
1343    //! Runtime `schema_for!` smoke tests per D-32.
1344    //!
1345    //! Each test asserts that the generated JSON Schema for the given Props
1346    //! struct is a non-empty JSON object with a populated `properties` field.
1347    //! This proves the `JsonSchema` derive executes without panic on every
1348    //! surviving Props struct — a compile-time `#[derive(JsonSchema)]` alone
1349    //! does not prove the generated code runs.
1350    //!
1351    //! One `#[test]` per type for clear failure localization.
1352
1353    use super::*;
1354
1355    fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1356        let schema = schemars::schema_for!(T);
1357        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1358        assert!(
1359            value.is_object(),
1360            "{type_label}: schema must be a JSON object"
1361        );
1362        let props = value
1363            .get("properties")
1364            .and_then(|p| p.as_object())
1365            .map(|o| !o.is_empty())
1366            .unwrap_or(false);
1367        assert!(
1368            props,
1369            "{type_label}: schema must have a non-empty `properties` field"
1370        );
1371    }
1372
1373    #[test]
1374    fn schema_for_card_props_generates() {
1375        assert_schema_nonempty_object::<CardProps>("CardProps");
1376    }
1377
1378    #[test]
1379    fn schema_for_table_props_generates() {
1380        assert_schema_nonempty_object::<TableProps>("TableProps");
1381    }
1382
1383    #[test]
1384    fn schema_for_form_props_generates() {
1385        assert_schema_nonempty_object::<FormProps>("FormProps");
1386    }
1387
1388    #[test]
1389    fn schema_for_button_props_generates() {
1390        assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1391    }
1392
1393    #[test]
1394    fn schema_for_input_props_generates() {
1395        assert_schema_nonempty_object::<InputProps>("InputProps");
1396    }
1397
1398    #[test]
1399    fn schema_for_select_props_generates() {
1400        assert_schema_nonempty_object::<SelectProps>("SelectProps");
1401    }
1402
1403    #[test]
1404    fn schema_for_alert_props_generates() {
1405        assert_schema_nonempty_object::<AlertProps>("AlertProps");
1406    }
1407
1408    #[test]
1409    fn schema_for_badge_props_generates() {
1410        assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1411    }
1412
1413    #[test]
1414    fn schema_for_modal_props_generates() {
1415        assert_schema_nonempty_object::<ModalProps>("ModalProps");
1416    }
1417
1418    #[test]
1419    fn schema_for_text_props_generates() {
1420        assert_schema_nonempty_object::<TextProps>("TextProps");
1421    }
1422
1423    #[test]
1424    fn schema_for_checkbox_props_generates() {
1425        assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1426    }
1427
1428    #[test]
1429    fn schema_for_switch_props_generates() {
1430        assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1431    }
1432
1433    #[test]
1434    fn schema_for_separator_props_generates() {
1435        assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1436    }
1437
1438    #[test]
1439    fn schema_for_description_list_props_generates() {
1440        assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1441    }
1442
1443    #[test]
1444    fn schema_for_tab_generates() {
1445        assert_schema_nonempty_object::<Tab>("Tab");
1446    }
1447
1448    #[test]
1449    fn schema_for_tabs_props_generates() {
1450        assert_schema_nonempty_object::<TabsProps>("TabsProps");
1451    }
1452
1453    #[test]
1454    fn schema_for_breadcrumb_props_generates() {
1455        assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1456    }
1457
1458    #[test]
1459    fn schema_for_pagination_props_generates() {
1460        assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1461    }
1462
1463    #[test]
1464    fn schema_for_progress_props_generates() {
1465        assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1466    }
1467
1468    #[test]
1469    fn schema_for_image_props_generates() {
1470        assert_schema_nonempty_object::<ImageProps>("ImageProps");
1471    }
1472
1473    #[test]
1474    fn image_inline_svg_factory_roundtrips_via_serde() {
1475        let p = ImageProps::inline_svg("<svg/>", "alt");
1476        let json = serde_json::to_value(&p).expect("serialization must not fail");
1477        let parsed: ImageProps =
1478            serde_json::from_value(json).expect("deserialization must not fail");
1479        assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1480        assert_eq!(parsed.alt, "alt");
1481        assert_eq!(parsed.src, "");
1482    }
1483
1484    #[test]
1485    fn schema_for_avatar_props_generates() {
1486        assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1487    }
1488
1489    #[test]
1490    fn schema_for_skeleton_props_generates() {
1491        assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1492    }
1493
1494    #[test]
1495    fn schema_for_stat_card_props_generates() {
1496        assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1497    }
1498
1499    #[test]
1500    fn schema_for_checklist_props_generates() {
1501        assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1502    }
1503
1504    #[test]
1505    fn schema_for_toast_props_generates() {
1506        assert_schema_nonempty_object::<ToastProps>("ToastProps");
1507    }
1508
1509    #[test]
1510    fn schema_for_notification_dropdown_props_generates() {
1511        assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1512    }
1513
1514    #[test]
1515    fn schema_for_sidebar_props_generates() {
1516        assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1517    }
1518
1519    #[test]
1520    fn schema_for_header_props_generates() {
1521        assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1522    }
1523
1524    #[test]
1525    fn schema_for_grid_props_generates() {
1526        assert_schema_nonempty_object::<GridProps>("GridProps");
1527    }
1528
1529    #[test]
1530    fn schema_for_collapsible_props_generates() {
1531        assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1532    }
1533
1534    #[test]
1535    fn schema_for_empty_state_props_generates() {
1536        assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1537    }
1538
1539    #[test]
1540    fn schema_for_form_section_props_generates() {
1541        assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1542    }
1543
1544    #[test]
1545    fn schema_for_page_header_props_generates() {
1546        assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1547    }
1548
1549    #[test]
1550    fn schema_for_button_group_props_generates() {
1551        assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1552    }
1553
1554    #[test]
1555    fn schema_for_action_item_generates() {
1556        assert_schema_nonempty_object::<ActionItem>("ActionItem");
1557    }
1558
1559    #[test]
1560    fn schema_for_action_group_props_generates() {
1561        assert_schema_nonempty_object::<ActionGroupProps>("ActionGroupProps");
1562    }
1563
1564    #[test]
1565    fn schema_for_dropdown_menu_action_generates() {
1566        assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1567    }
1568
1569    #[test]
1570    fn schema_for_data_table_props_generates() {
1571        assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1572    }
1573
1574    #[test]
1575    fn schema_for_kanban_column_props_generates() {
1576        assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1577    }
1578
1579    #[test]
1580    fn schema_for_kanban_board_props_generates() {
1581        assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1582    }
1583
1584    #[test]
1585    fn schema_for_calendar_cell_props_generates() {
1586        assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1587    }
1588
1589    #[test]
1590    fn schema_for_action_card_props_generates() {
1591        assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1592    }
1593
1594    #[test]
1595    fn schema_for_product_tile_props_generates() {
1596        assert_schema_nonempty_object::<ProductTileProps>("ProductTileProps");
1597    }
1598
1599    #[test]
1600    fn card_props_round_trips_footer() {
1601        let original = CardProps {
1602            title: "Hero".to_string(),
1603            description: None,
1604            subtitle: None,
1605            badge: None,
1606            max_width: None,
1607            footer: vec!["btn1".to_string(), "btn2".to_string()],
1608            variant: CardVariant::Bordered,
1609        };
1610        let json = serde_json::to_string(&original).unwrap();
1611        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1612        assert_eq!(original.footer, parsed.footer);
1613    }
1614
1615    #[test]
1616    fn tab_round_trips_children() {
1617        let original = Tab {
1618            value: "overview".to_string(),
1619            label: "Overview".to_string(),
1620            children: vec!["panel1".to_string()],
1621        };
1622        let json = serde_json::to_string(&original).unwrap();
1623        let parsed: Tab = serde_json::from_str(&json).unwrap();
1624        assert_eq!(original.children, parsed.children);
1625    }
1626
1627    #[test]
1628    fn card_props_omits_empty_footer_in_json() {
1629        let card = CardProps {
1630            title: "Card".to_string(),
1631            description: None,
1632            subtitle: None,
1633            badge: None,
1634            max_width: None,
1635            footer: Vec::new(),
1636            variant: CardVariant::Bordered,
1637        };
1638        let json = serde_json::to_string(&card).unwrap();
1639        assert!(
1640            !json.contains("\"footer\""),
1641            "empty footer must be skipped, got: {json}"
1642        );
1643    }
1644
1645    #[test]
1646    fn card_props_round_trips_badge() {
1647        let original = CardProps {
1648            title: "Hero".to_string(),
1649            description: None,
1650            subtitle: None,
1651            badge: Some("Scade tra 9m".to_string()),
1652            max_width: None,
1653            footer: Vec::new(),
1654            variant: CardVariant::Bordered,
1655        };
1656        let json = serde_json::to_string(&original).unwrap();
1657        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1658        assert_eq!(original.badge, parsed.badge);
1659    }
1660
1661    #[test]
1662    fn card_props_omits_empty_badge_in_json() {
1663        let card = CardProps {
1664            title: "Card".to_string(),
1665            description: None,
1666            subtitle: None,
1667            badge: None,
1668            max_width: None,
1669            footer: Vec::new(),
1670            variant: CardVariant::Bordered,
1671        };
1672        let json = serde_json::to_string(&card).unwrap();
1673        assert!(
1674            !json.contains("\"badge\""),
1675            "empty badge must be skipped, got: {json}"
1676        );
1677    }
1678
1679    #[test]
1680    fn card_props_round_trips_subtitle() {
1681        let original = CardProps {
1682            title: "Hero".to_string(),
1683            description: None,
1684            subtitle: Some("Marco Rossi".to_string()),
1685            badge: None,
1686            max_width: None,
1687            footer: Vec::new(),
1688            variant: CardVariant::Bordered,
1689        };
1690        let json = serde_json::to_string(&original).unwrap();
1691        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1692        assert_eq!(original.subtitle, parsed.subtitle);
1693    }
1694
1695    #[test]
1696    fn card_props_omits_empty_subtitle_in_json() {
1697        let card = CardProps {
1698            title: "Card".to_string(),
1699            description: None,
1700            subtitle: None,
1701            badge: None,
1702            max_width: None,
1703            footer: Vec::new(),
1704            variant: CardVariant::Bordered,
1705        };
1706        let json = serde_json::to_string(&card).unwrap();
1707        assert!(
1708            !json.contains("\"subtitle\""),
1709            "empty subtitle must be skipped, got: {json}"
1710        );
1711    }
1712
1713    #[test]
1714    fn card_props_schema_includes_badge() {
1715        let schema = schemars::schema_for!(CardProps);
1716        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1717        let props = value
1718            .get("properties")
1719            .and_then(|p| p.as_object())
1720            .expect("schema has a properties object");
1721        assert!(
1722            props.contains_key("badge"),
1723            "CardProps schema must expose a `badge` property; got keys: {:?}",
1724            props.keys().collect::<Vec<_>>()
1725        );
1726        // `badge: Option<String>` — schemars 1.x emits either {"type": ["string","null"]}
1727        // or a {"type":"string"} entry inside a oneOf/anyOf branch. We only assert
1728        // presence + that the rendered schema mentions a string somewhere under
1729        // the badge entry, which is robust to either encoding.
1730        let badge_schema = props.get("badge").expect("badge entry");
1731        let badge_json = badge_schema.to_string();
1732        assert!(
1733            badge_json.contains("\"string\""),
1734            "badge schema entry must mention string type; got: {badge_json}"
1735        );
1736    }
1737
1738    #[test]
1739    fn card_props_schema_includes_subtitle() {
1740        let schema = schemars::schema_for!(CardProps);
1741        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1742        let props = value
1743            .get("properties")
1744            .and_then(|p| p.as_object())
1745            .expect("schema has a properties object");
1746        assert!(
1747            props.contains_key("subtitle"),
1748            "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1749            props.keys().collect::<Vec<_>>()
1750        );
1751        // Same robustness note as `card_props_schema_includes_badge` —
1752        // `subtitle: Option<String>` may surface as type-union or oneOf depending
1753        // on the schemars version. Assert string is mentioned in the rendered
1754        // entry rather than locking down the exact null encoding.
1755        let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1756        let subtitle_json = subtitle_schema.to_string();
1757        assert!(
1758            subtitle_json.contains("\"string\""),
1759            "subtitle schema entry must mention string type; got: {subtitle_json}"
1760        );
1761    }
1762
1763    #[test]
1764    fn schema_for_checkbox_list_props_generates() {
1765        assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
1766    }
1767
1768    #[test]
1769    fn checkbox_list_props_serde_roundtrip() {
1770        let json = serde_json::json!({
1771            "field": "services",
1772            "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
1773            "selected_path": "/preselected"
1774        });
1775        let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
1776        assert_eq!(parsed.field, "services");
1777        assert_eq!(parsed.options.len(), 2);
1778        assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
1779        let reserialized = serde_json::to_value(&parsed).expect("encode");
1780        // None/empty fields are omitted by serde.
1781        assert!(reserialized.get("label").is_none());
1782        assert!(reserialized.get("disabled").is_none());
1783    }
1784
1785    #[test]
1786    fn schema_for_rich_text_editor_props_generates() {
1787        assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
1788    }
1789
1790    #[test]
1791    fn rich_text_editor_props_serde_roundtrip() {
1792        let json = serde_json::json!({
1793            "field": "body",
1794            "label": "Body"
1795        });
1796        let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
1797        assert_eq!(parsed.field, "body");
1798        assert_eq!(parsed.label, "Body");
1799        assert!(parsed.placeholder.is_none());
1800        assert!(parsed.default_value.is_none());
1801        assert!(parsed.data_path.is_none());
1802        assert!(parsed.error.is_none());
1803        let reserialized = serde_json::to_value(&parsed).expect("encode");
1804        // Optional None fields are omitted.
1805        assert!(reserialized.get("placeholder").is_none());
1806        assert!(reserialized.get("error").is_none());
1807    }
1808}
1809
1810#[cfg(test)]
1811mod strum_tests {
1812    use super::*;
1813
1814    /// Assert AsRef<str> matches serde JSON wire format for every variant of
1815    /// AlertVariant, BadgeVariant, ButtonVariant, and ToastVariant.
1816    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
1817    #[test]
1818    fn variant_enums_strum_matches_serde_wire_format() {
1819        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
1820            for v in variants {
1821                let json = serde_json::to_string(v).expect("serialize");
1822                let json_stripped = json.trim_matches('"');
1823                assert_eq!(
1824                    v.as_ref(),
1825                    json_stripped,
1826                    "strum AsRefStr drifted from serde for {label} variant"
1827                );
1828            }
1829        }
1830        check(
1831            &[
1832                AlertVariant::Info,
1833                AlertVariant::Success,
1834                AlertVariant::Warning,
1835                AlertVariant::Error,
1836            ],
1837            "AlertVariant",
1838        );
1839        check(
1840            &[
1841                BadgeVariant::Default,
1842                BadgeVariant::Secondary,
1843                BadgeVariant::Destructive,
1844                BadgeVariant::Outline,
1845            ],
1846            "BadgeVariant",
1847        );
1848        check(
1849            &[
1850                ButtonVariant::Default,
1851                ButtonVariant::Secondary,
1852                ButtonVariant::Destructive,
1853                ButtonVariant::Outline,
1854                ButtonVariant::Ghost,
1855                ButtonVariant::Link,
1856            ],
1857            "ButtonVariant",
1858        );
1859        check(
1860            &[
1861                ToastVariant::Info,
1862                ToastVariant::Success,
1863                ToastVariant::Warning,
1864                ToastVariant::Error,
1865            ],
1866            "ToastVariant",
1867        );
1868    }
1869
1870    #[test]
1871    fn alert_variant_as_ref_str_matches_wire_format() {
1872        assert_eq!(AlertVariant::Success.as_ref(), "success");
1873        assert_eq!(AlertVariant::Warning.as_ref(), "warning");
1874        assert_eq!(AlertVariant::Info.as_ref(), "info");
1875        assert_eq!(AlertVariant::Error.as_ref(), "error");
1876    }
1877}
1878
1879#[cfg(test)]
1880mod card_variant_tests {
1881    use super::*;
1882
1883    #[test]
1884    fn card_variant_default_is_bordered() {
1885        assert_eq!(CardVariant::default(), CardVariant::Bordered);
1886    }
1887
1888    #[test]
1889    fn card_variant_serializes_snake_case() {
1890        assert_eq!(
1891            serde_json::to_value(CardVariant::Bordered).unwrap(),
1892            serde_json::json!("bordered")
1893        );
1894        assert_eq!(
1895            serde_json::to_value(CardVariant::Elevated).unwrap(),
1896            serde_json::json!("elevated")
1897        );
1898    }
1899
1900    #[test]
1901    fn card_variant_deserializes_snake_case() {
1902        assert_eq!(
1903            serde_json::from_value::<CardVariant>(serde_json::json!("bordered")).unwrap(),
1904            CardVariant::Bordered
1905        );
1906        assert_eq!(
1907            serde_json::from_value::<CardVariant>(serde_json::json!("elevated")).unwrap(),
1908            CardVariant::Elevated
1909        );
1910    }
1911
1912    #[test]
1913    fn card_props_without_variant_defaults_to_bordered() {
1914        let v = serde_json::json!({"title": "x"});
1915        let p: CardProps = serde_json::from_value(v).unwrap();
1916        assert_eq!(p.variant, CardVariant::Bordered);
1917    }
1918
1919    #[test]
1920    fn card_props_with_elevated_variant() {
1921        let v = serde_json::json!({"title": "x", "variant": "elevated"});
1922        let p: CardProps = serde_json::from_value(v).unwrap();
1923        assert_eq!(p.variant, CardVariant::Elevated);
1924    }
1925
1926    #[test]
1927    fn card_props_roundtrip_preserves_variant() {
1928        let p = CardProps {
1929            title: "x".into(),
1930            description: None,
1931            subtitle: None,
1932            badge: None,
1933            max_width: None,
1934            footer: vec![],
1935            variant: CardVariant::Elevated,
1936        };
1937        let j = serde_json::to_value(&p).unwrap();
1938        let back: CardProps = serde_json::from_value(j).unwrap();
1939        assert_eq!(back.variant, CardVariant::Elevated);
1940    }
1941}
1942
1943#[cfg(test)]
1944mod kanban_board_props_tests {
1945    use super::*;
1946
1947    #[test]
1948    fn kanban_board_props_serde_static_columns() {
1949        let v = serde_json::json!({
1950            "columns": [{"title": "To Do", "id": "todo", "count": 0}]
1951        });
1952        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1953        assert_eq!(p.columns.len(), 1);
1954        assert!(p.items_path.is_none());
1955        assert!(p.group_by.is_none());
1956    }
1957
1958    #[test]
1959    fn kanban_board_props_serde_data_bound() {
1960        let v = serde_json::json!({
1961            "columns": [{"title": "Open", "id": "open"}],
1962            "items_path": "/data/order",
1963            "group_by": "status",
1964            "card_title_key": "name"
1965        });
1966        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1967        assert_eq!(p.columns.len(), 1);
1968        assert_eq!(p.items_path.as_deref(), Some("/data/order"));
1969        assert_eq!(p.group_by.as_deref(), Some("status"));
1970        assert_eq!(p.card_title_key.as_deref(), Some("name"));
1971    }
1972
1973    #[test]
1974    fn kanban_board_props_serde_neither() {
1975        let v = serde_json::json!({});
1976        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1977        assert!(p.columns.is_empty());
1978        assert!(p.items_path.is_none());
1979        assert!(p.group_by.is_none());
1980    }
1981
1982    #[test]
1983    fn kanban_board_props_empty_columns_skipped_on_serialize() {
1984        let p = KanbanBoardProps {
1985            columns: vec![],
1986            items_path: Some("/data/order".into()),
1987            group_by: Some("status".into()),
1988            card_title_key: None,
1989            card_description_key: None,
1990            row_actions: None,
1991            row_key: None,
1992            mobile_default_column: None,
1993            empty_label: None,
1994        };
1995        let j = serde_json::to_value(&p).unwrap();
1996        assert!(
1997            j.get("columns").is_none(),
1998            "empty columns must be skipped, got: {j}"
1999        );
2000        assert_eq!(
2001            j.get("items_path").and_then(|v| v.as_str()),
2002            Some("/data/order")
2003        );
2004    }
2005}
2006
2007#[cfg(test)]
2008mod page_header_actions_tests {
2009    use super::*;
2010
2011    #[test]
2012    fn page_header_actions_missing_field() {
2013        let v = serde_json::json!({"title": "X"});
2014        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2015        assert!(p.actions.is_empty());
2016    }
2017
2018    #[test]
2019    fn page_header_actions_null() {
2020        let v = serde_json::json!({"title": "X", "actions": null});
2021        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2022        assert!(p.actions.is_empty());
2023    }
2024
2025    #[test]
2026    fn page_header_actions_empty_string() {
2027        let v = serde_json::json!({"title": "X", "actions": ""});
2028        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2029        assert!(p.actions.is_empty());
2030    }
2031
2032    #[test]
2033    fn page_header_actions_empty_array() {
2034        let v = serde_json::json!({"title": "X", "actions": []});
2035        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2036        assert!(p.actions.is_empty());
2037    }
2038
2039    #[test]
2040    fn page_header_actions_non_empty_array() {
2041        let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
2042        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2043        assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
2044    }
2045
2046    #[test]
2047    fn page_header_actions_non_empty_string_rejected() {
2048        let v = serde_json::json!({"title": "X", "actions": "not-empty"});
2049        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2050        assert!(result.is_err(), "non-empty string must be rejected");
2051    }
2052
2053    #[test]
2054    fn page_header_actions_non_string_array_rejected() {
2055        let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2056        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2057        assert!(result.is_err(), "array of non-strings must be rejected");
2058    }
2059}