Repository: wagtail/wagtail

Issue:
Add ability to support `attrs` in `Panel` and by extension `FieldPanel`
### Is your proposal related to a problem?

With the adoption of Stimulus under way and plans for developer facing documentation (non-contributors) coming up soon, it would be good to be able to provide a concise way to allow for HTML attributes to be easily passed from `Panel`/`FieldPanel` through to the HTML template.

This would allow for official support for much simpler customisations where using/adding data attributes is needed (e.g. `data-controller="custom-controller"`) or even common requirements like aria or ID attributes.

### Describe the solution you'd like

* `Panel` class should allow a new kwarg `attrs` - https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/base.py#L48 (including support for cloning this in `clone_kwargs`)
* `FieldPanel` should support have something similar to `BASE_ATTRS` on the class as a property, similar to `TEMPLATE_VAR` https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/field_panel.py#L12
* `FieldPanel` could have `BASE_ATTRS` set to `{"data-field-wrapper": true}` by default and then on the `FieldPanel` init, it sets this as the `attrs`, and extends it with any `attrs` provided to the `FieldPanel` instance, before calling `super`. Note that someone should be able to override the `data-field-wrapper` if they chose (although this would probably break things).
* `BoundPanel.get_context_data` should support passing in the `attrs` to the template (probably at the root `Panel` implementation.
* The template `wagtail/admin/templates/wagtailadmin/shared/field.html` should support rendering the attributes in the same way that Django attrs are rendered.
* Extending `FieldPanel` or `Panel` and the BoundPanel's `get_context` should easily allow appending/adjusting the `attrs` through the inheritance chain as needed.

### Example usage

Trivial example but shows off how it would work

```py
# models.py
class OperatingHours(models.Model):

    day = models.CharField(max_length=4, choices=DAY_CHOICES, default="MON")

    panels = [
        FieldPanel("day", attrs={"data-controller": "today"}),
    ]
```

```js
// .../static/js/TodayController.js - added via hooks

const FilterController = window.Stimulus.constructor.createController({
  connect() {
    const field = this.element.querySelector('[data-field] input');
    const todayWeekday = 3; // get today's weekday as a number
    if (field.value === todayWeekday) {
        this.element.classList.add('highlight');
    }
  }
});

window.Stimulus.register('today', FilterController);
```

#### `attrs` rendering should emulate Django widget attrs

Using Django's terminology and ways of working with widget attrs on panels feels intuitive and is also very powerful, note that this is not a proposal to allow for non-called `Panel/FieldPanel` as is done with Widgets.

The way that `attrs` in Django widgets get rendered is a bit nuanced and has some really nice defaults, the main one being how True/False items are handled. True will just add the attribute as is (no value) and False will not add the attribute at all. There are also the built in escaping benefits that come with this being driven at the Django side.

There is also some nice handling of cloning/extending the `attrs` object.

* https://docs.djangoproject.com/en/4.1/ref/forms/widgets/#django.forms.Widget.attrs
* See https://github.com/django/django/blob/main/django/forms/widgets.py#L281
* https://github.com/django/django/blob/main/django/forms/templates/django/forms/attrs.html

### Describe alternatives you've considered

* Wagtail provides a documented way to add attributes to a field, via the `widget=` param on `FieldPanel`, this is often enough to get an attribute inside the element.
* Maybe we do not want to add another kwarg to `Panel`, we already do support `classname` and that may be enough at the root level.
* We may not want to provide the ability to do this at the panel level and instead want to push this kind of thing to the template level, however, our `field_panel` does not allow for appending `attrs` by extension right now so it's very difficult to do this without just adding another `div` around it. Additionally, when putting attrs in the template's HTML, there are further escaping considerations that have to be done manually and add developer overhead / security risks.

### Additional context

* See answer to a problem in a comment - https://github.com/wagtail/wagtail/issues/7417#issuecomment-1438396850 - this would be easier to implement (when that solution ends up in the docs for example).
* `PageChooserPanel`, `InlinePanel` and other custom Panels could much more easily support Stimulus controller attributes without needing to change much/any of the template HTML files if needed.
* We can provide some nice documented / answered solutions to #161 & #1063 via essentially adding some JavaScript snippets and adding `attrs` to the `FieldPanel`.



Discussion:
Spoke about this at core team and approval that this would be a useful enhancement.

**Considerations**

* Ensure developers can use the calling kwargs but also override when extending the `FieldPanel` class or similar.
* Double check other Panel templates in use and how they would or would not support this (e.g. `MultiFieldPanel` - does not have a root element), may need some nuance in the docs.
would like to pick this one up, after i am done with https://github.com/wagtail/wagtail/issues/10090
if anyone else is willing to try let me know.  
@salty-ivy this one might be a bit chunky, but if you feel up for it - sounds good. I am happy to answer questions as needed.

No pressure though, I was also happy to do this one as I originally requested this and am hopeful this will make developer facing Stimulus usage much easier.
taking this up.
@lb-  do we need to render these attrs next to `data-field-input` in the `dive`?  in `field.html`
```html
<div class="w-field__input" data-field-input>
            {% if icon %}
                {% icon name=icon classname="w-field__icon" %}
            {% endif %}

            {% block form_field %}
                {% if rendered_field %}
                    {{ rendered_field }}
                {% elif field %}
                    {{ field|render_with_errors }}
                {% elif children %}
                    {{ children }}
                {% endif %}
            {% endblock %}

            {% if show_add_comment_button %}
                <button class="w-field__comment-button w-field__comment-button--add u-hidden" type="button" data-component="add-comment-button" data-comment-add aria-label="{% trans 'Add comment' %}" {% if label_for %}aria-describedby="{{ label_id }}"{% endif %}>
                    {% icon name="comment-add" %}
                    {% icon name="comment-add-reversed" %}
                </button>
            {% endif %}
        </div>
```

EDIT: I think I should have first tried some stuff on my own before asking, you can ignore this  : )
Thanks Aman. Looks like you have found the answer. Happy to help with this though if you do get stuck. 

Resolving patch:
diff --git a/wagtail/admin/panels/base.py b/wagtail/admin/panels/base.py
--- a/wagtail/admin/panels/base.py
+++ b/wagtail/admin/panels/base.py
@@ -65,8 +65,11 @@ class Panel:
     :param help_text: Help text to display within the panel.
     :param base_form_class: The base form class to use for the panel. Defaults to the model's ``base_form_class``, before falling back to :class:`~wagtail.admin.forms.WagtailAdminModelForm`. This is only relevant for the top-level panel.
     :param icon: The name of the icon to display next to the panel heading.
+    :param attrs: A dictionary of HTML attributes to add to the panel's HTML element.
     """
 
+    BASE_ATTRS = {}
+
     def __init__(
         self,
         heading="",
@@ -74,6 +77,7 @@ def __init__(
         help_text="",
         base_form_class=None,
         icon="",
+        attrs=None,
     ):
         self.heading = heading
         self.classname = classname
@@ -81,6 +85,10 @@ def __init__(
         self.base_form_class = base_form_class
         self.icon = icon
         self.model = None
+        self.attrs = self.BASE_ATTRS.copy()
+
+        if attrs is not None:
+            self.attrs.update(attrs)
 
     def clone(self):
         """
@@ -95,6 +103,7 @@ def clone_kwargs(self):
         """
         return {
             "icon": self.icon,
+            "attrs": self.attrs,
             "heading": self.heading,
             "classname": self.classname,
             "help_text": self.help_text,
@@ -245,6 +254,10 @@ def classname(self):
         def classes(self):
             return self.panel.classes()
 
+        @property
+        def attrs(self):
+            return self.panel.attrs
+
         @property
         def icon(self):
             return self.panel.icon
@@ -273,6 +286,7 @@ def is_required(self):
         def get_context_data(self, parent_context=None):
             context = super().get_context_data(parent_context)
             context["self"] = self
+            context["attrs"] = self.attrs
             return context
 
         def get_comparison(self):


Test patch:
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py
--- a/wagtail/admin/tests/test_edit_handlers.py
+++ b/wagtail/admin/tests/test_edit_handlers.py
@@ -20,6 +20,7 @@
     CommentPanel,
     FieldPanel,
     FieldRowPanel,
+    HelpPanel,
     InlinePanel,
     MultiFieldPanel,
     MultipleChooserPanel,
@@ -381,6 +382,115 @@ def test_can_build_panel_list(self):
         )
 
 
+class TestPanelAttributes(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.request = RequestFactory().get("/")
+        user = self.create_superuser(username="admin")
+        self.request.user = user
+        self.user = self.login()
+
+        # a custom tabbed interface for EventPage
+        self.event_page_tabbed_interface = TabbedInterface(
+            [
+                ObjectList(
+                    [
+                        HelpPanel(
+                            "Double-check event details before submit.",
+                            attrs={"data-panel-type": "help"},
+                        ),
+                        FieldPanel("title", widget=forms.Textarea),
+                        FieldRowPanel(
+                            [
+                                FieldPanel("date_from"),
+                                FieldPanel(
+                                    "date_to", attrs={"data-panel-type": "field"}
+                                ),
+                            ],
+                            attrs={"data-panel-type": "field-row"},
+                        ),
+                    ],
+                    heading="Event details",
+                    classname="shiny",
+                    attrs={"data-panel-type": "object-list"},
+                ),
+                ObjectList(
+                    [
+                        InlinePanel(
+                            "speakers",
+                            label="Speakers",
+                            attrs={"data-panel-type": "inline"},
+                        ),
+                    ],
+                    heading="Speakers",
+                ),
+                ObjectList(
+                    [
+                        MultiFieldPanel(
+                            [
+                                HelpPanel(
+                                    "Double-check cost details before submit.",
+                                    attrs={"data-panel-type": "help-cost"},
+                                ),
+                                FieldPanel("cost"),
+                                FieldRowPanel(
+                                    [
+                                        FieldPanel("cost"),
+                                        FieldPanel(
+                                            "cost",
+                                            attrs={
+                                                "data-panel-type": "nested-object_list-multi_field-field_row-field"
+                                            },
+                                        ),
+                                    ],
+                                    attrs={
+                                        "data-panel-type": "nested-object_list-multi_field-field_row"
+                                    },
+                                ),
+                            ],
+                            attrs={"data-panel-type": "multi-field"},
+                        )
+                    ],
+                    heading="Secret",
+                ),
+            ],
+            attrs={"data-panel-type": "tabs"},
+        ).bind_to_model(EventPage)
+
+    def test_render(self):
+        EventPageForm = self.event_page_tabbed_interface.get_form_class()
+        event = EventPage(title="Abergavenny sheepdog trials")
+        form = EventPageForm(instance=event)
+
+        tabbed_interface = self.event_page_tabbed_interface.get_bound_panel(
+            instance=event,
+            form=form,
+            request=self.request,
+        )
+
+        result = tabbed_interface.render_html()
+
+        # result should contain custom data attributes assigned to panels
+        # each attribute should be rendered exactly once
+        self.assertEqual(result.count('data-panel-type="tabs"'), 1)
+        self.assertEqual(result.count('data-panel-type="multi-field"'), 1)
+        self.assertEqual(
+            result.count('data-panel-type="nested-object_list-multi_field-field_row"'),
+            1,
+        )
+        self.assertEqual(
+            result.count(
+                'data-panel-type="nested-object_list-multi_field-field_row-field"'
+            ),
+            1,
+        )
+        self.assertEqual(result.count('data-panel-type="help-cost"'), 1)
+        self.assertEqual(result.count('data-panel-type="inline"'), 1)
+        self.assertEqual(result.count('data-panel-type="object-list"'), 1)
+        self.assertEqual(result.count('data-panel-type="field-row"'), 1)
+        self.assertEqual(result.count('data-panel-type="field"'), 1)
+        self.assertEqual(result.count('data-panel-type="help"'), 1)
+
+
 class TestTabbedInterface(WagtailTestUtils, TestCase):
     def setUp(self):
         self.request = RequestFactory().get("/")
@@ -428,7 +538,8 @@ def setUp(self):
                     permission="tests.other_custom_see_panel_setting",
                     heading="Other Custom Setting",
                 ),
-            ]
+            ],
+            attrs={"data-controller": "my-tabbed-interface"},
         ).bind_to_model(EventPage)
 
     def test_get_form_class(self):
@@ -471,6 +582,9 @@ def test_render(self):
         # result should contain rendered content from descendants
         self.assertIn("Abergavenny sheepdog trials</textarea>", result)
 
+        # result should contain the data-controller attribute as defined by attrs
+        self.assertIn('data-controller="my-tabbed-interface"', result)
+
         # this result should not include fields that are not covered by the panel definition
         self.assertNotIn("signup_link", result)
 
@@ -619,6 +733,7 @@ def setUp(self):
             ],
             heading="Event details",
             classname="shiny",
+            attrs={"data-controller": "my-object-list"},
         ).bind_to_model(EventPage)
 
     def test_get_form_class(self):
@@ -647,6 +762,9 @@ def test_render(self):
         # result should contain ObjectList furniture
         self.assertIn('<div class="w-panel__header">', result)
 
+        # result should contain the specified attrs
+        self.assertIn('data-controller="my-object-list"', result)
+
         # result should contain labels for children
         self.assertIn(
             '<label for="id_date_from" id="id_date_from-label">',
@@ -810,6 +928,9 @@ def test_render_html(self):
                 # The input should have the expected value
                 self.assertIn(f'value="{expected_input_value}"', result)
 
+                # check that data-field-wrapper is added by default via attrs.
+                self.assertIn("data-field-wrapper", result)
+
                 # help text should rendered
                 self.assertIn("Not required if event is on a single day", result)
 
@@ -1206,7 +1327,10 @@ def test_render(self):
         speaker_object_list = ObjectList(
             [
                 InlinePanel(
-                    "speakers", label="Speakers", classname="classname-for-speakers"
+                    "speakers",
+                    label="Speakers",
+                    classname="classname-for-speakers",
+                    attrs={"data-controller": "test"},
                 )
             ]
         ).bind_to_model(EventPage)
@@ -1268,6 +1392,12 @@ def test_render(self):
         # rendered panel must include the JS initializer
         self.assertIn("var panel = new InlinePanel({", result)
 
+        # rendered panel must have data-contentpath-disabled attribute by default
+        self.assertIn("data-contentpath-disabled", result)
+
+        # check that attr option renders the data-controller attribute
+        self.assertIn('data-controller="test"', result)
+
     def test_render_with_panel_overrides(self):
         """
         Check that inline panel renders the panels listed in the InlinePanel definition