fh-pydantic-form 0.3.6__py3-none-any.whl → 0.3.8__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.

@@ -14,7 +14,7 @@ from typing import (
14
14
  import fasthtml.common as fh
15
15
  import monsterui.all as mui
16
16
  from fastcore.xml import FT
17
- from pydantic import ValidationError
17
+ from pydantic import BaseModel, ValidationError
18
18
  from pydantic.fields import FieldInfo
19
19
 
20
20
  from fh_pydantic_form.color_utils import (
@@ -23,6 +23,7 @@ from fh_pydantic_form.color_utils import (
23
23
  robust_color_to_rgba,
24
24
  )
25
25
  from fh_pydantic_form.constants import _UNSET
26
+ from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
26
27
  from fh_pydantic_form.registry import FieldRendererRegistry
27
28
  from fh_pydantic_form.type_helpers import (
28
29
  DecorationScope,
@@ -30,7 +31,9 @@ from fh_pydantic_form.type_helpers import (
30
31
  MetricsDict,
31
32
  _get_underlying_type_if_optional,
32
33
  _is_optional_type,
34
+ _is_skip_json_schema_field,
33
35
  get_default,
36
+ normalize_path_segments,
34
37
  )
35
38
  from fh_pydantic_form.ui_style import (
36
39
  SpacingTheme,
@@ -379,6 +382,10 @@ class BaseFieldRenderer(MetricsRendererMixin):
379
382
  metric_entry: Optional[MetricEntry] = None,
380
383
  metrics_dict: Optional[MetricsDict] = None,
381
384
  refresh_endpoint_override: Optional[str] = None,
385
+ keep_skip_json_pathset: Optional[set[str]] = None,
386
+ comparison_copy_enabled: bool = False,
387
+ comparison_copy_target: Optional[str] = None,
388
+ comparison_name: Optional[str] = None,
382
389
  **kwargs, # Accept additional kwargs for extensibility
383
390
  ):
384
391
  """
@@ -397,11 +404,22 @@ class BaseFieldRenderer(MetricsRendererMixin):
397
404
  metric_entry: Optional metric entry for visual feedback
398
405
  metrics_dict: Optional full metrics dict for auto-lookup
399
406
  refresh_endpoint_override: Optional override URL for refresh actions (used in ComparisonForm)
407
+ comparison_copy_enabled: If True, show copy button for this field
408
+ comparison_copy_target: "left" or "right" - which side this field is on
409
+ comparison_name: Name of the ComparisonForm (for copy route URLs)
400
410
  **kwargs: Additional keyword arguments for extensibility
401
411
  """
402
412
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
403
413
  self.original_field_name = field_name
404
414
  self.field_info = field_info
415
+ # Normalize PydanticUndefined → None so it never renders as text
416
+ try:
417
+ from pydantic_core import PydanticUndefined
418
+
419
+ if value is PydanticUndefined:
420
+ value = None
421
+ except Exception:
422
+ pass
405
423
  self.value = value
406
424
  self.prefix = prefix
407
425
  self.field_path: List[str] = field_path or []
@@ -412,6 +430,10 @@ class BaseFieldRenderer(MetricsRendererMixin):
412
430
  self.spacing = _normalize_spacing(spacing)
413
431
  self.metrics_dict = metrics_dict
414
432
  self._refresh_endpoint_override = refresh_endpoint_override
433
+ self._keep_skip_json_pathset = keep_skip_json_pathset or set()
434
+ self._cmp_copy_enabled = comparison_copy_enabled
435
+ self._cmp_copy_target = comparison_copy_target
436
+ self._cmp_name = comparison_name
415
437
 
416
438
  # Initialize metric entry attribute
417
439
  self.metric_entry: Optional[MetricEntry] = None
@@ -447,6 +469,15 @@ class BaseFieldRenderer(MetricsRendererMixin):
447
469
  parts.append(segment)
448
470
  return ".".join(parts)
449
471
 
472
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
473
+ """Normalize path segments by dropping indices and joining with dots."""
474
+ return normalize_path_segments(path_segments)
475
+
476
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
477
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
478
+ normalized = self._normalized_dot_path(full_path)
479
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
480
+
450
481
  def _is_inline_color(self, color: str) -> bool:
451
482
  """
452
483
  Determine if a color should be applied as an inline style or CSS class.
@@ -487,6 +518,38 @@ class BaseFieldRenderer(MetricsRendererMixin):
487
518
  """
488
519
  return f"text-{color}-600"
489
520
 
521
+ def _render_comparison_copy_button(self) -> Optional[FT]:
522
+ """
523
+ Render a copy button for comparison forms.
524
+
525
+ Note: Copy buttons are never disabled, even if the field itself is disabled.
526
+ This allows copying from disabled (read-only) fields to editable fields.
527
+
528
+ Returns:
529
+ A copy button component, or None if not in comparison mode
530
+ """
531
+ if not (self._cmp_copy_enabled and self._cmp_copy_target and self._cmp_name):
532
+ return None
533
+
534
+ path = self._build_path_string()
535
+ # Use arrow pointing in the direction of the copy (towards target)
536
+ arrow = "arrow-left" if self._cmp_copy_target == "left" else "arrow-right"
537
+ tooltip_text = f"Copy to {self._cmp_copy_target}"
538
+
539
+ # Note: We explicitly do NOT pass disabled=self.disabled here
540
+ # Copy buttons should always be enabled, even in disabled forms
541
+ #
542
+ # Pure JS copy: Bypass HTMX entirely to avoid accordion collapse
543
+ # Button is on SOURCE side, arrow points to TARGET side
544
+ return mui.Button(
545
+ mui.UkIcon(arrow, cls="w-4 h-4 text-gray-500 hover:text-blue-600"),
546
+ type="button",
547
+ onclick=f"window.fhpfPerformCopy('{path}', '{self.prefix}', '{self._cmp_copy_target}'); return false;",
548
+ uk_tooltip=tooltip_text,
549
+ cls="uk-button-text uk-button-small flex-shrink-0",
550
+ style="all: unset; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.25rem; min-width: 1.5rem;",
551
+ )
552
+
490
553
  def render_label(self) -> FT:
491
554
  """
492
555
  Render label for the field
@@ -557,13 +620,16 @@ class BaseFieldRenderer(MetricsRendererMixin):
557
620
  Returns:
558
621
  A FastHTML component containing the complete field
559
622
  """
560
- # 1. Get the label component
623
+ # 1. Get the label component (without copy button)
561
624
  label_component = self.render_label()
562
625
 
563
626
  # 2. Render the input field
564
627
  input_component = self.render_input()
565
628
 
566
- # 3. Choose layout based on spacing theme
629
+ # 3. Get the copy button if enabled
630
+ copy_button = self._render_comparison_copy_button()
631
+
632
+ # 4. Choose layout based on spacing theme
567
633
  if self.spacing == SpacingTheme.COMPACT:
568
634
  # Horizontal layout for compact mode
569
635
  field_element = fh.Div(
@@ -575,15 +641,25 @@ class BaseFieldRenderer(MetricsRendererMixin):
575
641
  cls=f"{spacing('outer_margin', self.spacing)} w-full",
576
642
  )
577
643
  else:
578
- # Vertical layout for normal mode (existing behavior)
644
+ # Vertical layout for normal mode
579
645
  field_element = fh.Div(
580
646
  label_component,
581
647
  input_component,
582
648
  cls=spacing("outer_margin", self.spacing),
583
649
  )
584
650
 
585
- # 4. Apply metrics decoration if available
586
- return self._decorate_metrics(field_element, self.metric_entry)
651
+ # 5. Apply metrics decoration if available
652
+ decorated_field = self._decorate_metrics(field_element, self.metric_entry)
653
+
654
+ # 6. If copy button exists, wrap the entire decorated field with copy button on the right
655
+ if copy_button:
656
+ return fh.Div(
657
+ decorated_field,
658
+ copy_button,
659
+ cls="flex items-start gap-2 w-full",
660
+ )
661
+ else:
662
+ return decorated_field
587
663
 
588
664
 
589
665
  # ---- Specific Field Renderers ----
@@ -640,6 +716,7 @@ class StringFieldRenderer(BaseFieldRenderer):
640
716
  "cls": " ".join(input_cls_parts),
641
717
  "rows": rows,
642
718
  "style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
719
+ "data-field-path": self._build_path_string(),
643
720
  }
644
721
 
645
722
  # Only add the disabled attribute if the field should actually be disabled
@@ -697,6 +774,7 @@ class NumberFieldRenderer(BaseFieldRenderer):
697
774
  if self.field_info.annotation is float
698
775
  or get_origin(self.field_info.annotation) is float
699
776
  else "1",
777
+ "data-field-path": self._build_path_string(),
700
778
  }
701
779
 
702
780
  # Only add the disabled attribute if the field should actually be disabled
@@ -757,6 +835,7 @@ class DecimalFieldRenderer(BaseFieldRenderer):
757
835
  "required": is_field_required,
758
836
  "cls": " ".join(input_cls_parts),
759
837
  "step": "any", # Allow arbitrary decimal precision
838
+ "data-field-path": self._build_path_string(),
760
839
  }
761
840
 
762
841
  # Only add the disabled attribute if the field should actually be disabled
@@ -784,6 +863,7 @@ class BooleanFieldRenderer(BaseFieldRenderer):
784
863
  "id": self.field_name,
785
864
  "name": self.field_name,
786
865
  "checked": bool(self.value),
866
+ "data-field-path": self._build_path_string(),
787
867
  }
788
868
 
789
869
  # Only add the disabled attribute if the field should actually be disabled
@@ -807,6 +887,9 @@ class BooleanFieldRenderer(BaseFieldRenderer):
807
887
  # Get the checkbox component
808
888
  checkbox_component = self.render_input()
809
889
 
890
+ # Get the copy button if enabled
891
+ copy_button = self._render_comparison_copy_button()
892
+
810
893
  # Create a flex container to place label and checkbox side by side
811
894
  field_element = fh.Div(
812
895
  fh.Div(
@@ -818,10 +901,20 @@ class BooleanFieldRenderer(BaseFieldRenderer):
818
901
  )
819
902
 
820
903
  # Apply metrics decoration if available (border only, as bullet is in the label)
821
- return self._decorate_metrics(
904
+ decorated_field = self._decorate_metrics(
822
905
  field_element, self.metric_entry, scope=DecorationScope.BORDER
823
906
  )
824
907
 
908
+ # If copy button exists, wrap the entire decorated field with copy button on the right
909
+ if copy_button:
910
+ return fh.Div(
911
+ decorated_field,
912
+ copy_button,
913
+ cls="flex items-start gap-2 w-full",
914
+ )
915
+ else:
916
+ return decorated_field
917
+
825
918
 
826
919
  class DateFieldRenderer(BaseFieldRenderer):
827
920
  """Renderer for date fields"""
@@ -869,6 +962,7 @@ class DateFieldRenderer(BaseFieldRenderer):
869
962
  "placeholder": placeholder_text,
870
963
  "required": is_field_required,
871
964
  "cls": " ".join(input_cls_parts),
965
+ "data-field-path": self._build_path_string(),
872
966
  }
873
967
 
874
968
  # Only add the disabled attribute if the field should actually be disabled
@@ -933,6 +1027,7 @@ class TimeFieldRenderer(BaseFieldRenderer):
933
1027
  "placeholder": placeholder_text,
934
1028
  "required": is_field_required,
935
1029
  "cls": " ".join(input_cls_parts),
1030
+ "data-field-path": self._build_path_string(),
936
1031
  }
937
1032
 
938
1033
  # Only add the disabled attribute if the field should actually be disabled
@@ -1010,6 +1105,7 @@ class LiteralFieldRenderer(BaseFieldRenderer):
1010
1105
  "required": is_field_required,
1011
1106
  "placeholder": placeholder_text,
1012
1107
  "cls": " ".join(select_cls_parts),
1108
+ "data-field-path": self._build_path_string(),
1013
1109
  }
1014
1110
 
1015
1111
  if self.disabled:
@@ -1098,6 +1194,7 @@ class EnumFieldRenderer(BaseFieldRenderer):
1098
1194
  "w-full",
1099
1195
  f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
1100
1196
  ),
1197
+ "data-field-path": self._build_path_string(),
1101
1198
  }
1102
1199
 
1103
1200
  # Only add the disabled attribute if the field should actually be disabled
@@ -1130,34 +1227,45 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
1130
1227
  if self.label_color:
1131
1228
  if self._is_inline_color(self.label_color):
1132
1229
  # Color value - apply as inline style
1133
- title_component = fh.Span(
1230
+ title_span = fh.Span(
1134
1231
  label_text,
1135
1232
  style=f"color: {self.label_color};",
1136
1233
  cls="text-sm font-medium",
1137
1234
  )
1138
1235
  else:
1139
1236
  # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
1140
- title_component = fh.Span(
1237
+ title_span = fh.Span(
1141
1238
  label_text,
1142
1239
  cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
1143
1240
  )
1144
1241
  else:
1145
1242
  # No color specified - use default styling
1146
- title_component = fh.Span(
1147
- label_text, cls="text-sm font-medium text-gray-700"
1148
- )
1243
+ title_span = fh.Span(label_text, cls="text-sm font-medium text-gray-700")
1149
1244
 
1150
1245
  # Add tooltip if description is available
1151
1246
  description = getattr(self.field_info, "description", None)
1152
1247
  if description:
1153
- title_component.attrs["uk-tooltip"] = description
1154
- title_component.attrs["title"] = description
1248
+ title_span.attrs["uk-tooltip"] = description
1249
+ title_span.attrs["title"] = description
1155
1250
 
1156
1251
  # Apply metrics decoration to title (bullet only, no border)
1157
- title_component = self._decorate_metrics(
1158
- title_component, self.metric_entry, scope=DecorationScope.BULLET
1252
+ title_with_metrics = self._decorate_metrics(
1253
+ title_span, self.metric_entry, scope=DecorationScope.BULLET
1159
1254
  )
1160
1255
 
1256
+ # Get copy button if enabled - add it AFTER metrics decoration
1257
+ copy_button = self._render_comparison_copy_button()
1258
+
1259
+ # Wrap title (with metrics) and copy button together if copy button exists
1260
+ if copy_button:
1261
+ title_component = fh.Div(
1262
+ title_with_metrics,
1263
+ copy_button,
1264
+ cls="flex items-center justify-between gap-2 w-full",
1265
+ )
1266
+ else:
1267
+ title_component = title_with_metrics
1268
+
1161
1269
  # Compute border color for the top-level BaseModel card
1162
1270
  border_color = self._metric_border_color(self.metric_entry)
1163
1271
  li_style = {}
@@ -1249,18 +1357,26 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
1249
1357
 
1250
1358
  # Only use defaults if field wasn't provided
1251
1359
  if not field_was_provided:
1252
- if nested_field_info.default is not None:
1253
- nested_field_value = nested_field_info.default
1254
- elif (
1255
- getattr(nested_field_info, "default_factory", None) is not None
1256
- ):
1257
- try:
1258
- nested_field_value = nested_field_info.default_factory()
1259
- except Exception as e:
1260
- logger.warning(
1261
- f"Default factory failed for {nested_field_name}: {e}"
1262
- )
1263
- nested_field_value = None
1360
+ dv = get_default(nested_field_info) # _UNSET if truly unset
1361
+ if dv is not _UNSET:
1362
+ nested_field_value = dv
1363
+ else:
1364
+ ann = nested_field_info.annotation
1365
+ base_ann = get_origin(ann) or ann
1366
+ if isinstance(base_ann, type) and issubclass(
1367
+ base_ann, BaseModel
1368
+ ):
1369
+ nested_field_value = default_dict_for_model(base_ann)
1370
+ else:
1371
+ nested_field_value = default_for_annotation(ann)
1372
+
1373
+ # Skip SkipJsonSchema fields unless explicitly kept
1374
+ if _is_skip_json_schema_field(
1375
+ nested_field_info
1376
+ ) and not self._is_kept_skip_field(
1377
+ self.field_path + [nested_field_name]
1378
+ ):
1379
+ continue
1264
1380
 
1265
1381
  # Get renderer for this nested field
1266
1382
  registry = FieldRendererRegistry() # Get singleton instance
@@ -1290,6 +1406,10 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
1290
1406
  metric_entry=None, # Let auto-lookup handle it
1291
1407
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1292
1408
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1409
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1410
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
1411
+ comparison_copy_target=self._cmp_copy_target,
1412
+ comparison_name=self._cmp_name,
1293
1413
  )
1294
1414
 
1295
1415
  nested_inputs.append(renderer.render())
@@ -1469,7 +1589,10 @@ class ListFieldRenderer(BaseFieldRenderer):
1469
1589
 
1470
1590
  # Metric decoration will be applied to the title_component below
1471
1591
 
1472
- # Only add refresh icon if we have a form name and field is not disabled
1592
+ # Build action buttons row (refresh only, NOT copy - copy goes after metrics)
1593
+ action_buttons = []
1594
+
1595
+ # Add refresh icon if we have a form name and field is not disabled
1473
1596
  if form_name and not self.disabled:
1474
1597
  # Create the smaller icon component
1475
1598
  refresh_icon_component = mui.UkIcon(
@@ -1496,7 +1619,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1496
1619
  hx_include="closest form", # Include all form fields from the enclosing form
1497
1620
  hx_preserve="scroll",
1498
1621
  uk_tooltip="Refresh form display to update list summaries",
1499
- style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem; margin-left: 0.5rem;",
1622
+ style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem;",
1500
1623
  **{
1501
1624
  "hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
1502
1625
  },
@@ -1504,32 +1627,47 @@ class ListFieldRenderer(BaseFieldRenderer):
1504
1627
  "hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
1505
1628
  },
1506
1629
  )
1630
+ action_buttons.append(refresh_icon_trigger)
1507
1631
 
1508
- # Combine label and icon - put refresh icon in a separate div to isolate it
1509
- title_component = fh.Div(
1632
+ # Build title component with label and action buttons (excluding copy button)
1633
+ if action_buttons:
1634
+ # Combine label and action buttons
1635
+ title_base = fh.Div(
1510
1636
  fh.Div(
1511
1637
  label_span, # Use the properly styled label span
1512
1638
  cls="flex-1", # Take up remaining space
1513
1639
  ),
1514
1640
  fh.Div(
1515
- refresh_icon_trigger,
1516
- cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding for larger click area
1517
- onclick="event.stopPropagation();", # Isolate the refresh icon area
1641
+ *action_buttons,
1642
+ cls="flex-shrink-0 flex items-center gap-1 px-1", # Don't shrink, add horizontal padding
1643
+ onclick="event.stopPropagation();", # Isolate the action buttons area
1518
1644
  ),
1519
1645
  cls="flex items-center",
1520
1646
  )
1521
1647
  else:
1522
- # If no form name, just use the styled label
1523
- title_component = fh.Div(
1648
+ # If no action buttons, just use the styled label
1649
+ title_base = fh.Div(
1524
1650
  label_span, # Use the properly styled label span
1525
- cls="flex items-center", # Remove cursor-pointer and click handler
1651
+ cls="flex items-center",
1526
1652
  )
1527
1653
 
1528
1654
  # Apply metrics decoration to title (bullet only, no border)
1529
- title_component = self._decorate_metrics(
1530
- title_component, self.metric_entry, scope=DecorationScope.BULLET
1655
+ title_with_metrics = self._decorate_metrics(
1656
+ title_base, self.metric_entry, scope=DecorationScope.BULLET
1531
1657
  )
1532
1658
 
1659
+ # Add copy button AFTER metrics decoration
1660
+ copy_button = self._render_comparison_copy_button()
1661
+ if copy_button:
1662
+ # Wrap title (with metrics) and copy button together
1663
+ title_component = fh.Div(
1664
+ title_with_metrics,
1665
+ copy_button,
1666
+ cls="flex items-center justify-between gap-2 w-full",
1667
+ )
1668
+ else:
1669
+ title_component = title_with_metrics
1670
+
1533
1671
  # Compute border color for the wrapper accordion
1534
1672
  border_color = self._metric_border_color(self.metric_entry)
1535
1673
  li_style = {}
@@ -1843,6 +1981,10 @@ class ListFieldRenderer(BaseFieldRenderer):
1843
1981
  metric_entry=None, # Let auto-lookup handle it
1844
1982
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1845
1983
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1984
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1985
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
1986
+ comparison_copy_target=self._cmp_copy_target,
1987
+ comparison_name=self._cmp_name,
1846
1988
  )
1847
1989
  # Add the rendered input to content elements
1848
1990
  item_content_elements.append(item_renderer.render_input())
@@ -1866,18 +2008,28 @@ class ListFieldRenderer(BaseFieldRenderer):
1866
2008
 
1867
2009
  # Use defaults only if field not provided
1868
2010
  if not field_was_provided:
1869
- if nested_field_info.default is not None:
1870
- nested_field_value = nested_field_info.default
1871
- elif (
1872
- getattr(nested_field_info, "default_factory", None)
1873
- is not None
1874
- ):
1875
- try:
1876
- nested_field_value = (
1877
- nested_field_info.default_factory()
2011
+ dv = get_default(nested_field_info)
2012
+ if dv is not _UNSET:
2013
+ nested_field_value = dv
2014
+ else:
2015
+ ann = nested_field_info.annotation
2016
+ base_ann = get_origin(ann) or ann
2017
+ if isinstance(base_ann, type) and issubclass(
2018
+ base_ann, BaseModel
2019
+ ):
2020
+ nested_field_value = default_dict_for_model(
2021
+ base_ann
1878
2022
  )
1879
- except Exception:
1880
- continue # Skip fields with problematic defaults
2023
+ else:
2024
+ nested_field_value = default_for_annotation(ann)
2025
+
2026
+ # Skip SkipJsonSchema fields unless explicitly kept
2027
+ if _is_skip_json_schema_field(
2028
+ nested_field_info
2029
+ ) and not self._is_kept_skip_field(
2030
+ self.field_path + [nested_field_name]
2031
+ ):
2032
+ continue
1881
2033
 
1882
2034
  # Get renderer and render field with error handling
1883
2035
  renderer_cls = FieldRendererRegistry().get_renderer(
@@ -1902,6 +2054,10 @@ class ListFieldRenderer(BaseFieldRenderer):
1902
2054
  metric_entry=None, # Let auto-lookup handle it
1903
2055
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1904
2056
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
2057
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
2058
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
2059
+ comparison_copy_target=self._cmp_copy_target,
2060
+ comparison_name=self._cmp_name,
1905
2061
  )
1906
2062
 
1907
2063
  # Add rendered field to valid fields
@@ -2044,15 +2200,46 @@ class ListFieldRenderer(BaseFieldRenderer):
2044
2200
  )
2045
2201
 
2046
2202
  # Return the accordion item
2047
- title_component = fh.Span(
2203
+ title_span = fh.Span(
2048
2204
  item_summary_text, cls="text-gray-700 font-medium pl-3"
2049
2205
  )
2050
2206
 
2051
- # Apply metrics decoration to the title (bullet only)
2052
- title_component = self._decorate_metrics(
2053
- title_component, item_metric_entry, scope=DecorationScope.BULLET
2207
+ # Apply metrics decoration to the title span FIRST (bullet only)
2208
+ title_with_metrics = self._decorate_metrics(
2209
+ title_span, item_metric_entry, scope=DecorationScope.BULLET
2054
2210
  )
2055
2211
 
2212
+ # Get copy button for this specific list item (if enabled) - add AFTER metrics
2213
+ # Create a temporary renderer context with this item's path
2214
+ if self._cmp_copy_enabled and self._cmp_copy_target and self._cmp_name:
2215
+ # Build the path for this specific item
2216
+ item_path_for_copy = self.field_path + [str(idx)]
2217
+ item_path_string = _build_path_string_static(item_path_for_copy)
2218
+ arrow = (
2219
+ "arrow-left" if self._cmp_copy_target == "left" else "arrow-right"
2220
+ )
2221
+ tooltip_text = f"Copy item to {self._cmp_copy_target}"
2222
+
2223
+ # Note: Copy button is never disabled, even in disabled forms
2224
+ # Pure JS copy: Bypass HTMX entirely to avoid accordion collapse
2225
+ item_copy_button = mui.Button(
2226
+ mui.UkIcon(arrow, cls="w-4 h-4 text-gray-500 hover:text-blue-600"),
2227
+ type="button",
2228
+ onclick=f"window.fhpfPerformCopy('{item_path_string}', '{self.prefix}', '{self._cmp_copy_target}'); return false;",
2229
+ uk_tooltip=tooltip_text,
2230
+ cls="uk-button-text uk-button-small flex-shrink-0",
2231
+ style="all: unset; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.25rem; min-width: 1.5rem;",
2232
+ )
2233
+
2234
+ # Wrap title (with metrics) and copy button together
2235
+ title_component = fh.Div(
2236
+ title_with_metrics,
2237
+ item_copy_button,
2238
+ cls="flex items-center justify-between gap-2 w-full",
2239
+ )
2240
+ else:
2241
+ title_component = title_with_metrics
2242
+
2056
2243
  # Prepare li attributes with optional border styling
2057
2244
  li_attrs = {"id": full_card_id}
2058
2245