Repository: wagtail/wagtail

Issue:
Title/Slug autosync no longer works for non-Page models in 5.x
### Issue Summary

For snippets, using fields title and slug together previously (i.e. <5.0) enabled the default autocomplete feature on the slug field.

After reading the release notes, with the note that if you're just using title/slug, [no changes should be made](https://docs.wagtail.org/en/stable/releases/5.0.html#title-field-auto-sync-with-the-slug-field-on-pages-now-relies-on-data-attributes), the autosync is not working without creating a base form to make the needed changes.

### Steps to Reproduce

Example model (autosync working pre-5.x):

```python
@register_snippet
class Category(models.Model):
    title = models.CharField(max_length=100, verbose_name=_("Category Name"))
    slug = models.SlugField(
        max_length=100,
        help_text=_("How the category will appear in URL"),
        unique=True, 
        allow_unicode=True,
    )

    panels = [
        FieldPanel("title"),
        FieldPanel("slug"),
    ]
```

To workaround, I have needed to change the panels as follows and add a baseform definition to add missing title field data attributes:

```python
class CategoryForm(WagtailAdminModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['title'].widget.attrs['data-controller'] = 'w-sync'
        self.fields['title'].widget.attrs['data-action'] = 'focus->w-sync#check blur->w-sync#apply change->w-sync#apply'
        self.fields['title'].widget.attrs['data-w-sync-target-value'] = '#id_slug'
        
@register_snippet
class Category(models.Model):
    title = models.CharField(max_length=100, verbose_name=_("Category Name"))
    slug = models.SlugField(
        max_length=100,
        help_text=_("How the category will appear in URL"),
        unique=True,
        allow_unicode=True,
    )

    base_form_class = CategoryForm

    panels = [
        FieldPanel("title", classname="title"),
        FieldPanel("slug", widget=SlugInput),
    ]

    class Meta:
        abstract = True

    def __str__(self):
        return self.title

    class Meta:
        verbose_name = _("Blog Category")
        verbose_name_plural = _("Blog Categories")
        ordering = ["title"]
```

This workaround overcomes the issue, though only on title losing focus.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: (yes)

### Technical details

-   Python version: 3.10.6
-   Django version: 4.2.1
-   Wagtail version: 5.0.1
-   Browser version: Chrome 114



Discussion:
Thanks for the report @enzedonline - I worked with that migration and title/slug combos outside of pages was not tested. The release notes probably could have been clearer that Page title/slug sync would work the same. 

**Update: The docs are pretty clear that this behaviour was for Pages only.** 

Looks like you have a work around, I'll check in with others on the core team for whether we want to officially continue to support this. It probably worked by accident previously as the code was based on their being an element with the id of the two fields, as in if your fields were named differently it would not have worked. 

PR with changes. https://github.com/wagtail/wagtail/pull/10189
@enzedonline in regards to the 'only changes when focus is lost', I'm pretty sure that's how it used to work. 

However, if you want the slug field to changes as you type, you can try the following. 

```
self.fields['title'].widget.attrs['data-action'] = 'focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply'
```

Observe the `keyup` added to the action, this will trigger the sync as you type. 
Thanks @lb

Yes, apologies, should have mentioned the docs were only discussing the Page model.

It's an extremely useful feature, considering how many models require a slug field.

It was possible to add the following line to a base admin form to set the autosync source, providing there wasn't already a 'title' field:
        `self.fields['name'].widget.attrs['id'] = 'id_title'`

I've just double-checked a 3.x site, the previous behaviour was on keyup (or possibly input?).

Thanks for the suggestion, this works well.
Ahh yes, I can see the previous logic was more frequent, I think we adjusted for performance and the use case where the slug field is on a different tab. 

```
$('#id_title').on('keyup keydown keypress blur', function () {
```

I recall some of that being needed for how IE11 handled copy/paste into the field. 

I'll get some guidance on this one from the core team also, maybe it's worth trying to fix the frequency of the updates in isolation. 
Just for reference, this should be doable without having to override the form, you can pass your own `widget` into the `FieldPanel` and then add `attrs` this way.

Double check code, should be close enough.

```python
@register_snippet
class Category(models.Model):
    title = models.CharField(max_length=100, verbose_name=_("Category Name"))
    slug = models.SlugField(
        max_length=100,
        help_text=_("How the category will appear in URL"),
        unique=True, 
        allow_unicode=True,
    )

    panels = [
        FieldPanel("title", widget=TextInput(attrs={
            "data-controller": "w-sync",
            "data-action": "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
            "data-w-sync-target-value": "[name='slug']",
        })),
        FieldPanel("slug"),
    ]
```
Ok, thanks for that, that works well but needs , `widget=SlugInput` on the slug panel to slugify the sync'd text. Surprised that's not the default widget for SlugField.
After a bit of discussion with some of the core team, here is the proposed approach.

## 1. Official support for non-Page model slug/title sync (enhancement)

* Yes we want to be able to officially support something like a `SyncTextInput` widget. This would abstract the current approach for the title field, similar to what we are doing for the `SlugField` (except without the global widget default approach, which I think we just do for Page models).
* Those using Wagtail should be able to pass in a kwarg for the name/s of the fields they can sync with.
* This will not work by default by magically including title/slug fields in non-Page models but would be easily added by opting in for the correct widgets.
* This can be worked around right now (as per the discussion on this thread), but hopefully is not a huge change to bring into an upcoming release.
* There is a related issue #161 (which is more about other fields being able to sync their value with the title field), building out this feature may let us provide a JS only way to support that long-standing enhancement request.

## 2. Re-add `keyup` title sync (regression)

* For the built in title sync behaviour we should adopt the previous approach for updating the sync target field as the user types (keyup event).
* Not just as the user moves away from the field, this is technically a regression as it would have been an expected behaviour if someone had the Slug field showing on the same tab as the Title field.



Resolving patch:
diff --git a/wagtail/admin/panels/__init__.py b/wagtail/admin/panels/__init__.py
--- a/wagtail/admin/panels/__init__.py
+++ b/wagtail/admin/panels/__init__.py
@@ -18,3 +18,4 @@
 from .page_utils import *  # NOQA: F403
 from .publishing_panel import *  # NOQA: F403
 from .signal_handlers import *  # NOQA: F403
+from .title_field_panel import *  # NOQA: F403
diff --git a/wagtail/admin/panels/page_utils.py b/wagtail/admin/panels/page_utils.py
--- a/wagtail/admin/panels/page_utils.py
+++ b/wagtail/admin/panels/page_utils.py
@@ -1,6 +1,4 @@
-from django import forms
 from django.conf import settings
-from django.utils.text import format_lazy
 from django.utils.translation import gettext_lazy
 
 from wagtail.admin.forms.pages import WagtailAdminPageForm
@@ -12,25 +10,12 @@
 from .field_panel import FieldPanel
 from .group import MultiFieldPanel, ObjectList, TabbedInterface
 from .publishing_panel import PublishingPanel
+from .title_field_panel import TitleFieldPanel
 
 
 def set_default_page_edit_handlers(cls):
     cls.content_panels = [
-        FieldPanel(
-            "title",
-            classname="title",
-            widget=forms.TextInput(
-                attrs={
-                    "placeholder": format_lazy(
-                        "{title}*", title=gettext_lazy("Page title")
-                    ),
-                    "data-controller": "w-sync",
-                    "data-action": "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
-                    # ensure that if the page is live, the slug field does not receive updates from changes to the title field
-                    "data-w-sync-target-value": "body:not(.page-is-live) [data-edit-form] #id_slug",
-                }
-            ),
-        ),
+        TitleFieldPanel("title"),
     ]
 
     cls.promote_panels = [
diff --git a/wagtail/admin/panels/title_field_panel.py b/wagtail/admin/panels/title_field_panel.py
new file mode 100644
--- /dev/null
+++ b/wagtail/admin/panels/title_field_panel.py
@@ -0,0 +1,129 @@
+from django.utils.text import format_lazy
+from django.utils.translation import gettext_lazy
+
+from wagtail.models import Page
+
+from .field_panel import FieldPanel
+
+
+class TitleFieldPanel(FieldPanel):
+    """
+    Prepares the default widget attributes that are used on Page title fields.
+    Can be used outside of pages to easily enable the slug field sync functionality.
+
+    :param apply_if_live: (optional) If ``True``, the built in slug sync behaviour will apply irrespective of the published state.
+        The default is ``False``, where the slug sync will only apply when the instance is not live (or does not have a live property).
+    :param classname: (optional) A CSS class name to add to the panel's HTML element. Default is ``"title"``.
+    :param placeholder: (optional) If a value is provided, it will be used as the field's placeholder, if ``False`` is provided no placeholder will be shown.
+        If ``True``, a placeholder value of ``"Title*"`` will be used or ``"Page Title*"`` if the model is a ``Page`` model.
+        The default is ``True``. If a widget is provided with a placeholder, the widget's value will be used instead.
+    :param targets: (optional) This allows you to override the default target of the field named `slug` on the form.
+        Accepts a list of field names, default is ``["slug"]``.
+        Note that the slugify/urlify behaviour relies on usage of the ``wagtail.admin.widgets.slug`` widget on the slug field.
+    """
+
+    def __init__(
+        self,
+        *args,
+        apply_if_live=False,
+        classname="title",
+        placeholder=True,
+        targets=["slug"],
+        **kwargs,
+    ):
+        kwargs["classname"] = classname
+        self.apply_if_live = apply_if_live
+        self.placeholder = placeholder
+        self.targets = targets
+        super().__init__(*args, **kwargs)
+
+    def clone_kwargs(self):
+        return {
+            **super().clone_kwargs(),
+            "apply_if_live": self.apply_if_live,
+            "placeholder": self.placeholder,
+            "targets": self.targets,
+        }
+
+    class BoundPanel(FieldPanel.BoundPanel):
+
+        apply_actions = [
+            "focus->w-sync#check",
+            "blur->w-sync#apply",
+            "change->w-sync#apply",
+            "keyup->w-sync#apply",
+        ]
+
+        def get_context_data(self, parent_context=None):
+            field = self.bound_field.field
+            if field and not self.read_only:
+                field.widget.attrs.update(**self.get_attrs())
+            return super().get_context_data(parent_context)
+
+        def get_attrs(self):
+            """
+            Generates a dict of widget attributes to be updated on the widget
+            before rendering.
+            """
+
+            panel = self.panel
+            widget = self.bound_field.field.widget
+
+            attrs = {}
+
+            controllers = [widget.attrs.get("data-controller", None), "w-sync"]
+            attrs["data-controller"] = " ".join(filter(None, controllers))
+
+            if self.get_should_apply():
+                actions = [widget.attrs.get("data-action", None)] + self.apply_actions
+                attrs["data-action"] = " ".join(filter(None, actions))
+
+            targets = [self.get_target_selector(target) for target in panel.targets]
+            attrs["data-w-sync-target-value"] = ", ".join(filter(None, targets))
+
+            placeholder = self.get_placeholder()
+            if placeholder and "placeholder" not in widget.attrs:
+                attrs["placeholder"] = placeholder
+
+            return attrs
+
+        def get_placeholder(self):
+            """
+            If placeholder is falsey, return None. Otherwise allow a valid placeholder
+            to be resolved.
+            """
+            placeholder = self.panel.placeholder
+
+            if not placeholder:
+                return None
+
+            if placeholder is True:
+                title = gettext_lazy("Title")
+
+                if issubclass(self.panel.model, Page):
+                    title = gettext_lazy("Page title")
+
+                return format_lazy("{title}*", title=title)
+
+            return placeholder
+
+        def get_should_apply(self):
+            """
+            Check that the title field should apply the sync with the target fields.
+            """
+            if self.panel.apply_if_live:
+                return True
+
+            instance = self.instance
+            if not instance:
+                return True
+
+            is_live = instance.pk and getattr(instance, "live", False)
+            return not is_live
+
+        def get_target_selector(self, target):
+            """
+            Prepare a selector for an individual target field.
+            """
+            field = self.form[target]
+            return f"#{field.id_for_label}"


Test patch:
diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py
--- a/wagtail/admin/tests/pages/test_create_page.py
+++ b/wagtail/admin/tests/pages/test_create_page.py
@@ -2,6 +2,7 @@
 import unittest
 from unittest import mock
 
+from bs4 import BeautifulSoup
 from django.contrib.auth.models import Group, Permission
 from django.http import HttpRequest, HttpResponse
 from django.test import TestCase
@@ -974,6 +975,39 @@ def test_long_slug(self):
             "Ensure this value has at most 255 characters (it has 287).",
         )
 
+    def test_title_field_attrs(self):
+        """
+        Should correctly add the sync field and placeholder attributes to the title field.
+        Note: Many test Page models use a FieldPanel for 'title', StandardChild does not
+        override content_panels (uses the default).
+        """
+
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "standardchild", self.root_page.id),
+            )
+        )
+
+        html = BeautifulSoup(response.content, "html5lib")
+
+        actual_attrs = html.find("input", {"name": "title"}).attrs
+
+        expected_attrs = {
+            "aria-describedby": "panel-child-content-child-title-helptext",
+            "data-action": "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+            "data-controller": "w-sync",
+            "data-w-sync-target-value": "#id_slug",
+            "id": "id_title",
+            "maxlength": "255",
+            "name": "title",
+            "placeholder": "Page title*",
+            "required": "",
+            "type": "text",
+        }
+
+        self.assertEqual(actual_attrs, expected_attrs)
+
     def test_before_create_page_hook(self):
         def hook_func(request, parent_page, page_class):
             self.assertIsInstance(request, HttpRequest)
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
@@ -3,6 +3,7 @@
 from typing import Any, List, Mapping, Optional
 from unittest import mock
 
+from bs4 import BeautifulSoup
 from django import forms
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -27,6 +28,7 @@
     Panel,
     PublishingPanel,
     TabbedInterface,
+    TitleFieldPanel,
     extract_panel_definitions_from_model_class,
     get_form_for_model,
 )
@@ -43,6 +45,7 @@
 from wagtail.models import Comment, CommentReply, Page, Site
 from wagtail.test.testapp.forms import ValidatedPageForm
 from wagtail.test.testapp.models import (
+    Advert,
     EventPage,
     EventPageChooserModel,
     EventPageSpeaker,
@@ -2063,3 +2066,320 @@ def test_override_misc_panel_icon(self):
             with self.subTest(panel_type=type(panel)):
                 self.assertEqual(bound_panel.icon, expected_icon)
                 self.assertIn(f"#icon-{expected_icon}", html)
+
+
+class TestTitleFieldPanel(WagtailTestUtils, TestCase):
+    fixtures = ["test.json"]
+
+    def setUp(self):
+        self.user = self.login()
+        self.request = get_dummy_request()
+        self.request.user = self.user
+
+    def get_edit_handler_html(
+        self,
+        edit_handler,
+        model=EventPage,
+        instance=None,
+    ):
+        edit_handler = edit_handler.bind_to_model(model)
+        form_class = edit_handler.get_form_class()
+        bound_edit_handler = edit_handler.get_bound_panel(
+            request=self.request,
+            form=form_class(),
+            instance=instance,
+        )
+        html = bound_edit_handler.render_form_content()
+        return BeautifulSoup(html, "html5lib")
+
+    @clear_edit_handler(Page)
+    def test_default_page_content_panels_uses_title_field(self):
+        edit_handler = Page.get_edit_handler()
+        first_inner_panel_child = edit_handler.children[0].children[0]
+        self.assertTrue(isinstance(first_inner_panel_child, TitleFieldPanel))
+
+    def test_default_title_field_panel(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title"), FieldPanel("slug")])
+        )
+
+        # check default classname is used
+        self.assertIsNotNone(html.find(attrs={"class": "w-panel title"}))
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["placeholder"], "Page title*")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_slug")
+        self.assertEqual(
+            attrs["data-action"],
+            "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+        )
+
+    def test_not_using_apply_actions_if_live(self):
+        """
+        If the Page (or any model) has `live = True`, do not apply the actions by default.
+        Allow this to be overridden though.
+        """
+
+        event_live = EventPage.objects.get(slug="christmas")
+
+        self.assertEqual(event_live.live, True)
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title"), FieldPanel("slug")]),
+            instance=event_live,
+        )
+
+        self.assertIsNone(html.find("input").attrs.get("data-action"))
+
+        # allow to be overridden
+
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", apply_if_live=True), FieldPanel("slug")]
+            ),
+            instance=event_live,
+        )
+
+        self.assertIsNotNone(html.find("input").attrs.get("data-action"))
+
+    def test_using_apply_actions_if_non_page_model(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("text", targets=["url"]), FieldPanel("url")]),
+            model=Advert,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_url")
+        self.assertIsNotNone(attrs["data-action"])
+
+    def test_using_apply_actions_if_non_page_model_with_live_property(self):
+        """
+        Check for instance being live should be agnostic to how that is implemented.
+        """
+
+        advert_live = Advert(text="Free sheepdog", url="https://example.com", id=5000)
+        advert_live.live = True
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("text", targets=["url"]), FieldPanel("url")]),
+            model=Advert,
+            instance=advert_live,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_url")
+        self.assertIsNone(attrs.get("data-action"))
+
+        # apply_if_live should work the same when apply_if_live is True
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "text",
+                        targets=["url"],
+                        apply_if_live=True,
+                    ),
+                    FieldPanel("url"),
+                ]
+            ),
+            model=Advert,
+            instance=advert_live,
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertIsNotNone(attrs.get("data-action"))
+
+    def test_targets_override_with_empty(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", targets=[]), FieldPanel("slug")]),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-w-sync-target-value"], "")
+
+    def test_targets_override_with_non_slug_field(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("location", targets=["title"]), FieldPanel("title")]
+            ),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_title")
+
+    def test_targets_override_with_multiple_fields(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel("title", targets=["cost", "location"]),
+                    FieldPanel("cost"),
+                    FieldPanel("location"),
+                ]
+            ),
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_cost, #id_location")
+
+    def test_classname_override(self):
+
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", classname="super-title"), FieldPanel("slug")]
+            )
+        )
+
+        # check default classname is not used
+        self.assertIsNone(html.find(attrs={"class": "w-panel title"}))
+
+        # check custom one is used
+        self.assertIsNotNone(html.find(attrs={"class": "w-panel super-title"}))
+
+    def test_merging_data_attrs(self):
+        widget = forms.TextInput(
+            attrs={
+                "data-controller": "w-clean",
+                "data-action": "w-clean#clean blur->w-clean#clean",
+                "data-w-clean-filters-value": "trim upper",
+                "data-w-sync-target-value": ".will-be-ignored",
+            }
+        )
+
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", widget=widget), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        # data-controller should be merged
+        self.assertEqual(attrs["data-controller"], "w-clean w-sync")
+
+        # data-action should be merged
+        self.assertEqual(
+            attrs["data-action"],
+            " ".join(
+                [
+                    "w-clean#clean blur->w-clean#clean",
+                    "focus->w-sync#check blur->w-sync#apply change->w-sync#apply keyup->w-sync#apply",
+                ]
+            ),
+        )
+
+        # "data-w-sync-target-value" should be ignored if supplied in widget attrs
+        self.assertEqual(attrs["data-w-sync-target-value"], "#id_slug")
+
+        # other data attributes should be appended
+        self.assertEqual(attrs["data-w-clean-filters-value"], "trim upper")
+
+    def test_placeholder_override_false(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [TitleFieldPanel("title", placeholder=False), FieldPanel("slug")]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_none(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", placeholder=None), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_empty_string(self):
+        html = self.get_edit_handler_html(
+            ObjectList([TitleFieldPanel("title", placeholder=""), FieldPanel("slug")])
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertNotIn("placeholder", attrs)
+
+    def test_placeholder_override_via_widget(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "My custom placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "My custom placeholder")
+
+    def test_placeholder_override_via_widget_over_kwarg(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        placeholder="PANEL placeholder",
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "WIDGET placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "WIDGET placeholder")
+
+    def test_placeholder_override_via_widget_over_false_kwarg(self):
+        html = self.get_edit_handler_html(
+            ObjectList(
+                [
+                    TitleFieldPanel(
+                        "title",
+                        placeholder=False,
+                        widget=forms.TextInput(
+                            attrs={"placeholder": "WIDGET placeholder"}
+                        ),
+                    ),
+                    FieldPanel("slug"),
+                ]
+            )
+        )
+
+        attrs = html.find("input").attrs
+
+        self.assertEqual(attrs["name"], "title")
+        self.assertEqual(attrs["data-controller"], "w-sync")
+        self.assertEqual(attrs["placeholder"], "WIDGET placeholder")