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