fh-pydantic-form 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  from datetime import date, time
3
+ from enum import Enum
3
4
  from typing import (
4
5
  Any,
5
6
  Optional,
@@ -15,13 +16,30 @@ from pydantic.fields import FieldInfo
15
16
 
16
17
  from fh_pydantic_form.registry import FieldRendererRegistry
17
18
  from fh_pydantic_form.type_helpers import (
19
+ _UNSET,
18
20
  _get_underlying_type_if_optional,
19
21
  _is_optional_type,
22
+ get_default,
23
+ )
24
+ from fh_pydantic_form.ui_style import (
25
+ SpacingTheme,
26
+ SpacingValue,
27
+ _normalize_spacing,
28
+ spacing,
20
29
  )
21
30
 
22
31
  logger = logging.getLogger(__name__)
23
32
 
24
33
 
34
+ def _merge_cls(base: str, extra: str) -> str:
35
+ """Return base plus extra class(es) separated by a single space (handles blanks)."""
36
+ if extra:
37
+ combined = f"{base} {extra}".strip()
38
+ # Remove duplicate whitespace
39
+ return " ".join(combined.split())
40
+ return base
41
+
42
+
25
43
  class BaseFieldRenderer:
26
44
  """
27
45
  Base class for field renderers
@@ -42,6 +60,7 @@ class BaseFieldRenderer:
42
60
  prefix: str = "",
43
61
  disabled: bool = False,
44
62
  label_color: Optional[str] = None,
63
+ spacing: SpacingValue = SpacingTheme.NORMAL,
45
64
  ):
46
65
  """
47
66
  Initialize the field renderer
@@ -53,6 +72,7 @@ class BaseFieldRenderer:
53
72
  prefix: Optional prefix for the field name (used for nested fields)
54
73
  disabled: Whether the field should be rendered as disabled
55
74
  label_color: Optional CSS color value for the field label
75
+ spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
56
76
  """
57
77
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
58
78
  self.original_field_name = field_name
@@ -62,6 +82,7 @@ class BaseFieldRenderer:
62
82
  self.is_optional = _is_optional_type(field_info.annotation)
63
83
  self.disabled = disabled
64
84
  self.label_color = label_color
85
+ self.spacing = _normalize_spacing(spacing)
65
86
 
66
87
  def render_label(self) -> FT:
67
88
  """
@@ -79,7 +100,8 @@ class BaseFieldRenderer:
79
100
  # Create span attributes with tooltip if description is available
80
101
  span_attrs = {}
81
102
  if description:
82
- span_attrs["uk_tooltip"] = description
103
+ span_attrs["uk-tooltip"] = description # UIkit tooltip
104
+ span_attrs["title"] = description # Standard HTML tooltip
83
105
  # Removed cursor-help class while preserving tooltip functionality
84
106
 
85
107
  # Create the span with the label text and tooltip
@@ -88,15 +110,27 @@ class BaseFieldRenderer:
88
110
  # Prepare label attributes
89
111
  label_attrs = {"For": self.field_name}
90
112
 
113
+ # Build label classes with tokenized gap
114
+ label_gap_class = spacing("label_gap", self.spacing)
115
+ base_classes = f"block text-sm font-medium text-gray-700 {label_gap_class}"
116
+
117
+ cls_attr = base_classes
118
+
91
119
  # Apply color styling if specified
92
120
  if self.label_color:
93
- label_attrs["style"] = f"color: {self.label_color};"
121
+ # Check if it's a CSS class (contains letters/hyphens) or a color value
122
+ if self.label_color.replace("-", "").replace("#", "").isalnum():
123
+ # Looks like a CSS class, add it to the class list
124
+ cls_attr = f"block text-sm font-medium {self.label_color} {label_gap_class}".strip()
125
+ else:
126
+ # Treat as color value
127
+ label_attrs["style"] = f"color: {self.label_color};"
94
128
 
95
129
  # Create and return the label - using standard fh.Label with appropriate styling
96
130
  return fh.Label(
97
131
  label_text_span,
98
132
  **label_attrs,
99
- cls="block text-sm font-medium text-gray-700 mb-1",
133
+ cls=cls_attr,
100
134
  )
101
135
 
102
136
  def render_input(self) -> FT:
@@ -141,7 +175,9 @@ class BaseFieldRenderer:
141
175
  input_component, # Content component (the input field)
142
176
  open=True, # Open by default
143
177
  li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
144
- cls="mb-2", # Add spacing between accordion items
178
+ cls=spacing(
179
+ "outer_margin_sm", self.spacing
180
+ ), # Add spacing between accordion items
145
181
  )
146
182
 
147
183
  # 5. Wrap the single AccordionItem in an Accordion container
@@ -150,6 +186,7 @@ class BaseFieldRenderer:
150
186
  id=accordion_id, # ID for the accordion container (ul)
151
187
  multiple=True, # Allow multiple open (though only one exists)
152
188
  collapsible=True, # Allow toggling
189
+ cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
153
190
  )
154
191
 
155
192
  return accordion_container
@@ -168,11 +205,13 @@ class StringFieldRenderer(BaseFieldRenderer):
168
205
  Returns:
169
206
  A TextInput component appropriate for string values
170
207
  """
171
- is_field_required = (
172
- not self.is_optional
173
- and self.field_info.default is None
174
- and getattr(self.field_info, "default_factory", None) is None
175
- )
208
+ # is_field_required = (
209
+ # not self.is_optional
210
+ # and self.field_info.default is None
211
+ # and getattr(self.field_info, "default_factory", None) is None
212
+ # )
213
+ has_default = get_default(self.field_info) is not _UNSET
214
+ is_field_required = not self.is_optional and not has_default
176
215
 
177
216
  placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
178
217
  if self.is_optional:
@@ -185,7 +224,10 @@ class StringFieldRenderer(BaseFieldRenderer):
185
224
  "type": "text",
186
225
  "placeholder": placeholder_text,
187
226
  "required": is_field_required,
188
- "cls": "w-full",
227
+ "cls": _merge_cls(
228
+ "w-full",
229
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
230
+ ),
189
231
  }
190
232
 
191
233
  # Only add the disabled attribute if the field should actually be disabled
@@ -205,11 +247,9 @@ class NumberFieldRenderer(BaseFieldRenderer):
205
247
  Returns:
206
248
  A NumberInput component appropriate for numeric values
207
249
  """
208
- is_field_required = (
209
- not self.is_optional
210
- and self.field_info.default is None
211
- and getattr(self.field_info, "default_factory", None) is None
212
- )
250
+ # Determine if field is required
251
+ has_default = get_default(self.field_info) is not _UNSET
252
+ is_field_required = not self.is_optional and not has_default
213
253
 
214
254
  placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
215
255
  if self.is_optional:
@@ -222,7 +262,10 @@ class NumberFieldRenderer(BaseFieldRenderer):
222
262
  "type": "number",
223
263
  "placeholder": placeholder_text,
224
264
  "required": is_field_required,
225
- "cls": "w-full",
265
+ "cls": _merge_cls(
266
+ "w-full",
267
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
268
+ ),
226
269
  "step": "any"
227
270
  if self.field_info.annotation is float
228
271
  or get_origin(self.field_info.annotation) is float
@@ -278,11 +321,8 @@ class DateFieldRenderer(BaseFieldRenderer):
278
321
  elif isinstance(self.value, date):
279
322
  formatted_value = self.value.isoformat() # YYYY-MM-DD
280
323
 
281
- is_field_required = (
282
- not self.is_optional
283
- and self.field_info.default is None
284
- and getattr(self.field_info, "default_factory", None) is None
285
- )
324
+ has_default = get_default(self.field_info) is not _UNSET
325
+ is_field_required = not self.is_optional and not has_default
286
326
 
287
327
  placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
288
328
  if self.is_optional:
@@ -295,7 +335,10 @@ class DateFieldRenderer(BaseFieldRenderer):
295
335
  "type": "date",
296
336
  "placeholder": placeholder_text,
297
337
  "required": is_field_required,
298
- "cls": "w-full",
338
+ "cls": _merge_cls(
339
+ "w-full",
340
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
341
+ ),
299
342
  }
300
343
 
301
344
  # Only add the disabled attribute if the field should actually be disabled
@@ -324,11 +367,9 @@ class TimeFieldRenderer(BaseFieldRenderer):
324
367
  elif isinstance(self.value, time):
325
368
  formatted_value = self.value.strftime("%H:%M") # HH:MM
326
369
 
327
- is_field_required = (
328
- not self.is_optional
329
- and self.field_info.default is None
330
- and getattr(self.field_info, "default_factory", None) is None
331
- )
370
+ # Determine if field is required
371
+ has_default = get_default(self.field_info) is not _UNSET
372
+ is_field_required = not self.is_optional and not has_default
332
373
 
333
374
  placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
334
375
  if self.is_optional:
@@ -341,7 +382,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
341
382
  "type": "time",
342
383
  "placeholder": placeholder_text,
343
384
  "required": is_field_required,
344
- "cls": "w-full",
385
+ "cls": _merge_cls(
386
+ "w-full",
387
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
388
+ ),
345
389
  }
346
390
 
347
391
  # Only add the disabled attribute if the field should actually be disabled
@@ -371,11 +415,8 @@ class LiteralFieldRenderer(BaseFieldRenderer):
371
415
  )
372
416
 
373
417
  # Determine if field is required
374
- is_field_required = (
375
- not self.is_optional
376
- and self.field_info.default is None
377
- and getattr(self.field_info, "default_factory", None) is None
378
- )
418
+ has_default = get_default(self.field_info) is not _UNSET
419
+ is_field_required = not self.is_optional and not has_default
379
420
 
380
421
  # Create options for each literal value
381
422
  options = []
@@ -409,7 +450,95 @@ class LiteralFieldRenderer(BaseFieldRenderer):
409
450
  "name": self.field_name,
410
451
  "required": is_field_required,
411
452
  "placeholder": placeholder_text,
412
- "cls": "w-full",
453
+ "cls": _merge_cls(
454
+ "w-full",
455
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
456
+ ),
457
+ }
458
+
459
+ # Only add the disabled attribute if the field should actually be disabled
460
+ if self.disabled:
461
+ select_attrs["disabled"] = True
462
+
463
+ # Render the select element with options and attributes
464
+ return mui.Select(*options, **select_attrs)
465
+
466
+
467
+ class EnumFieldRenderer(BaseFieldRenderer):
468
+ """Renderer for Enum fields as dropdown selects"""
469
+
470
+ def render_input(self) -> FT:
471
+ """
472
+ Render input element for the field as a select dropdown
473
+
474
+ Returns:
475
+ A Select component with options based on the Enum values
476
+ """
477
+ # Get the Enum class from annotation
478
+ annotation = _get_underlying_type_if_optional(self.field_info.annotation)
479
+ enum_class = annotation
480
+
481
+ if not (isinstance(enum_class, type) and issubclass(enum_class, Enum)):
482
+ return mui.Alert(
483
+ f"No enum class found for {self.field_name}", cls=mui.AlertT.warning
484
+ )
485
+
486
+ # Get all enum members
487
+ enum_members = list(enum_class)
488
+
489
+ if not enum_members:
490
+ return mui.Alert(
491
+ f"No enum values found for {self.field_name}", cls=mui.AlertT.warning
492
+ )
493
+
494
+ # Determine if field is required
495
+ has_default = get_default(self.field_info) is not _UNSET
496
+ is_field_required = not self.is_optional and not has_default
497
+
498
+ # Create options for each enum value
499
+ options = []
500
+ current_value_str = None
501
+
502
+ # Convert current value to string for comparison
503
+ if self.value is not None:
504
+ if isinstance(self.value, Enum):
505
+ current_value_str = str(self.value.value)
506
+ else:
507
+ current_value_str = str(self.value)
508
+
509
+ # Add empty option for optional fields
510
+ if self.is_optional:
511
+ options.append(
512
+ fh.Option("-- None --", value="", selected=(self.value is None))
513
+ )
514
+
515
+ # Add options for each enum member
516
+ for member in enum_members:
517
+ member_value_str = str(member.value)
518
+ display_name = member.name.replace("_", " ").title()
519
+ is_selected = current_value_str == member_value_str
520
+ options.append(
521
+ fh.Option(
522
+ display_name, # Display text
523
+ value=member_value_str, # Value attribute
524
+ selected=is_selected,
525
+ )
526
+ )
527
+
528
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
529
+ if self.is_optional:
530
+ placeholder_text += " (Optional)"
531
+
532
+ # Prepare attributes dictionary
533
+ select_attrs = {
534
+ "id": self.field_name,
535
+ "name": self.field_name,
536
+ "required": is_field_required,
537
+ "placeholder": placeholder_text,
538
+ "cls": _merge_cls(
539
+ "w-full",
540
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
541
+ ),
413
542
  }
414
543
 
415
544
  # Only add the disabled attribute if the field should actually be disabled
@@ -469,24 +598,25 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
469
598
  input_component, # Content component (the Card with nested fields)
470
599
  open=True, # Open by default
471
600
  li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
472
- cls="mb-4", # Add bottom margin to the <li> element
601
+ cls=spacing(
602
+ "outer_margin", self.spacing
603
+ ), # Add bottom margin to the <li> element
473
604
  )
474
605
 
475
606
  # 5. Wrap the single AccordionItem in an Accordion container
476
- # - Set multiple=True (harmless for single item)
477
- # - Set collapsible=True
478
607
  accordion_container = mui.Accordion(
479
608
  accordion_item, # The single item to include
480
609
  id=accordion_id, # ID for the accordion container (ul)
481
610
  multiple=True, # Allow multiple open (though only one exists)
482
611
  collapsible=True, # Allow toggling
612
+ cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
483
613
  )
484
614
 
485
615
  return accordion_container
486
616
 
487
617
  def render_input(self) -> FT:
488
618
  """
489
- Render input elements for nested model fields
619
+ Render input elements for nested model fields with robust schema drift handling
490
620
 
491
621
  Returns:
492
622
  A Card component containing nested form fields
@@ -504,69 +634,95 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
504
634
  cls=mui.AlertT.error,
505
635
  )
506
636
 
507
- # Prepare values dict
508
- values_dict = (
509
- self.value.model_dump()
510
- if hasattr(self.value, "model_dump")
511
- else self.value
512
- if isinstance(self.value, dict)
513
- else {}
514
- )
637
+ # Robust value preparation
638
+ if isinstance(self.value, dict):
639
+ values_dict = self.value.copy()
640
+ elif hasattr(self.value, "model_dump"):
641
+ values_dict = self.value.model_dump()
642
+ else:
643
+ values_dict = {}
515
644
 
516
- # Create nested field inputs directly instead of using FormRenderer
645
+ # Create nested field inputs with error handling
517
646
  nested_inputs = []
647
+ skipped_fields = []
518
648
 
649
+ # Only process fields that exist in current model schema
519
650
  for (
520
651
  nested_field_name,
521
652
  nested_field_info,
522
653
  ) in nested_model_class.model_fields.items():
523
- # Determine initial value
524
- nested_field_value = (
525
- values_dict.get(nested_field_name) if values_dict else None
526
- )
654
+ try:
655
+ # Check if field exists in provided values
656
+ field_was_provided = nested_field_name in values_dict
657
+ nested_field_value = (
658
+ values_dict.get(nested_field_name) if field_was_provided else None
659
+ )
527
660
 
528
- # Apply default if needed
529
- if nested_field_value is None:
530
- if nested_field_info.default is not None:
531
- nested_field_value = nested_field_info.default
532
- elif getattr(nested_field_info, "default_factory", None) is not None:
533
- try:
534
- nested_field_value = nested_field_info.default_factory()
535
- except Exception:
536
- nested_field_value = None
537
-
538
- # Get renderer for this nested field
539
- registry = FieldRendererRegistry() # Get singleton instance
540
- renderer_cls = registry.get_renderer(nested_field_name, nested_field_info)
541
-
542
- if not renderer_cls:
543
- # Fall back to StringFieldRenderer if no renderer found
544
- renderer_cls = StringFieldRenderer
545
-
546
- # The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
547
- # field_name already includes the form prefix, so we don't need to add self.prefix again
548
- nested_prefix = f"{self.field_name}_"
549
-
550
- # Create and render the nested field
551
- renderer = renderer_cls(
552
- field_name=nested_field_name,
553
- field_info=nested_field_info,
554
- value=nested_field_value,
555
- prefix=nested_prefix,
556
- disabled=self.disabled, # Propagate disabled state to nested fields
557
- )
661
+ # Only use defaults if field wasn't provided
662
+ if not field_was_provided:
663
+ if nested_field_info.default is not None:
664
+ nested_field_value = nested_field_info.default
665
+ elif (
666
+ getattr(nested_field_info, "default_factory", None) is not None
667
+ ):
668
+ try:
669
+ nested_field_value = nested_field_info.default_factory()
670
+ except Exception as e:
671
+ logger.warning(
672
+ f"Default factory failed for {nested_field_name}: {e}"
673
+ )
674
+ nested_field_value = None
675
+
676
+ # Get renderer for this nested field
677
+ registry = FieldRendererRegistry() # Get singleton instance
678
+ renderer_cls = registry.get_renderer(
679
+ nested_field_name, nested_field_info
680
+ )
681
+
682
+ if not renderer_cls:
683
+ # Fall back to StringFieldRenderer if no renderer found
684
+ renderer_cls = StringFieldRenderer
685
+
686
+ # The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
687
+ # field_name already includes the form prefix, so we don't need to add self.prefix again
688
+ nested_prefix = f"{self.field_name}_"
689
+
690
+ # Create and render the nested field
691
+ renderer = renderer_cls(
692
+ field_name=nested_field_name,
693
+ field_info=nested_field_info,
694
+ value=nested_field_value,
695
+ prefix=nested_prefix,
696
+ disabled=self.disabled, # Propagate disabled state to nested fields
697
+ spacing=self.spacing, # Propagate spacing to nested fields
698
+ )
699
+
700
+ nested_inputs.append(renderer.render())
701
+
702
+ except Exception as e:
703
+ logger.warning(
704
+ f"Skipping field {nested_field_name} in nested model: {e}"
705
+ )
706
+ skipped_fields.append(nested_field_name)
707
+ continue
558
708
 
559
- nested_inputs.append(renderer.render())
709
+ # Log summary if fields were skipped
710
+ if skipped_fields:
711
+ logger.info(
712
+ f"Skipped {len(skipped_fields)} fields in {self.field_name}: {skipped_fields}"
713
+ )
560
714
 
561
715
  # Create container for nested inputs
562
716
  nested_form_content = mui.DivVStacked(
563
- *nested_inputs, cls="space-y-3 items-stretch"
717
+ *nested_inputs,
718
+ cls=f"{spacing('inner_gap', self.spacing)} items-stretch",
564
719
  )
565
720
 
566
721
  # Wrap in card for visual distinction
722
+ t = self.spacing
567
723
  return mui.Card(
568
724
  nested_form_content,
569
- cls="p-3 mt-1 border rounded",
725
+ cls=f"{spacing('padding_sm', t)} mt-1 {spacing('card_border', t)} rounded".strip(),
570
726
  )
571
727
 
572
728
 
@@ -626,7 +782,11 @@ class ListFieldRenderer(BaseFieldRenderer):
626
782
  )
627
783
 
628
784
  # Return container with label+icon and input
629
- return fh.Div(label_with_icon, self.render_input(), cls="mb-4")
785
+ return fh.Div(
786
+ label_with_icon,
787
+ self.render_input(),
788
+ cls=spacing("outer_margin", self.spacing),
789
+ )
630
790
 
631
791
  def render_input(self) -> FT:
632
792
  """
@@ -688,7 +848,7 @@ class ListFieldRenderer(BaseFieldRenderer):
688
848
  id=container_id,
689
849
  multiple=True, # Allow multiple items to be open at once
690
850
  collapsible=True, # Make it collapsible
691
- cls="space-y-2", # Add space between items
851
+ cls=f"{spacing('inner_gap_small', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(), # Add space between items and accordion content styling
692
852
  )
693
853
 
694
854
  # Empty state message if no items
@@ -728,10 +888,11 @@ class ListFieldRenderer(BaseFieldRenderer):
728
888
  )
729
889
 
730
890
  # Return the complete component
891
+ t = self.spacing
731
892
  return fh.Div(
732
893
  accordion,
733
894
  empty_state,
734
- cls="mb-4 border rounded-md p-4",
895
+ cls=f"{spacing('outer_margin', t)} {spacing('card_border', t)} rounded-md {spacing('padding', t)}".strip(),
735
896
  )
736
897
 
737
898
  def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
@@ -767,8 +928,8 @@ class ListFieldRenderer(BaseFieldRenderer):
767
928
  model_for_display = item
768
929
 
769
930
  elif isinstance(item, dict):
770
- # Item is a dict, try to create a model instance for display
771
- model_for_display = item_type.model_validate(item)
931
+ # Item is a dict, use model_construct for better performance (defaults are known-good)
932
+ model_for_display = item_type.model_construct(**item)
772
933
 
773
934
  else:
774
935
  # Handle cases where item is None or unexpected type
@@ -812,17 +973,17 @@ class ListFieldRenderer(BaseFieldRenderer):
812
973
  item_content_elements = []
813
974
 
814
975
  if is_model:
815
- # Handle BaseModel items - include the form prefix in nested items
976
+ # Handle BaseModel items with robust schema drift handling
816
977
  # Form name prefix + field name + index + _
817
978
  name_prefix = f"{self.prefix}{self.original_field_name}_{idx}_"
818
979
 
819
- nested_values = (
820
- item.model_dump()
821
- if hasattr(item, "model_dump")
822
- else item
823
- if isinstance(item, dict)
824
- else {}
825
- )
980
+ # Robust value preparation for schema drift handling
981
+ if isinstance(item, dict):
982
+ nested_values = item.copy()
983
+ elif hasattr(item, "model_dump"):
984
+ nested_values = item.model_dump()
985
+ else:
986
+ nested_values = {}
826
987
 
827
988
  # Check if there's a specific renderer registered for this item_type
828
989
  registry = FieldRendererRegistry()
@@ -853,44 +1014,71 @@ class ListFieldRenderer(BaseFieldRenderer):
853
1014
  # Add the rendered input to content elements
854
1015
  item_content_elements.append(item_renderer.render_input())
855
1016
  else:
856
- # Fall back to original behavior: render each field individually
1017
+ # Fall back to original behavior: render each field individually with schema drift handling
1018
+ valid_fields = []
1019
+ skipped_fields = []
1020
+
1021
+ # Only process fields that exist in current model
857
1022
  for (
858
1023
  nested_field_name,
859
1024
  nested_field_info,
860
1025
  ) in item_type.model_fields.items():
861
- nested_field_value = nested_values.get(nested_field_name)
862
-
863
- # Apply default if needed
864
- if (
865
- nested_field_value is None
866
- and hasattr(nested_field_info, "default")
867
- and nested_field_info.default is not None
868
- ):
869
- nested_field_value = nested_field_info.default
870
- elif (
871
- nested_field_value is None
872
- and hasattr(nested_field_info, "default_factory")
873
- and nested_field_info.default_factory is not None
874
- ):
875
- try:
876
- nested_field_value = nested_field_info.default_factory()
877
- except Exception:
878
- pass
879
-
880
- # Get renderer and render field
881
- renderer_cls = FieldRendererRegistry().get_renderer(
882
- nested_field_name, nested_field_info
883
- )
884
- renderer = renderer_cls(
885
- field_name=nested_field_name,
886
- field_info=nested_field_info,
887
- value=nested_field_value,
888
- prefix=name_prefix,
889
- disabled=self.disabled, # Propagate disabled state
1026
+ try:
1027
+ field_was_provided = nested_field_name in nested_values
1028
+ nested_field_value = (
1029
+ nested_values.get(nested_field_name)
1030
+ if field_was_provided
1031
+ else None
1032
+ )
1033
+
1034
+ # Use defaults only if field not provided
1035
+ if not field_was_provided:
1036
+ if nested_field_info.default is not None:
1037
+ nested_field_value = nested_field_info.default
1038
+ elif (
1039
+ getattr(nested_field_info, "default_factory", None)
1040
+ is not None
1041
+ ):
1042
+ try:
1043
+ nested_field_value = (
1044
+ nested_field_info.default_factory()
1045
+ )
1046
+ except Exception:
1047
+ continue # Skip fields with problematic defaults
1048
+
1049
+ # Get renderer and render field with error handling
1050
+ renderer_cls = FieldRendererRegistry().get_renderer(
1051
+ nested_field_name, nested_field_info
1052
+ )
1053
+ if not renderer_cls:
1054
+ renderer_cls = StringFieldRenderer
1055
+
1056
+ renderer = renderer_cls(
1057
+ field_name=nested_field_name,
1058
+ field_info=nested_field_info,
1059
+ value=nested_field_value,
1060
+ prefix=name_prefix,
1061
+ disabled=self.disabled, # Propagate disabled state
1062
+ spacing=self.spacing, # Propagate spacing
1063
+ )
1064
+
1065
+ # Add rendered field to valid fields
1066
+ valid_fields.append(renderer.render())
1067
+
1068
+ except Exception as e:
1069
+ logger.warning(
1070
+ f"Skipping problematic field {nested_field_name} in list item: {e}"
1071
+ )
1072
+ skipped_fields.append(nested_field_name)
1073
+ continue
1074
+
1075
+ # Log summary if fields were skipped
1076
+ if skipped_fields:
1077
+ logger.info(
1078
+ f"Skipped {len(skipped_fields)} fields in list item {idx}: {skipped_fields}"
890
1079
  )
891
1080
 
892
- # Add rendered field to content elements
893
- item_content_elements.append(renderer.render())
1081
+ item_content_elements = valid_fields
894
1082
  else:
895
1083
  # Handle simple type items
896
1084
  field_info = FieldInfo(annotation=item_type)
@@ -906,6 +1094,7 @@ class ListFieldRenderer(BaseFieldRenderer):
906
1094
  value=item,
907
1095
  prefix=self.prefix, # Correct: Provide the form prefix
908
1096
  disabled=self.disabled, # Propagate disabled state
1097
+ spacing=self.spacing, # Propagate spacing
909
1098
  )
910
1099
  input_element = simple_renderer.render_input()
911
1100
  item_content_elements.append(fh.Div(input_element))
@@ -986,6 +1175,7 @@ class ListFieldRenderer(BaseFieldRenderer):
986
1175
  )
987
1176
 
988
1177
  # Assemble actions div
1178
+ t = self.spacing
989
1179
  actions = fh.Div(
990
1180
  fh.Div( # Left side buttons
991
1181
  delete_button, add_below_button, cls="flex items-center"
@@ -993,11 +1183,15 @@ class ListFieldRenderer(BaseFieldRenderer):
993
1183
  fh.Div( # Right side buttons
994
1184
  move_up_button, move_down_button, cls="flex items-center space-x-1"
995
1185
  ),
996
- cls="flex justify-between w-full mt-3 pt-3 border-t border-gray-200",
1186
+ cls=f"flex justify-between w-full mt-3 pt-3 {spacing('section_divider', t)}".strip(),
997
1187
  )
998
1188
 
999
1189
  # Create a wrapper Div for the main content elements with proper padding
1000
- content_wrapper = fh.Div(*item_content_elements, cls="px-4 py-3 space-y-3")
1190
+ t = self.spacing
1191
+ content_wrapper = fh.Div(
1192
+ *item_content_elements,
1193
+ cls=f"{spacing('card_body_pad', t)} {spacing('inner_gap', t)}",
1194
+ )
1001
1195
 
1002
1196
  # Return the accordion item
1003
1197
  title_component = fh.Span(
@@ -1005,11 +1199,16 @@ class ListFieldRenderer(BaseFieldRenderer):
1005
1199
  )
1006
1200
  li_attrs = {"id": full_card_id}
1007
1201
 
1202
+ # Build card classes conditionally based on spacing theme
1203
+ card_cls = "uk-card uk-margin-small-bottom"
1204
+ if self.spacing == SpacingTheme.NORMAL:
1205
+ card_cls += " uk-card-default"
1206
+
1008
1207
  return mui.AccordionItem(
1009
1208
  title_component, # Title as first positional argument
1010
1209
  content_wrapper, # Use the new padded wrapper for content
1011
1210
  actions, # More content elements
1012
- cls="uk-card uk-card-default uk-margin-small-bottom", # Use cls keyword arg directly
1211
+ cls=card_cls, # Use theme-aware card classes
1013
1212
  open=is_open,
1014
1213
  li_kwargs=li_attrs, # Pass remaining li attributes without cls
1015
1214
  )
@@ -1023,11 +1222,17 @@ class ListFieldRenderer(BaseFieldRenderer):
1023
1222
  li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
1024
1223
 
1025
1224
  # Wrap error component in a div with consistent padding
1026
- content_wrapper = fh.Div(content_component, cls="px-4 py-3")
1225
+ t = self.spacing
1226
+ content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
1227
+
1228
+ # Build card classes conditionally based on spacing theme
1229
+ card_cls = "uk-card uk-margin-small-bottom"
1230
+ if self.spacing == SpacingTheme.NORMAL:
1231
+ card_cls += " uk-card-default"
1027
1232
 
1028
1233
  return mui.AccordionItem(
1029
1234
  title_component, # Title as first positional argument
1030
1235
  content_wrapper, # Wrapped content element
1031
- cls="mb-2", # Use cls keyword arg directly
1236
+ cls=card_cls, # Use theme-aware card classes
1032
1237
  li_kwargs=li_attrs, # Pass remaining li attributes without cls
1033
1238
  )