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