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