Repository: wagtail/wagtail
Pull request #12952: Cleanup Stimulus controller imports, JSDoc & linting
Author: login: lb-
Labels: name: type:Cleanup/Optimisation
description: Code quality improvements, refactoring, performance optimization, tech debt reduction
name: component:Stimulus
description: Stimulus.js framework integration, JavaScript controllers, reactive components
Created: 2025-03-08T07:18:06Z | Merged: 2025-03-13T10:54:36Z

* Fix ordering of imports for Stimulus controller index
* Update JSDoc spelling to be aligned with US English
* Fix JSDoc `@example` usage to have the description within the same line, use the `html` markdown blocks & have consistent indentation
* Add some JSDoc `@example` entries to controllers that did not have them, the only one without an example now is the PreviewController
* Avoid repeated declarations of ignoring ESLint `no-param-reassign`, instead bring to the top of file if it's suitable as a global ignore, only for specific properties though. We do this in a few other controllers and it helps make the code cleaner while also ensuring our linting alignment.


Diff:
diff --git a/client/src/controllers/ActionController.ts b/client/src/controllers/ActionController.ts
index 6e5cf002f471..c579a73228b5 100644
--- a/client/src/controllers/ActionController.ts
+++ b/client/src/controllers/ActionController.ts
@@ -7,50 +7,61 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
  * triggering a form submission where the form is created dynamically
  * in the DOM and then submitted.
  *
- * @example - triggering a click
+ * @example - Triggering a click
+ * ```html
  * <button
- *  type="button"
- *  data-controller="w-action"
- *  data-action="some-event#click"
+ *   type="button"
+ *   data-controller="w-action"
+ *   data-action="some-event#click"
  * >
- *  Go
+ *   Go
  * </button>
+ * ```
  *
- * @example - triggering a dynamic POST submission
+ * @example - Triggering a dynamic POST submission
+ * ```html
  * <button
- *  type="submit"
- *  data-controller="w-action"
- *  data-action="w-action#post"
- *  data-w-action-url-value='url/to/post/to'
+ *   type="submit"
+ *   data-controller="w-action"
+ *   data-action="w-action#post"
+ *   data-w-action-url-value='url/to/post/to'
  * >
  *  Enable
  * </button>
+ * ```
  *
- * @example - triggering a POST request via sendBeacon
+ * @example - Triggering a POST request via sendBeacon
+ * ```html
  * <button data-controller="w-action" data-action="blur->w-action#sendBeacon">
- *  If you move focus away from this button, a POST request will be sent.
+ *   If you move focus away from this button, a POST request will be sent.
  * </button>
+ * ```
  *
- * @example - triggering a dynamic redirect
- * // note: a link is preferred normally
+ * @example - Triggering a dynamic redirect (a link is normally preferred)
+ * ```html
  * <form>
  *   <select name="url" data-controller="w-action" data-action="change->w-action#redirect">
  *     <option value="/path/to/1">1</option>
  *     <option value="/path/to/2">2</option>
  *   </select>
  * </form>
+ * ```
  *
- * @example - triggering selection of the text in a field
+ * @example - Triggering selection of the text in a field
+ * ```html
  * <form>
  *   <textarea name="url" data-controller="w-action" data-action="click->w-action#select">
  *     This text will all be selected on focus.
  *   </textarea>
  * </form>
+ * ```
  *
- * @example - ensuring a button's click does not propagate
+ * @example - Ensuring a button's click does not propagate
+ * ```html
  * <div>
  *   <button type="button" data-controller="w-action" data-action="w-action#noop:stop">Go</button>
  * </div>
+ * ```
  */
 export class ActionController extends Controller<
   HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement
diff --git a/client/src/controllers/AutosizeController.ts b/client/src/controllers/AutosizeController.ts
index d63289361278..c9e404763f15 100644
--- a/client/src/controllers/AutosizeController.ts
+++ b/client/src/controllers/AutosizeController.ts
@@ -7,7 +7,9 @@ import { debounce } from '../utils/debounce';
  * types in the field so that it expands to show all content.
  *
  * @example
+ * ```html
  * <textarea data-controller="w-autosize"></textarea>
+ * ```
  */
 export class AutosizeController extends Controller<HTMLTextAreaElement> {
   resizeObserver?: ResizeObserver;
diff --git a/client/src/controllers/BlockController.ts b/client/src/controllers/BlockController.ts
index a240acf382e5..7c9d3cd864e6 100644
--- a/client/src/controllers/BlockController.ts
+++ b/client/src/controllers/BlockController.ts
@@ -12,21 +12,25 @@ declare global {
  * Used to initialize the top-level element of a BlockWidget (the form widget for a StreamField).
  *
  * @example
+ * ```html
  * <div
- *  id="some-id"
- *  data-controller="w-block"
- *  data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
+ *   id="some-id"
+ *   data-controller="w-block"
+ *   data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
  * >
  * </div>
+ * ```
  *
- * @example - with initial arguments
+ * @example - With initial arguments
+ * ```html
  * <div
- *  id="some-id"
- *  data-controller="w-block"
- *  data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
- *  data-w-block-arguments-value='[[{ type: "paragraph_block", value: "..."}], {messages:["An error..."]}]'
+ *   id="some-id"
+ *   data-controller="w-block"
+ *   data-w-block-data-value='{"_args":["..."], "_type": "wagtail.blocks.StreamBlock"}'
+ *   data-w-block-arguments-value='[[{ type: "paragraph_block", value: "..."}], {messages:["An error..."]}]'
  * >
  * </div>
+ * ```
  */
 export class BlockController extends Controller<HTMLElement> {
   static values = {
diff --git a/client/src/controllers/BulkController.ts b/client/src/controllers/BulkController.ts
index 15746512488e..86eda40bd244 100644
--- a/client/src/controllers/BulkController.ts
+++ b/client/src/controllers/BulkController.ts
@@ -1,3 +1,5 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["checked"] }] */
+
 import { Controller } from '@hotwired/stimulus';
 
 type ToggleOptions = {
@@ -14,6 +16,7 @@ type ToggleAllOptions = ToggleOptions & {
  * Adds the ability to collectively toggle a set of (non-disabled) checkboxes.
  *
  * @example - Basic usage
+ * ```html
  * <div data-controller="w-bulk">
  *   <input type="checkbox" data-action="w-bulk#toggleAll" data-w-bulk-target="all">
  *   <div>
@@ -26,6 +29,7 @@ type ToggleAllOptions = ToggleOptions & {
  * </div>
  *
  * @example - Showing and hiding an actions container
+ * ```html
  * <div data-controller="w-bulk" data-w-bulk-action-inactive-class="w-invisible">
  *   <div class="w-invisible" data-w-bulk-target="action" id="inner-actions">
  *     <button type="button">Some action</button>
@@ -37,8 +41,10 @@ type ToggleAllOptions = ToggleOptions & {
  *     <input data-action="w-bulk#toggle" data-w-bulk-target="item" type="checkbox" />
  *   </div>
  * </div>
+ * ```
  *
  * @example - Using groups to allow toggles to be controlled separately or together
+ * ```html
  * <table data-controller="w-bulk">
  *   <thead>
  *     <tr>
@@ -74,7 +80,7 @@ type ToggleAllOptions = ToggleOptions & {
  *     </td>
  *    </tfoot>
  * </table>
- *
+ * ```
  */
 export class BulkController extends Controller<HTMLElement> {
   static classes = ['actionInactive'];
@@ -179,7 +185,6 @@ export class BulkController extends Controller<HTMLElement> {
 
       activeItems.forEach((target, index) => {
         if (index >= start && index <= end) {
-          // eslint-disable-next-line no-param-reassign
           target.checked = !!activeItems[lastClickedIndex].checked;
           this.dispatch('change', { target, bubbles: true });
         }
@@ -194,7 +199,6 @@ export class BulkController extends Controller<HTMLElement> {
     const isAllChecked = totalCheckedItems === activeItems.length;
 
     this.getValidTargets(group, this.allTargets).forEach((target) => {
-      // eslint-disable-next-line no-param-reassign
       target.checked = isAllChecked;
     });
 
@@ -238,7 +242,6 @@ export class BulkController extends Controller<HTMLElement> {
 
     this.getValidTargets(group).forEach((target) => {
       if (target.checked !== isChecked) {
-        // eslint-disable-next-line no-param-reassign
         target.checked = isChecked;
         target.dispatchEvent(new Event('change', { bubbles: true }));
       }
diff --git a/client/src/controllers/ClipboardController.ts b/client/src/controllers/ClipboardController.ts
index 42aac3c8c8af..b3e41b43bf36 100644
--- a/client/src/controllers/ClipboardController.ts
+++ b/client/src/controllers/ClipboardController.ts
@@ -11,8 +11,8 @@ type CopyOptions = {
  * @example
  * ```html
  * <div data-controller="w-clipboard">
- *  <input type="text" value="Hello World" data-w-clipboard-target="value" />
- *  <button type="button" data-action="w-clipboard#copy">Copy</button>
+ *   <input type="text" value="Hello World" data-w-clipboard-target="value" />
+ *   <button type="button" data-action="w-clipboard#copy">Copy</button>
  * </div>
  * ```
  */
diff --git a/client/src/controllers/CloneController.ts b/client/src/controllers/CloneController.ts
index b3af9d537ef3..649bf4711465 100644
--- a/client/src/controllers/CloneController.ts
+++ b/client/src/controllers/CloneController.ts
@@ -19,6 +19,7 @@ type AddOptions = {
  * Additionally, it will allow for clearing all previously added elements.
  *
  * @example - Using with the w-messages identifier
+ * ```html
  * <div
  *   data-controller="w-messages"
  *   data-action="w-messages:add@document->w-messages#add"
@@ -28,8 +29,9 @@ type AddOptions = {
  *   <ul data-w-messages-target="container"></ul>
  *   <template data-w-messages-target="template">
  *     <li data-message-status="error-or-success"><span></span></li>
- *  </template>
+ *   </template>
  * </div>
+ * ```
  *
  * @example - Using to show a temporary element with auto-clearing
  * ```html
@@ -39,12 +41,12 @@ type AddOptions = {
  *  data-w-clone-auto-clear-value="5_000"
  * >
  *   <div data-w-clone-target="container"></div>
- *   <template data-w-clone-target="template">
- *     <p>Page has loaded, this will be removed in 5 seconds.</p>
- *   </template>
+ *     <template data-w-clone-target="template">
+ *       <p>Page has loaded, this will be removed in 5 seconds.</p>
+ *     </template>
  *   </div>
  * </div>
- *
+ * ```
  */
 export class CloneController extends Controller<HTMLElement> {
   static classes = ['added', 'hide', 'show'];
diff --git a/client/src/controllers/CountController.ts b/client/src/controllers/CountController.ts
index b643b81fd1d4..e49ae90efa52 100644
--- a/client/src/controllers/CountController.ts
+++ b/client/src/controllers/CountController.ts
@@ -9,10 +9,12 @@ const DEFAULT_ERROR_SELECTOR = '.error-message,.help-critical';
  * to `body.`
  *
  * @example
+ * ```html
  * <div data-controller="w-count">
- *  <span data-w-count-target="label"></span>
- *  <span class="error-message">An error</span>
+ *   <span data-w-count-target="label"></span>
+ *   <span class="error-message">An error</span>
  * </div>
+ * ```
  */
 export class CountController extends Controller<HTMLFormElement> {
   static classes = ['active'];
@@ -29,7 +31,7 @@ export class CountController extends Controller<HTMLFormElement> {
   declare containerValue: string;
   /** selector string, used to find the elements to count within the container */
   declare findValue: string;
-  /** override pluralisation strings, e.g. `data-w-count-labels-value='["One item","Many items"]'` */
+  /** override pluralization strings, e.g. `data-w-count-labels-value='["One item","Many items"]'` */
   declare labelsValue: string[];
   /** minimum value, anything equal or below will trigger blank labels in the UI */
   declare minValue: number;
diff --git a/client/src/controllers/DialogController.ts b/client/src/controllers/DialogController.ts
index e73c6085f713..98c4e7a1ede5 100644
--- a/client/src/controllers/DialogController.ts
+++ b/client/src/controllers/DialogController.ts
@@ -9,12 +9,14 @@ const FLOATING = 'floating';
  * scroll when the dialog is open.
  *
  * @example
+ * ```html
  * <div
- *    data-controller="w-dialog"
- *    data-w-dialog-theme-value="floating"
+ *   data-controller="w-dialog"
+ *   data-w-dialog-theme-value="floating"
  *   >
  *    <div data-w-dialog-target="body"></div>
- *  </div>
+ * </div>
+ * ```
  */
 export class DialogController extends Controller<HTMLElement> {
   static values = {
diff --git a/client/src/controllers/DismissibleController.ts b/client/src/controllers/DismissibleController.ts
index ddbb7b5406ce..bb8af8409fca 100644
--- a/client/src/controllers/DismissibleController.ts
+++ b/client/src/controllers/DismissibleController.ts
@@ -3,7 +3,7 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
 
 /**
  * Updates the server, using a PATCH request when the toggle is clicked on a dismissible
- * element initialised by DismissibleController
+ * element initialized by DismissibleController
  *
  * @param data - The dismissible represented as an object with keys as
  * the id and its new state: whether it is dismissed (boolean)
@@ -29,18 +29,20 @@ export const updateDismissibles = (
 
 /**
  * Adds the ability to make an element dismissible so that it updates it's class and makes an async request.
- * Initialise such elements with a default handler that performs the dismissal.
- * This only initialises elements that are rendered by the server (if they have the data attr), so elements
+ * Initialize such elements with a default handler that performs the dismissal.
+ * This only initializes elements that are rendered by the server (if they have the data attr), so elements
  * that are rendered by the client (e.g. React) needs to be handled separately.
  *
  * @example
+ * ```html
  * <section
  *  data-controller="w-dismissible"
  *  data-w-dismissible-dismissed-class="w-dismissible--dismissed"
  *  data-w-dismissible-id-value="Whats new in Wagtail"
  * >
- *  <button type="button" data-action="w-dismiss#dismissible">Close</button>
+ *   <button type="button" data-action="w-dismiss#dismissible">Close</button>
  * </section>
+ * ```
  */
 export class DismissibleController extends Controller<HTMLElement> {
   static classes = ['dismissed'];
diff --git a/client/src/controllers/DrilldownController.ts b/client/src/controllers/DrilldownController.ts
index 10181d7a3771..a69bd9669a97 100644
--- a/client/src/controllers/DrilldownController.ts
+++ b/client/src/controllers/DrilldownController.ts
@@ -1,3 +1,5 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["hidden","textContent"] }] */
+
 import { Controller } from '@hotwired/stimulus';
 
 import type { ActionController } from './ActionController';
@@ -99,9 +101,7 @@ export class DrilldownController extends Controller<HTMLElement> {
       const count = name
         ? document.querySelectorAll(`[${this.countAttrValue}=${name}]`).length
         : total;
-      // eslint-disable-next-line no-param-reassign
       countTarget.hidden = count === 0;
-      // eslint-disable-next-line no-param-reassign
       countTarget.textContent = count.toString();
     });
   }
diff --git a/client/src/controllers/DropdownController.ts b/client/src/controllers/DropdownController.ts
index cde873b870db..39b4680b3ca4 100644
--- a/client/src/controllers/DropdownController.ts
+++ b/client/src/controllers/DropdownController.ts
@@ -86,10 +86,12 @@ type TippyTheme = 'dropdown' | 'drilldown' | 'dropdown-button';
  * A Tippy.js tooltip with interactive "dropdown" options.
  *
  * @example
+ * ```html
  * <div data-controller="w-dropdown" data-w-dropdown-hide-on-click-value-"true">
- *  <button type="button" data-w-dropdown-target="toggle" aria-label="Actions"></button>
- *  <div data-w-dropdown-target="content">[…]</div>
+ *   <button type="button" data-w-dropdown-target="toggle" aria-label="Actions"></button>
+ *   <div data-w-dropdown-target="content">[…]</div>
  * </div>
+ * ```
  */
 export class DropdownController extends Controller<HTMLElement> {
   static targets = ['toggle', 'content'];
diff --git a/client/src/controllers/FormsetController.ts b/client/src/controllers/FormsetController.ts
index 8a85b251fa94..14f4a33c2434 100644
--- a/client/src/controllers/FormsetController.ts
+++ b/client/src/controllers/FormsetController.ts
@@ -10,30 +10,30 @@ import { runInlineScripts } from '../utils/runInlineScripts';
  * @example
  * ```html
  * <form data-controller="w-formset">
- *  <input type="hidden" name="form-TOTAL_FORMS" value="2" data-w-formset-target="totalFormsInput">
- *  <input type="hidden" name="form-MIN_NUM_FORMS" value="0" data-w-formset-target="minFormsInput">
- *  <input type="hidden" name="form-MAX_NUM_FORMS" value="50" data-w-formset-target="maxFormsInput">
- *  <input type="hidden" name="form-INITIAL_FORMS" value="2">
- *  <ul data-w-formset-target="forms">
- *    <li data-w-formset-target="child">
- *      <input type="text" name="form-0-name">
- *      <input type="hidden" name="form-0-DELETE" data-w-formset-target="deleteInput">
- *      <button type="button" data-action="w-formset#delete">Delete</button>
- *    </li>
- *    <li data-w-formset-target="child">
- *      <input type="text" name="form-1-name">
- *      <input type="hidden" name="form-1-DELETE" data-w-formset-target="deleteInput">
- *      <button type="button" data-action="w-formset#delete">Delete</button>
- *    </li>
- *  </ul>
- *  <button type="button" data-action="w-formset#add">Add</button>
- *  <template data-w-formset-target="template">
- *    <li data-w-formset-target="child">
- *      <input type="text" name="form-__prefix__-name">
- *      <input type="hidden" name="form-__prefix__-DELETE" data-w-formset-target="deleteInput">
- *      <button type="button" data-action="w-formset#delete">Delete</button>
- *    </li>
- *  </template>
+ *   <input type="hidden" name="form-TOTAL_FORMS" value="2" data-w-formset-target="totalFormsInput">
+ *   <input type="hidden" name="form-MIN_NUM_FORMS" value="0" data-w-formset-target="minFormsInput">
+ *   <input type="hidden" name="form-MAX_NUM_FORMS" value="50" data-w-formset-target="maxFormsInput">
+ *   <input type="hidden" name="form-INITIAL_FORMS" value="2">
+ *   <ul data-w-formset-target="forms">
+ *     <li data-w-formset-target="child">
+ *       <input type="text" name="form-0-name">
+ *       <input type="hidden" name="form-0-DELETE" data-w-formset-target="deleteInput">
+ *       <button type="button" data-action="w-formset#delete">Delete</button>
+ *     </li>
+ *     <li data-w-formset-target="child">
+ *       <input type="text" name="form-1-name">
+ *       <input type="hidden" name="form-1-DELETE" data-w-formset-target="deleteInput">
+ *       <button type="button" data-action="w-formset#delete">Delete</button>
+ *     </li>
+ *   </ul>
+ *   <button type="button" data-action="w-formset#add">Add</button>
+ *   <template data-w-formset-target="template">
+ *     <li data-w-formset-target="child">
+ *       <input type="text" name="form-__prefix__-name">
+ *       <input type="hidden" name="form-__prefix__-DELETE" data-w-formset-target="deleteInput">
+ *       <button type="button" data-action="w-formset#delete">Delete</button>
+ *     </li>
+ *   </template>
  * </form>
  * ```
  */
diff --git a/client/src/controllers/InitController.ts b/client/src/controllers/InitController.ts
index 0eee5d295f8c..7fb958676bb5 100644
--- a/client/src/controllers/InitController.ts
+++ b/client/src/controllers/InitController.ts
@@ -6,19 +6,25 @@ import { debounce } from '../utils/debounce';
  * add or remove classes when ready to be interacted with.
  *
  * @example - Dynamic classes when ready
+ * ```html
  * <div class="keep-me hide-me" data-controller="w-init" data-w-init-remove-class="hide-me" data-w-init-ready-class="loaded">
  *   When the DOM is ready, this div will have the class 'loaded' added and 'hide-me' removed.
  * </div>
+ * ```
  *
  * @example - Custom event dispatching
+ * ```html
  * <div class="keep-me hide-me" data-controller="w-init" data-w-init-event-value="custom:event other-custom:event">
  *   When the DOM is ready, two additional custom events will be dispatched; `custom:event` and `other-custom:event`.
  * </div>
+ * ```
  *
  * @example - Detail dispatching
+ * ```html
  * <article data-controller="w-init" data-w-init-detail-value='{"status": "success", "message": "Article has entered the room"}'>
  *  When the DOM is ready, the detail with value of a JSON object above will be dispatched.
  * </article>
+ * ```
  */
 export class InitController extends Controller<HTMLElement> {
   static classes = ['ready', 'remove'];
diff --git a/client/src/controllers/LocaleController.ts b/client/src/controllers/LocaleController.ts
index 887e2b77c757..369c93234713 100644
--- a/client/src/controllers/LocaleController.ts
+++ b/client/src/controllers/LocaleController.ts
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
 
 /**
  * Localizes elements in the current locale.
+ *
+ * @example
+ * ```html
+ * <select data-controller="w-locale" data-action="w-locale#localizeTimeZoneOptions">
+ *   <option value="" selected>Use server time zone</option>
+ *   <option value="Asia/Jakarta">Asia/Jakarta</option>
+ *   <option value="Asia/Tokyo">Asia/Tokyo</option>
+ * </select>
+ * ```
  */
 export class LocaleController extends Controller<HTMLSelectElement> {
   /**
diff --git a/client/src/controllers/OrderableController.ts b/client/src/controllers/OrderableController.ts
index 94ede99322bf..4ffa05510f49 100644
--- a/client/src/controllers/OrderableController.ts
+++ b/client/src/controllers/OrderableController.ts
@@ -12,6 +12,15 @@ enum Direction {
  *
  * Once re-ordering is completed an async request will be made to the
  * provided URL to submit the update per item.
+ *
+ * @example
+ * ```html
+ * <fieldset data-controller="w-orderable" data-w-orderable-url-value="/path/to/orderable/">
+ *   <input type="button" data-w-orderable-target="item" data-w-orderable-item-id="1" value="Item 1"/>
+ *   <input type="button" data-w-orderable-target="item" data-w-orderable-item-id="2" value="Item 2"/>
+ *   <input type="button" data-w-orderable-target="item" data-w-orderable-item-id="3" value="Item 3"/>
+ * </fieldset>
+ * ```
  */
 export class OrderableController extends Controller<HTMLElement> {
   static classes = ['active', 'chosen', 'drag', 'ghost'];
diff --git a/client/src/controllers/PreviewController.ts b/client/src/controllers/PreviewController.ts
index 4e14db2af7d7..5c44ad4559f7 100644
--- a/client/src/controllers/PreviewController.ts
+++ b/client/src/controllers/PreviewController.ts
@@ -557,7 +557,7 @@ export class PreviewController extends Controller<HTMLElement> {
    * @returns whether the data is valid
    */
   async checkAndUpdatePreview() {
-    // Small performance optimisation: the hasChanges() method will not be called
+    // Small performance optimization: the hasChanges() method will not be called
     // if there is a pending update due to the || operator short-circuiting
     if (this.updatePromise || !this.hasChanges()) return undefined;
     return this.setPreviewData();
diff --git a/client/src/controllers/ProgressController.ts b/client/src/controllers/ProgressController.ts
index 6165432e83ee..22317d616ff1 100644
--- a/client/src/controllers/ProgressController.ts
+++ b/client/src/controllers/ProgressController.ts
@@ -7,6 +7,7 @@ const DEFAULT_CLASS = 'button-longrunning';
  * until the duration has elapsed. Will also update the button's label while in progress.
  *
  * @example
+ * ```html
  * <button
  *   type="submit"
  *   class="button button-longrunning"
@@ -16,9 +17,10 @@ const DEFAULT_CLASS = 'button-longrunning';
  *   data-w-progress-duration-seconds-value="40"
  *   data-action="w-progress#activate"
  * >
- *  {% icon name="spinner" %}
- *  <em data-w-progress-target="label">{% trans 'Sign in' %}</em>
+ *   {% icon name="spinner" %}
+ *   <em data-w-progress-target="label">{% trans 'Sign in' %}</em>
  * </button>
+ * ```
  */
 export class ProgressController extends Controller<HTMLButtonElement> {
   static classes = ['active'];
diff --git a/client/src/controllers/RevealController.ts b/client/src/controllers/RevealController.ts
index 8d74c50d2447..71d015c2fdb6 100644
--- a/client/src/controllers/RevealController.ts
+++ b/client/src/controllers/RevealController.ts
@@ -1,3 +1,5 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["hidden"] }] */
+
 import { Controller } from '@hotwired/stimulus';
 
 /**
@@ -8,10 +10,12 @@ import { Controller } from '@hotwired/stimulus';
  * @see https://w3c.github.io/aria/#aria-expanded
  *
  * @example
+ * ```html
  * <section data-controller="w-reveal">
- *  <button type="button" data-action="w-reveal#toggle" data-w-reveal-target="toggle" aria-controls="my-content" type="button">Toggle</button>
- *  <div id="my-content">CONTENT</div>
+ *   <button type="button" data-action="w-reveal#toggle" data-w-reveal-target="toggle" aria-controls="my-content" type="button">Toggle</button>
+ *   <div id="my-content">CONTENT</div>
  * </section>
+ * ```
  */
 export class RevealController extends Controller<HTMLElement> {
   static classes = [
@@ -106,7 +110,6 @@ export class RevealController extends Controller<HTMLElement> {
       });
       contentTargets.forEach((content) => {
         content.classList.remove(...openedContentClasses);
-        // eslint-disable-next-line no-param-reassign
         content.hidden = true;
       });
       this.element.classList.add(...closedClasses);
@@ -120,7 +123,7 @@ export class RevealController extends Controller<HTMLElement> {
       });
       contentTargets.forEach((content) => {
         content.classList.add(...openedContentClasses);
-        content.hidden = false; // eslint-disable-line no-param-reassign
+        content.hidden = false;
       });
       this.element.classList.remove(...closedClasses);
       this.element.classList.add(...openedClasses);
diff --git a/client/src/controllers/SlugController.ts b/client/src/controllers/SlugController.ts
index 3d1eccac18a7..ad387af67685 100644
--- a/client/src/controllers/SlugController.ts
+++ b/client/src/controllers/SlugController.ts
@@ -8,7 +8,9 @@ type SlugMethods = 'slugify' | 'urlify';
  * Adds ability to slugify the value of an input element.
  *
  * @example
+ * ```html
  * <input type="text" name="slug" data-controller="w-slug" data-action="blur->w-slug#slugify" />
+ * ```
  */
 export class SlugController extends Controller<HTMLInputElement> {
   static values = {
@@ -79,7 +81,7 @@ export class SlugController extends Controller<HTMLInputElement> {
    * or can be used to simply return the transformed value.
    *
    * The urlify (Django port) function performs extra processing on the string &
-   * is more suitable for creating a slug from the title, rather than sanitising manually.
+   * is more suitable for creating a slug from the title, rather than sanitizing manually.
    * If the urlify util returns an empty string it will fall back to the slugify method.
    *
    * If a custom event with detail.value is provided, that value will be used
diff --git a/client/src/controllers/SubmitController.ts b/client/src/controllers/SubmitController.ts
index 36cf8164970b..c67471df657e 100644
--- a/client/src/controllers/SubmitController.ts
+++ b/client/src/controllers/SubmitController.ts
@@ -3,14 +3,15 @@ import { Controller } from '@hotwired/stimulus';
 /**
  * Adds the ability for a field to trigger an automatic submission of its attached form.
  *
- * @example
- * // once any change is made to the below select field, the form will be auto submitted
+ * @example - Once any change is made to the below select field, the form will be auto submitted
+ * ```html
  * <form>
  *   <select name="order" data-controller="w-submit" data-action="change->w-submit#submit">
  *     <option value="A-Z">A to Z</option>
  *     <option value="Z-A">Z to A</option>
  *   </select>
  * </form>
+ * ```
  */
 export class SubmitController extends Controller<
   HTMLInputElement | HTMLSelectElement
diff --git a/client/src/controllers/SwapController.ts b/client/src/controllers/SwapController.ts
index 0085295093c9..39d68837aaef 100644
--- a/client/src/controllers/SwapController.ts
+++ b/client/src/controllers/SwapController.ts
@@ -12,41 +12,46 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
  * values.
  *
  * @example - A form that will update the results based on the form's input
- *  <div id="results"></div>
- *  <form
- *    data-controller="w-swap"
- *    data-action="input->w-swap#submitLazy"
- *    data-w-swap-src-value="path/to/search"
- *    data-w-swap-target-value="#results"
- *  >
- *  <input id="search" type="text" name="query" />
- *  <input id="filter" type="text" name="filter" />
+ * ```html
+ * <div id="results"></div>
+ * <form
+ *   data-controller="w-swap"
+ *   data-action="input->w-swap#submitLazy"
+ *   data-w-swap-src-value="path/to/search"
+ *   data-w-swap-target-value="#results"
+ * >
+ *   <input id="search" type="text" name="query" />
+ *   <input id="filter" type="text" name="filter" />
  * </form>
+ * ```
  *
  * @example - A single input that will update the results & the URL
- *  <div id="results"></div>
- *  <input
- *    id="search"
- *    type="text"
- *    name="q"
- *    data-controller="w-swap"
- *    data-action="input->w-swap#searchLazy"
- *    data-w-swap-src-value="path/to/search"
- *    data-w-swap-target-value="#listing-results"
- *  />
+ * ```html
+ * <div id="results"></div>
+ * <input
+ *   id="search"
+ *   type="text"
+ *   name="q"
+ *   data-controller="w-swap"
+ *   data-action="input->w-swap#searchLazy"
+ *   data-w-swap-src-value="path/to/search"
+ *   data-w-swap-target-value="#listing-results"
+ * />
+ * ```
  *
  * @example - A single button that will update the results
- *  <div id="results"></div>
- *  <button
- *    id="clear"
- *    data-controller="w-swap"
- *    data-action="input->w-swap#replaceLazy"
- *    data-w-swap-src-value="path/to/results/?type=bar"
- *    data-w-swap-target-value="#results"
- *  >
- *    Clear owner filter
- *  </button>
- *
+ * ```html
+ * <div id="results"></div>
+ * <button
+ *   id="clear"
+ *   data-controller="w-swap"
+ *   data-action="input->w-swap#replaceLazy"
+ *   data-w-swap-src-value="path/to/results/?type=bar"
+ *   data-w-swap-target-value="#results"
+ * >
+ *   Clear owner filter
+ * </button>
+ * ```
  */
 export class SwapController extends Controller<
   HTMLFormElement | HTMLInputElement | HTMLButtonElement
@@ -91,7 +96,7 @@ export class SwapController extends Controller<
   replaceLazy?: { (...args: any[]): void; cancel(): void };
   /** Debounced function to search results and then replace the DOM */
   searchLazy?: { (...args: any[]): void; cancel(): void };
-  /** Debounced function to submit the serialised form and then replace the DOM */
+  /** Debounced function to submit the serialized form and then replace the DOM */
   submitLazy?: { (...args: any[]): void; cancel(): void };
   /** A function that writes the HTML to the target */
   writeDeferred?: () => Promise<string>;
@@ -227,7 +232,7 @@ export class SwapController extends Controller<
 
   /**
    * Update the target element's content with the response from a request based on the input's form
-   * values serialised. Do not account for anything in the main location/URL, simply replace the content within
+   * values serialized. Do not account for anything in the main location/URL, simply replace the content within
    * the target element.
    */
   submit() {
@@ -235,7 +240,7 @@ export class SwapController extends Controller<
     let data: FormData | undefined = new FormData(form);
 
     let url = this.srcValue;
-    // serialise the form to a query string if it's a GET request
+    // serialize the form to a query string if it's a GET request
     if (form.getAttribute('method')?.toUpperCase() === 'GET') {
       // cast as any to avoid https://github.com/microsoft/TypeScript/issues/43797
       url += '?' + new URLSearchParams(data as any).toString();
diff --git a/client/src/controllers/SyncController.ts b/client/src/controllers/SyncController.ts
index 779ab505f623..16e3a2effeb4 100644
--- a/client/src/controllers/SyncController.ts
+++ b/client/src/controllers/SyncController.ts
@@ -7,6 +7,7 @@ import { debounce } from '../utils/debounce';
  * or more targeted other inputs.
  *
  * @example
+ * ```html
  * <section>
  *   <input type="text" name="title" id="title" />
  *   <input
@@ -19,6 +20,7 @@ import { debounce } from '../utils/debounce';
  *     data-w-sync-target-value="#title"
  *   />
  * </section>
+ * ```
  */
 export class SyncController extends Controller<HTMLInputElement> {
   static values = {
diff --git a/client/src/controllers/TagController.ts b/client/src/controllers/TagController.ts
index 68b010c58416..11bdfd2d8265 100644
--- a/client/src/controllers/TagController.ts
+++ b/client/src/controllers/TagController.ts
@@ -15,10 +15,14 @@ declare global {
  * See https://github.com/aehlke/tag-it
  *
  * @example
+ * ```html
  * <input id="id_tags" type="text" name="tags" data-controller="w-tag" data-w-tag-url-value="/admin/tag-autocomplete/" />
+ * ```
  *
- * @example - with delay
+ * @example - With delay
+ * ```html
  * <input id="id_tags" type="text" name="tags" data-controller="w-tag" data-w-tag-delay-value="300" data-w-tag-url-value="/admin/tag-autocomplete/" />
+ * ```
  */
 export class TagController extends Controller {
   static values = {
diff --git a/client/src/controllers/TeleportController.ts b/client/src/controllers/TeleportController.ts
index a9b478dfd4e7..f61cecc2df12 100644
--- a/client/src/controllers/TeleportController.ts
+++ b/client/src/controllers/TeleportController.ts
@@ -12,15 +12,17 @@ import { runInlineScripts } from '../utils/runInlineScripts';
  * Depending on location of the controlled element.
  *
  * @example
+ * ```html
  * <aside>
  *   <template
- *    data-controller="w-teleport"
- *    data-w-teleport-target-value="#other-location"
+ *     data-controller="w-teleport"
+ *     data-w-teleport-target-value="#other-location"
  *   >
- *    <div class="content-to-clone">Some content</div>
+ *     <div class="content-to-clone">Some content</div>
  *   </template>
  *   <div id="other-location"></div>
  * </aside>
+ * ```
  */
 export class TeleportController extends Controller<HTMLTemplateElement> {
   static values = {
diff --git a/client/src/controllers/TooltipController.ts b/client/src/controllers/TooltipController.ts
index 6ae255138b76..7eaa2c92e4e9 100644
--- a/client/src/controllers/TooltipController.ts
+++ b/client/src/controllers/TooltipController.ts
@@ -30,9 +30,11 @@ export const hideTooltipOnEsc = {
  * A Tippy.js tooltip with simple popover content.
  *
  * @example
+ * ```html
  * <button type="button" data-controller="w-tooltip" data-w-tooltip-content-value="More detail here">
- *  A button with a tooltip
+ *   A button with a tooltip
  * </button>
+ * ```
  */
 export class TooltipController extends Controller<HTMLElement> {
   static values = {
diff --git a/client/src/controllers/UnsavedController.ts b/client/src/controllers/UnsavedController.ts
index 4d5832a221a3..63d860419b33 100644
--- a/client/src/controllers/UnsavedController.ts
+++ b/client/src/controllers/UnsavedController.ts
@@ -21,6 +21,7 @@ const DEFAULT_DURATIONS = {
  * are about to move away from the page with potentially unsaved changes.
  *
  * @example - Warn the user when there are unsaved edits
+ * ```html
  * <form
  *   data-controller="w-unsaved"
  *   data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
@@ -29,8 +30,10 @@ const DEFAULT_DURATIONS = {
  *   <input type="text" value="something" />
  *   <button>Submit</submit>
  * </form>
+ * ```
  *
  * @example - Watch comments for changes in addition to edits (default is edits only)
+ * ```html
  * <form
  *   data-controller="w-unsaved"
  *   data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
@@ -40,8 +43,10 @@ const DEFAULT_DURATIONS = {
  *   <input type="text" value="something" />
  *   <button>Submit</submit>
  * </form>
+ * ```
  *
  * @example - Force the confirmation dialog
+ * ```html
  * <form
  *   data-controller="w-unsaved"
  *   data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
@@ -51,8 +56,10 @@ const DEFAULT_DURATIONS = {
  *   <input type="text" value="something" />
  *   <button>Submit</submit>
  * </form>
+ * ```
  *
  * @example - Force the confirmation dialog without watching for edits/comments
+ * ```html
  * <form
  *   data-controller="w-unsaved"
  *   data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm"
@@ -63,6 +70,7 @@ const DEFAULT_DURATIONS = {
  *   <input type="text" value="something" />
  *   <button>Submit</submit>
  * </form>
+ * ```
  */
 export class UnsavedController extends Controller<HTMLFormElement> {
   static values = {
diff --git a/client/src/controllers/UpgradeController.ts b/client/src/controllers/UpgradeController.ts
index 487dd0640a2c..2d0087996d4e 100644
--- a/client/src/controllers/UpgradeController.ts
+++ b/client/src/controllers/UpgradeController.ts
@@ -17,7 +17,7 @@ interface LatestVersionData extends VersionData {
  * is out of date.
  *
  * Expected JSON payload:
- *
+ * ```json
  * {
  *     "version": "2.15.2",
  *     "url":     "https://docs.wagtail.io/en/stable/releases/2.15.2.html",
@@ -28,6 +28,18 @@ interface LatestVersionData extends VersionData {
  *         "minorUrl": "https://docs.wagtail.io/en/stable/releases/2.12.html"
  *     }
  * }
+ * ```
+ *
+ * @example
+ * ```html
+ * <div
+ *   data-controller="w-upgrade"
+ *   data-w-upgrade-current-version-value="6.3.1"
+ *   data-w-upgrade-url-value="https://path.to/latest.txt"
+ * >
+ *   <p>A new version of Wagtail is available!</p>
+ * </div>
+ * ```
  */
 export class UpgradeController extends Controller<HTMLElement> {
   static targets = ['latestVersion', 'link', 'dismiss'];
diff --git a/client/src/controllers/ZoneController.ts b/client/src/controllers/ZoneController.ts
index 2ee7edc8af59..afe2af0ebe2e 100644
--- a/client/src/controllers/ZoneController.ts
+++ b/client/src/controllers/ZoneController.ts
@@ -30,7 +30,7 @@ enum ZoneMode {
  *   data-w-zone-active-class="public"
  *   data-w-zone-inactive-class="private"
  *   data-w-zone-switch-key-value="isPublic"
- *>
+ * >
  *  Content
  * </div>
  * ```
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index fbb72093a2cf..66b8cbdb4466 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -15,6 +15,7 @@ import { DropdownController } from './DropdownController';
 import { FormsetController } from './FormsetController';
 import { InitController } from './InitController';
 import { KeyboardController } from './KeyboardController';
+import { LocaleController } from './LocaleController';
 import { OrderableController } from './OrderableController';
 import { PreviewController } from './PreviewController';
 import { ProgressController } from './ProgressController';
@@ -32,7 +33,6 @@ import { TooltipController } from './TooltipController';
 import { UnsavedController } from './UnsavedController';
 import { UpgradeController } from './UpgradeController';
 import { ZoneController } from './ZoneController';
-import { LocaleController } from './LocaleController';
 
 /**
  * Important: Only add default core controllers that should load with the base admin JS bundle.


Reviews:
user: login: laymonage
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T14:51:25Z
user: login: laymonage
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T14:51:55Z
user: login: lb-
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T19:51:21Z
user: login: lb-
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T20:05:14Z
user: login: laymonage
body: Thank you!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T10:53:04Z

Review comments:
diff_hunk: @@ -8,49 +8,60 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
  * in the DOM and then submitted.
  *
  * @example - triggering a click
path: client/src/controllers/ActionController.ts
user: login: laymonage
body: This line still uses the lowercase `t`
created_at: 2025-03-12T14:23:25Z
author_association: MEMBER
diff_hunk: @@ -1,3 +1,5 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["hidden","textContent"] }] */
path: client/src/controllers/DrilldownController.ts
user: login: laymonage
body: This is not meant towards you at all, but can I just say how much I absolutely _hate_ dealing with `no-param-reassign` when manipulating DOM objects in an array? 😂 

It makes no sense in our use cases, so we end up having to ignore it every time. I can see the rule being useful for mutable objects that you may not want to modify as a side effect of your function. But for iterating through DOM objects and modifying their properties, that's exactly what you want to do.

From looking at the changes (and my experience), the main reason is because we use `.forEach()`, and the linter thinks the iterated element is a function parameter (true, but not in the sense that we care about). This won't happen if we use `for .. of` syntax or even plain `for (;;)` loop.

Can we maybe revisit the `no-restricted-syntax` rule in our ESLint config, which bans the `for .. of` syntax? I know it comes from [AirBnB's](https://github.com/airbnb/javascript/blob/11f986fdc7d6b4c80e396437e9c45c939362bdee/packages/eslint-config-airbnb-base/rules/style.js#L338-L358), but they still care about transpiling to ES5 which isn't the case for us, especially not in 2025.
created_at: 2025-03-12T14:47:44Z
author_association: MEMBER
diff_hunk: @@ -15,6 +15,7 @@ import { DropdownController } from './DropdownController';
 import { FormsetController } from './FormsetController';
 import { InitController } from './InitController';
 import { KeyboardController } from './KeyboardController';
+import { LocaleController } from './LocaleController';
path: client/src/controllers/index.ts
user: login: laymonage
body: Thanks!
created_at: 2025-03-12T14:51:52Z
author_association: MEMBER
diff_hunk: @@ -8,49 +8,60 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
  * in the DOM and then submitted.
  *
  * @example - triggering a click
path: client/src/controllers/ActionController.ts
user: login: lb-
body: Fixed, thank you.
created_at: 2025-03-12T19:51:21Z
author_association: MEMBER
diff_hunk: @@ -1,3 +1,5 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["hidden","textContent"] }] */
path: client/src/controllers/DrilldownController.ts
user: login: lb-
body: This makes sense to me, I have raised an issue on the Eslint config - could you add any extra thoughts there.
https://github.com/wagtail/eslint-config-wagtail/issues/27

Maybe chat with @thibaudcolas  also, from there I am happy to action that change on that repo and do some smoke testing against Wagtail's code.

I would rather we solve that as an isolated task end to end though.

Also, if you have a min for a small review - I would love to update that code to the latest Node :) https://github.com/wagtail/eslint-config-wagtail/pull/26
created_at: 2025-03-12T20:05:14Z
author_association: MEMBER

PR comments:
user: login: squash-labs[bot]
created_at: 2025-03-08T08:01:17Z
body: <h2>Manage this branch in <a href="https://app.squash.io/accounts/">Squash</a></h2>Test this branch here: <a href='https://cleanupstimulus-controller-mis-w6p4f.squash.io' target='_blank'>https://cleanupstimulus-controller-mis-w6p4f.squash.io</a><br/>
author_association: NONE
user: login: lb-
created_at: 2025-03-13T20:36:29Z
body: Thanks, it's nice to see us all emerging on some common practice here. 
author_association: MEMBER