Repository: wagtail/wagtail

Issue:
🎛️ Migrate `initSlugAutoPopulate` to a Stimulus Controller `w-sync`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

There is a core JavaScript implementation that allows for syncing the slug field with the title field as the user updates the title field. This implementation is quite rigid and it would be good to move this to a Stimulus controller, giving us an ability to provide more flexibility and have this functionality unit tested.

### Describe the solution you'd like

Implementation of a `w-sync` controller that allows the controlled element to 'sync' its value to some other input. It should allow for one or more elements to be selected via a selector string and then those elements become the target. Additionally, it should have the ability to turn on/off this sync behaviour based on a check.

#### Example usage

Given the HTML below, the 'title' field will be able to sync its value with the 'slug' field in the following ways;

* When the title field is focused, it will perform a check against the value of the slug field, if they are **not** the same it will enter the 'inactive' mode (a value) that means further apply/clear methods will do nothing. This is optional but allows for a value to sync only when they are not made different by user interaction.
* Assuming the values are different on 'check', when the title field changes, it will apply its value to the slug field's value.
* Assuming the values are different on 'check', when the title field receives the 'cut' event, it will completely clear the value on the slug field.
* Finally, the apply/clear methods can be debounced by the delay value (500ms), this value will default to 0 seconds

```html
<div>
  <input
    type="text"
    name="title"
    value="Anadama"
    placeholder="Page title*"
    maxlength="255"
    id="id_title"
    data-controller="w-sync"
    data-action="focus->w-sync#check change->w-sync#apply cut->w-sync#clear"
    data-w-sync-delay-value="500"
    data-w-sync-target-value="body:not(.page-is-live) #id_slug"
  />
  <input id="id_slug" name="slug" type="text" />
</div>

```

#### Part 1 - Create a `w-sync` Stimulus controller

This controller should be written in TypeScript and have a baseline of unit tests, it should have the following specification

* The controller should have the type of `HTMLInputElement`
* value: `inactive` or `inert` or maybe just `disabled`, boolean, default is false (see check method for usage)
* value: `delay`, used for the debounced wrapper around `clear` & `apply` methods.
* value: `target`, a string, no default or empty string (ok to error if not set), expects a CSS style selector
* method: `connect` - dispatches an event `start` on each of the target elements, if preventDefault is called, the controller will disconnect itself. Event dispatch must not bubble.
* method: `getTargetElements` - used as a convenience wrapper around the selection of a target elements from the target value. Very important that this uses `document.querySelectorAll` so that multiple targets are supported. This also makes the code simpler for typing.
* method: `check` - dispatches an event `check` on each of the target elements, if ANY of the events get their default prevented, then the controller goes into inactive mode (`this.disabledValue = true`). Must dispatch with a reference to the `this.element` in the event detail (e.g. `const event = this.dispatchEvent('check', { bubbles: false, cancellable: true, detail: { element: this.element, value: this.element.value } });`.
* method: `apply` - first dispatches an event, similar to `check` but with the name `apply` and if default is prevented does nothing else, if not it will update the target elements' values
* method: `clear` - similar to apply, but instead of applying a value, it just resets it to an empty string.
* method: `disconnect` - should clear any debounced timers and also dispatch an event `stop` to the target elements.

**Start small** Build this in small steps, driven by unit test writing, the `clear` and also the start/stop events can be done last as they are not critical to the core functionality and are nice to have.

#### Part 2 - Adopt the sync field in the Page title form field

In `wagtail/admin/panels/page_utils.py` add the relevant data attributes, example below.

```python
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-w-sync-target-value": "body:not(.page-is-live) #id_slug",
                    "data-action": "focus->w-sync#check blur->w-sync#update change->w-sync#update",
                }
            ),
        ),
    ]
#...
```

The goal here is to ensure that the sync field is basically doing something, firing events and also picking up the right element. Note the usage of `page-is-live` in the selector. This is because of the existing behaviour in `client/src/entrypoints/admin/page-editor.js` where the field should not try to sync with the slug if the page is already live. 

There may be a better way to do this page is live logic, but this should be ok for the initial implementation.

#### Part 3 - Ensure the `w-slug` field can support the new usage

Alongside the existing `w-slug` implementation done in #10086 in `wagtail/admin/panels/page_utils.py` we now want to add support for listening to the `w-sync` events. This is where Stimulus gets pretty powerful.

Our goal is the output in the HTML to be something like this...

```html
<input
  type="text"
  name="slug"
  value="anadama-bread"
  maxlength="255"
  required=""
  id="id_slug"
  data-controller="w-slug"
  data-action="blur->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->w-slug#urlify:prevent"
  data-w-slug-mode-param="urlify"
/>
```

Essentially we are saying

* If this element gets an event called 'w-sync:check', then call the `compare` method.
* If this element gets an event called 'w-sync:apply', then call the `urlify` method AND prevent default - this will stop the sync behaviour from updating the field's value.
* When any method is dispatched, call it with the params `{ mode }` - (note: we could skip this data attribute and just make the default `urilify` in the destructuring.

We will need to add two new methods to the `SlugController`;

* `slugify` method should also return the value it would have applied
* `compare` - this will receive an event and check the `value` in its detail, then compare the **resolved** version of that value against the current slug field's value. The way it will compare will be via the `mode` (urlify/slugify). If they are the same then call the event's `preventDetault` - also return the boolean value to the method call.
* `urlify` - essentially does the exact same thing as `cleanForSlug(this.value, true)`

### Part 4 - clean up

We can now validate the behaviour, write some upgrade consideration docs and potentially consider documenting this sync field fully.

We should also use the slug field's max length attribute as the max length value that gets passed into URLify.

Additionally, we should split out the `client/src/utils/text.ts` into three files; urlify, slugify and escapeHtml. Where the URLify function is basically the existing vendor script but outside of vendor.

We may can now remove `unicodeSlugsEnabled` being declared globally in `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` AND add this to the release notes considerations.

Finally, let's remove the global reading in `client/src/utils/text.ts` of `unicodeSlugsEnabled`. This is not ideal and this function can be 'pure' once we remove this.

### Describe alternatives you've considered

* Leave as is, but this means we cannot move away from page-editor.js used everywhere.

### Additional context

* This is part of a larger goal to migrate to Stimulus.
* Must be completed after #10086
* This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936 & #9771
* This functionality should cover almost all of the requirements in #1063 & #161 - but may need to be solved with example documentation. This sync field approach would not support multiple fields as a source into another field but that can probably be split to a new issue if really needed.

🎛️ Migrate `initSlugAutoPopulate` to a Stimulus Controller `w-sync`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

There is a core JavaScript implementation that allows for syncing the slug field with the title field as the user updates the title field. This implementation is quite rigid and it would be good to move this to a Stimulus controller, giving us an ability to provide more flexibility and have this functionality unit tested.

### Describe the solution you'd like

Implementation of a `w-sync` controller that allows the controlled element to 'sync' its value to some other input. It should allow for one or more elements to be selected via a selector string and then those elements become the target. Additionally, it should have the ability to turn on/off this sync behaviour based on a check.

#### Example usage

Given the HTML below, the 'title' field will be able to sync its value with the 'slug' field in the following ways;

* When the title field is focused, it will perform a check against the value of the slug field, if they are **not** the same it will enter the 'inactive' mode (a value) that means further apply/clear methods will do nothing. This is optional but allows for a value to sync only when they are not made different by user interaction.
* Assuming the values are different on 'check', when the title field changes, it will apply its value to the slug field's value.
* Assuming the values are different on 'check', when the title field receives the 'cut' event, it will completely clear the value on the slug field.
* Finally, the apply/clear methods can be debounced by the delay value (500ms), this value will default to 0 seconds

```html
<div>
  <input
    type="text"
    name="title"
    value="Anadama"
    placeholder="Page title*"
    maxlength="255"
    id="id_title"
    data-controller="w-sync"
    data-action="focus->w-sync#check change->w-sync#apply cut->w-sync#clear"
    data-w-sync-delay-value="500"
    data-w-sync-target-value="body:not(.page-is-live) #id_slug"
  />
  <input id="id_slug" name="slug" type="text" />
</div>

```

#### Part 1 - Create a `w-sync` Stimulus controller

This controller should be written in TypeScript and have a baseline of unit tests, it should have the following specification

* The controller should have the type of `HTMLInputElement`
* value: `inactive` or `inert` or maybe just `disabled`, boolean, default is false (see check method for usage)
* value: `delay`, used for the debounced wrapper around `clear` & `apply` methods.
* value: `target`, a string, no default or empty string (ok to error if not set), expects a CSS style selector
* method: `connect` - dispatches an event `start` on each of the target elements, if preventDefault is called, the controller will disconnect itself. Event dispatch must not bubble.
* method: `getTargetElements` - used as a convenience wrapper around the selection of a target elements from the target value. Very important that this uses `document.querySelectorAll` so that multiple targets are supported. This also makes the code simpler for typing.
* method: `check` - dispatches an event `check` on each of the target elements, if ANY of the events get their default prevented, then the controller goes into inactive mode (`this.disabledValue = true`). Must dispatch with a reference to the `this.element` in the event detail (e.g. `const event = this.dispatchEvent('check', { bubbles: false, cancellable: true, detail: { element: this.element, value: this.element.value } });`.
* method: `apply` - first dispatches an event, similar to `check` but with the name `apply` and if default is prevented does nothing else, if not it will update the target elements' values
* method: `clear` - similar to apply, but instead of applying a value, it just resets it to an empty string.
* method: `disconnect` - should clear any debounced timers and also dispatch an event `stop` to the target elements.

**Start small** Build this in small steps, driven by unit test writing, the `clear` and also the start/stop events can be done last as they are not critical to the core functionality and are nice to have.

#### Part 2 - Adopt the sync field in the Page title form field

In `wagtail/admin/panels/page_utils.py` add the relevant data attributes, example below.

```python
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-w-sync-target-value": "body:not(.page-is-live) #id_slug",
                    "data-action": "focus->w-sync#check blur->w-sync#update change->w-sync#update",
                }
            ),
        ),
    ]
#...
```

The goal here is to ensure that the sync field is basically doing something, firing events and also picking up the right element. Note the usage of `page-is-live` in the selector. This is because of the existing behaviour in `client/src/entrypoints/admin/page-editor.js` where the field should not try to sync with the slug if the page is already live. 

There may be a better way to do this page is live logic, but this should be ok for the initial implementation.

#### Part 3 - Ensure the `w-slug` field can support the new usage

Alongside the existing `w-slug` implementation done in #10086 in `wagtail/admin/panels/page_utils.py` we now want to add support for listening to the `w-sync` events. This is where Stimulus gets pretty powerful.

Our goal is the output in the HTML to be something like this...

```html
<input
  type="text"
  name="slug"
  value="anadama-bread"
  maxlength="255"
  required=""
  id="id_slug"
  data-controller="w-slug"
  data-action="blur->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->w-slug#urlify:prevent"
  data-w-slug-mode-param="urlify"
/>
```

Essentially we are saying

* If this element gets an event called 'w-sync:check', then call the `compare` method.
* If this element gets an event called 'w-sync:apply', then call the `urlify` method AND prevent default - this will stop the sync behaviour from updating the field's value.
* When any method is dispatched, call it with the params `{ mode }` - (note: we could skip this data attribute and just make the default `urilify` in the destructuring.

We will need to add two new methods to the `SlugController`;

* `slugify` method should also return the value it would have applied
* `compare` - this will receive an event and check the `value` in its detail, then compare the **resolved** version of that value against the current slug field's value. The way it will compare will be via the `mode` (urlify/slugify). If they are the same then call the event's `preventDetault` - also return the boolean value to the method call.
* `urlify` - essentially does the exact same thing as `cleanForSlug(this.value, true)`

### Part 4 - clean up

We can now validate the behaviour, write some upgrade consideration docs and potentially consider documenting this sync field fully.

We should also use the slug field's max length attribute as the max length value that gets passed into URLify.

Additionally, we should split out the `client/src/utils/text.ts` into three files; urlify, slugify and escapeHtml. Where the URLify function is basically the existing vendor script but outside of vendor.

We may can now remove `unicodeSlugsEnabled` being declared globally in `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` AND add this to the release notes considerations.

Finally, let's remove the global reading in `client/src/utils/text.ts` of `unicodeSlugsEnabled`. This is not ideal and this function can be 'pure' once we remove this.

### Describe alternatives you've considered

* Leave as is, but this means we cannot move away from page-editor.js used everywhere.

### Additional context

* This is part of a larger goal to migrate to Stimulus.
* Must be completed after #10086
* This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936 & #9771
* This functionality should cover almost all of the requirements in #1063 & #161 - but may need to be solved with example documentation. This sync field approach would not support multiple fields as a source into another field but that can probably be split to a new issue if really needed.



Discussion:
Working on this
> ```python
>  "data-action": "focus->w-sync#check blur->w-sync#update change->w-sync#update",
> ```

Is there a mistake here? I thought the action is supposed to be

```
    data-action="focus->w-sync#check change->w-sync#apply cut->w-sync#clear"
````
@Lovelyfin00 been thinking that maybe `w-slug` should be renamed to `w-clean` - it seems like it makes sense for this approach to be used for non-slug field cleaning in the future. 

See https://github.com/wagtail/wagtail/issues/7417 for an example. 

Do you think I should create a new issue for this rename or does it make sense in the scope.of this issue. 
It can be used in other approach I guess. Just that the regex method in the text util might need to be changed based on what it’s been used for. Creating a new issue is ok. Just something simple.
Working on this
> ```python
>  "data-action": "focus->w-sync#check blur->w-sync#update change->w-sync#update",
> ```

Is there a mistake here? I thought the action is supposed to be

```
    data-action="focus->w-sync#check change->w-sync#apply cut->w-sync#clear"
````
@Lovelyfin00 been thinking that maybe `w-slug` should be renamed to `w-clean` - it seems like it makes sense for this approach to be used for non-slug field cleaning in the future. 

See https://github.com/wagtail/wagtail/issues/7417 for an example. 

Do you think I should create a new issue for this rename or does it make sense in the scope.of this issue. 
It can be used in other approach I guess. Just that the regex method in the text util might need to be changed based on what it’s been used for. Creating a new issue is ok. Just something simple.

Resolving patch:
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
@@ -23,7 +23,11 @@ def set_default_page_edit_handlers(cls):
                 attrs={
                     "placeholder": format_lazy(
                         "{title}*", title=gettext_lazy("Page title")
-                    )
+                    ),
+                    "data-controller": "w-sync",
+                    "data-action": "focus->w-sync#check blur->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",
                 }
             ),
         ),
diff --git a/wagtail/admin/widgets/slug.py b/wagtail/admin/widgets/slug.py
--- a/wagtail/admin/widgets/slug.py
+++ b/wagtail/admin/widgets/slug.py
@@ -6,7 +6,7 @@ class SlugInput(widgets.TextInput):
     def __init__(self, attrs=None):
         default_attrs = {
             "data-controller": "w-slug",
-            "data-action": "blur->w-slug#slugify",
+            "data-action": "blur->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->w-slug#urlify:prevent",
             "data-w-slug-allow-unicode-value": getattr(
                 settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True
             ),


Test patch:
diff --git a/client/src/controllers/SlugController.test.js b/client/src/controllers/SlugController.test.js
--- a/client/src/controllers/SlugController.test.js
+++ b/client/src/controllers/SlugController.test.js
@@ -60,3 +60,168 @@ describe('SlugController', () => {
     expect(slugInput.value).toEqual('visiter-toulouse-en-été-2025');
   });
 });
+
+describe('compare behaviour', () => {
+  let application;
+
+  beforeEach(() => {
+    application?.stop();
+
+    document.body.innerHTML = `
+      <input
+        id="id_slug"
+        name="slug"
+        type="text"
+        data-controller="w-slug"
+      />`;
+
+    application = Application.start();
+    application.register('w-slug', SlugController);
+
+    const slugInput = document.querySelector('#id_slug');
+
+    slugInput.dataset.action = [
+      'blur->w-slug#slugify',
+      'custom:event->w-slug#compare',
+    ].join(' ');
+  });
+
+  it('should not prevent default if input has no value', () => {
+    const event = new CustomEvent('custom:event', {
+      detail: { value: 'title alpha' },
+    });
+
+    event.preventDefault = jest.fn();
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(document.getElementById('id_slug').value).toBe('');
+    expect(event.preventDefault).not.toHaveBeenCalled();
+  });
+
+  it('should not prevent default if the values are the same', () => {
+    document.querySelector('#id_slug').setAttribute('value', 'title-alpha');
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: 'title alpha' },
+    });
+
+    event.preventDefault = jest.fn();
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(event.preventDefault).not.toHaveBeenCalled();
+  });
+
+  it('should prevent default if the values are not the same', () => {
+    document.querySelector('#id_slug').setAttribute('value', 'title-alpha');
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: 'title beta' },
+    });
+
+    event.preventDefault = jest.fn();
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(event.preventDefault).toHaveBeenCalled();
+  });
+
+  it('should not prevent default if both values are empty strings', () => {
+    const slugInput = document.querySelector('#id_slug');
+    slugInput.setAttribute('value', '');
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: '' },
+    });
+
+    event.preventDefault = jest.fn();
+
+    slugInput.dispatchEvent(event);
+
+    expect(event.preventDefault).not.toHaveBeenCalled();
+  });
+
+  it('should prevent default if the new value is an empty string but the existing value is not', () => {
+    const slugInput = document.querySelector('#id_slug');
+    slugInput.setAttribute('value', 'existing-value');
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: '' },
+    });
+
+    event.preventDefault = jest.fn();
+
+    slugInput.dispatchEvent(event);
+
+    expect(event.preventDefault).toHaveBeenCalled();
+  });
+});
+
+describe('urlify behaviour', () => {
+  require('../../../wagtail/admin/static_src/wagtailadmin/js/vendor/urlify')
+    .default;
+  let application;
+
+  beforeEach(() => {
+    application?.stop();
+
+    document.body.innerHTML = `
+      <input
+        id="id_slug"
+        name="slug"
+        type="text"
+        data-controller="w-slug"
+      />`;
+
+    application = Application.start();
+    application.register('w-slug', SlugController);
+
+    const slugInput = document.querySelector('#id_slug');
+
+    slugInput.dataset.action = [
+      'blur->w-slug#slugify',
+      'custom:event->w-slug#urlify:prevent',
+    ].join(' ');
+  });
+
+  it('should update slug input value if the values are the same', () => {
+    const slugInput = document.getElementById('id_slug');
+    slugInput.value = 'urlify Testing On edit page  ';
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: 'urlify Testing On edit page' },
+      bubbles: false,
+    });
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(slugInput.value).toBe('urlify-testing-on-edit-page');
+  });
+
+  it('should transform input with special characters to their ASCII equivalent', () => {
+    const slugInput = document.getElementById('id_slug');
+    slugInput.value = 'Some Title with éçà Spaces';
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: 'Some Title with éçà Spaces' },
+    });
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(slugInput.value).toBe('some-title-with-eca-spaces');
+  });
+
+  it('should return an empty string when input contains only special characters', () => {
+    const slugInput = document.getElementById('id_slug');
+    slugInput.value = '$$!@#$%^&*';
+
+    const event = new CustomEvent('custom:event', {
+      detail: { value: '$$!@#$%^&*' },
+    });
+
+    document.getElementById('id_slug').dispatchEvent(event);
+
+    expect(slugInput.value).toBe('');
+  });
+});
diff --git a/client/src/controllers/SyncController.test.js b/client/src/controllers/SyncController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/SyncController.test.js
@@ -0,0 +1,271 @@
+import { Application } from '@hotwired/stimulus';
+import { SyncController } from './SyncController';
+
+jest.useFakeTimers();
+
+describe('SyncController', () => {
+  let application;
+
+  describe('basic sync between two fields', () => {
+    beforeAll(() => {
+      application?.stop();
+
+      document.body.innerHTML = `
+      <section>
+        <input type="text" name="title" id="title" />
+        <input
+          type="date"
+          id="event-date"
+          name="event-date"
+          value="2025-07-22"
+          data-controller="w-sync"
+          data-action="change->w-sync#apply cut->w-sync#clear custom:event->w-sync#ping"
+          data-w-sync-target-value="#title"
+        />
+      </section>`;
+
+      application = Application.start();
+    });
+
+    afterAll(() => {
+      document.body.innerHTML = '';
+      jest.clearAllMocks();
+      jest.clearAllTimers();
+    });
+
+    it('should dispatch a start event on targeted element', () => {
+      const startListener = jest.fn();
+      document
+        .getElementById('title')
+        .addEventListener('w-sync:start', startListener);
+
+      expect(startListener).not.toHaveBeenCalled();
+
+      application.register('w-sync', SyncController);
+
+      expect(startListener).toHaveBeenCalledTimes(1);
+
+      expect(startListener.mock.calls[0][0].detail).toEqual({
+        element: document.getElementById('event-date'),
+        value: '2025-07-22',
+      });
+    });
+
+    it('should allow the sync field to apply its value to the target element', () => {
+      const changeListener = jest.fn();
+      document
+        .getElementById('title')
+        .addEventListener('change', changeListener);
+
+      expect(document.getElementById('title').value).toEqual('');
+      expect(changeListener).not.toHaveBeenCalled();
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+
+      dateInput.value = '2025-05-05';
+      dateInput.dispatchEvent(new Event('change'));
+
+      jest.runAllTimers();
+
+      expect(document.getElementById('title').value).toEqual('2025-05-05');
+      expect(changeListener).toHaveBeenCalledTimes(1);
+    });
+
+    it('should allow for a simple ping against the target field that bubbles', () => {
+      const pingListener = jest.fn();
+      document.addEventListener('w-sync:ping', pingListener);
+
+      expect(pingListener).not.toHaveBeenCalled();
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+
+      dateInput.dispatchEvent(new CustomEvent('custom:event'));
+
+      expect(pingListener).toHaveBeenCalledTimes(1);
+      const event = pingListener.mock.calls[0][0];
+
+      expect(event.target).toEqual(document.getElementById('title'));
+
+      expect(event.detail).toEqual({
+        element: document.getElementById('event-date'),
+        value: '2025-05-05',
+      });
+    });
+
+    it('should allow the sync field to clear the value of the target element', () => {
+      const changeListener = jest.fn();
+      document
+        .getElementById('title')
+        .addEventListener('change', changeListener);
+
+      expect(document.getElementById('title').value).toEqual('2025-05-05');
+      expect(changeListener).not.toHaveBeenCalled();
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+
+      dateInput.dispatchEvent(new Event('cut'));
+
+      jest.runAllTimers();
+
+      expect(document.getElementById('title').value).toEqual('');
+      expect(changeListener).toHaveBeenCalledTimes(1);
+    });
+
+    it('should allow for no change events to be dispatched', () => {
+      const dateInput = document.getElementById('event-date');
+      dateInput.setAttribute('data-w-sync-quiet-value', 'true');
+
+      application.register('w-sync', SyncController);
+
+      dateInput.value = '2025-05-05';
+      dateInput.dispatchEvent(new Event('change'));
+
+      expect(dateInput.getAttribute('data-w-sync-quiet-value')).toBeTruthy();
+      expect(document.getElementById('title').value).toEqual('');
+
+      dateInput.value = '2025-05-05';
+      dateInput.dispatchEvent(new Event('cut'));
+
+      expect(document.getElementById('title').value).toEqual('');
+    });
+  });
+
+  describe('delayed sync between two fields', () => {
+    beforeAll(() => {
+      application?.stop();
+
+      document.body.innerHTML = `
+      <section>
+        <input type="text" name="title" id="title" />
+        <input
+          type="date"
+          id="event-date"
+          name="event-date"
+          value="2025-07-22"
+          data-controller="w-sync"
+          data-action="change->w-sync#apply cut->w-sync#clear"
+          data-w-sync-target-value="#title"
+          data-w-sync-delay-value="500"
+        />
+      </section>`;
+
+      application = Application.start();
+    });
+
+    it('should delay the update on change based on the set value', () => {
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+      dateInput.value = '2025-05-05';
+
+      dateInput.dispatchEvent(new Event('cut'));
+
+      jest.advanceTimersByTime(500);
+
+      expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 500);
+      expect(document.getElementById('title').value).toEqual('');
+
+      jest.runAllTimers();
+    });
+
+    it('should delay the update on apply based on the set value', () => {
+      const changeListener = jest.fn();
+      document
+        .getElementById('title')
+        .addEventListener('change', changeListener);
+
+      expect(document.getElementById('title').value).toEqual('');
+      expect(changeListener).not.toHaveBeenCalled();
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+
+      dateInput.value = '2025-05-05';
+      dateInput.dispatchEvent(new Event('change'));
+
+      jest.advanceTimersByTime(500);
+
+      expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 500);
+      expect(document.getElementById('title').value).toEqual('2025-05-05');
+      expect(changeListener).toHaveBeenCalledTimes(1);
+
+      jest.runAllTimers();
+    });
+  });
+
+  describe('ability for the sync to be disabled between two fields', () => {
+    beforeAll(() => {
+      application?.stop();
+
+      document.body.innerHTML = `
+      <section>
+        <input type="text" name="title" id="title" value="keep me"/>
+        <input
+          type="date"
+          id="event-date"
+          name="event-date"
+          value="2025-07-22"
+          data-controller="w-sync"
+          data-action="change->w-sync#apply cut->w-sync#clear focus->w-sync#check"
+          data-w-sync-target-value="#title"
+        />
+      </section>`;
+
+      application = Application.start();
+    });
+
+    it('should allow for the target element to block syncing at the start', () => {
+      const titleElement = document.getElementById('title');
+
+      expect(titleElement.value).toEqual('keep me');
+
+      titleElement.addEventListener('w-sync:start', (event) => {
+        event.preventDefault();
+      });
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+
+      dateInput.value = '2025-05-05';
+      dateInput.dispatchEvent(new Event('change'));
+
+      jest.runAllTimers();
+
+      expect(titleElement.value).toEqual('keep me');
+      expect(dateInput.getAttribute('data-w-sync-disabled-value')).toBeTruthy();
+    });
+
+    it('should allow for the target element to block syncing with the check approach', () => {
+      const titleElement = document.getElementById('title');
+
+      expect(titleElement.value).toEqual('keep me');
+
+      titleElement.addEventListener('w-sync:check', (event) => {
+        event.preventDefault();
+      });
+
+      application.register('w-sync', SyncController);
+
+      const dateInput = document.getElementById('event-date');
+      dateInput.setAttribute('data-w-sync-disabled-value', '');
+
+      dateInput.value = '2025-05-05';
+
+      dateInput.dispatchEvent(new Event('focus'));
+      dateInput.dispatchEvent(new Event('cut'));
+
+      jest.runAllTimers();
+
+      expect(titleElement.value).toEqual('keep me');
+      expect(dateInput.getAttribute('data-w-sync-disabled-value')).toBeTruthy();
+    });
+  });
+});
diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py
--- a/wagtail/admin/tests/pages/test_edit_page.py
+++ b/wagtail/admin/tests/pages/test_edit_page.py
@@ -1579,7 +1579,7 @@ def test_editor_page_shows_live_url_in_status_when_draft_edits_exist(self):
             reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
         )
 
-        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify" data-w-slug-allow-unicode-value maxlength="255" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" required id="id_slug">'
+        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-allow-unicode-value maxlength="255" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" required id="id_slug">'
         input_field_for_live_slug = '<input type="text" name="slug" value="hello-world" maxlength="255" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" required id="id_slug" />'
 
         # Status Link should be the live page (not revision)
diff --git a/wagtail/admin/tests/test_widgets.py b/wagtail/admin/tests/test_widgets.py
--- a/wagtail/admin/tests/test_widgets.py
+++ b/wagtail/admin/tests/test_widgets.py
@@ -651,7 +651,7 @@ def test_has_data_attr(self):
         html = widget.render("test", None, attrs={"id": "test-id"})
 
         self.assertInHTML(
-            '<input type="text" name="test" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify" data-w-slug-allow-unicode-value id="test-id">',
+            '<input type="text" name="test" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-allow-unicode-value id="test-id">',
             html,
         )