diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 5c31c88c10..3a5083f216 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -37,6 +37,7 @@ Changelog
* Fix: Ensure that gettext_lazy works correctly when using verbose_name on a generic Settings models (Sébastien Corbin)
* Fix: Remove unnecessary usage of `innerHTML` when modifying DOM content (LB (Ben) Johnston)
* Fix: Avoid `ValueError` when extending `PagesAPIViewSet` and setting `meta_fields` to an empty list (Henry Harutyunyan, Alex Morega)
+ * Fix: Improve accessibility for header search, remove autofocus on page load, advise screen readers that content has changed when results update (LB (Ben) Johnston)
* Docs: Document how to add non-ModelAdmin views to a `ModelAdminGroup` (Onno Timmerman)
* Docs: Document how to add StructBlock data to a StreamField (Ramon Wenger)
* Docs: Update ReadTheDocs settings to v2 to resolve urllib3 issue in linkcheck extension (Thibaud Colas)
@@ -70,6 +71,7 @@ Changelog
* Maintenance: Convert the CONTRIBUTORS file to Markdown (Dan Braghis)
* Maintenance: Move `django-filter` version upper bound to v24 (Yuekui)
* Maintenance: Update Pillow dependency to allow 10.x, only include support for >= 9.1.0 (Yuekui)
+ * Maintenance: Migrate header search behaviour to `w-swap`, a Stimulus controller (LB (Ben) Johnston)
5.0.2 (21.06.2023)
diff --git a/client/src/controllers/SwapController.test.js b/client/src/controllers/SwapController.test.js
new file mode 100644
index 0000000000..9d72a06a5f
--- /dev/null
+++ b/client/src/controllers/SwapController.test.js
@@ -0,0 +1,614 @@
+import { Application } from '@hotwired/stimulus';
+import { SwapController } from './SwapController';
+import { range } from '../utils/range';
+
+jest.useFakeTimers();
+
+jest.spyOn(console, 'error').mockImplementation(() => {});
+
+const flushPromises = () => new Promise(setImmediate);
+
+describe('SwapController', () => {
+ let application;
+ let handleError;
+
+ const getMockResults = (
+ { attrs = ['id="new-results"'], total = 3 } = {},
+ arr = range(0, total),
+ ) => {
+ const items = arr.map((_) => `
RESULT ${_}`).join('');
+ return ``;
+ };
+
+ beforeEach(() => {
+ application = Application.start();
+ application.register('w-swap', SwapController);
+ handleError = jest.fn();
+ application.handleError = handleError;
+ });
+
+ afterEach(() => {
+ application.stop();
+ document.body.innerHTML = '';
+ jest.clearAllMocks();
+
+ if (window.headerSearch) {
+ delete window.headerSearch;
+ }
+ });
+
+ describe('when results element & src URL value is not available', () => {
+ it('should throw an error if no valid selector can be resolved', async () => {
+ expect(handleError).not.toHaveBeenCalled();
+
+ document.body.innerHTML = `
+
+ `;
+
+ // trigger next browser render cycle
+ await Promise.resolve();
+
+ expect(handleError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "'' is not a valid selector" }),
+ 'Error connecting controller',
+ expect.objectContaining({ identifier: 'w-swap' }),
+ );
+ });
+
+ it('should throw an error if target element selector cannot resolve a DOM element', async () => {
+ expect(handleError).not.toHaveBeenCalled();
+
+ document.body.innerHTML = `
+
+ `;
+
+ // trigger next browser render cycle
+ await Promise.resolve();
+
+ expect(handleError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Cannot find valid target element at "#resultX"',
+ }),
+ 'Error connecting controller',
+ expect.objectContaining({ identifier: 'w-swap' }),
+ );
+ });
+
+ it('should throw an error if no valid src URL can be resolved', async () => {
+ expect(handleError).not.toHaveBeenCalled();
+
+ document.body.innerHTML = `
+
+ `;
+
+ // trigger next browser render cycle
+ await Promise.resolve();
+
+ expect(handleError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Cannot find valid src URL value' }),
+ 'Error connecting controller',
+ expect.objectContaining({ identifier: 'w-swap' }),
+ );
+ });
+ });
+
+ describe('fallback on window.headerSearch values if not in HTML', () => {
+ it('should set the src & target value from the window.headerSearch if not present', async () => {
+ window.headerSearch = {
+ termInput: '#search',
+ url: 'path/to/page/search',
+ targetOutput: '#page-results',
+ };
+
+ document.body.innerHTML = `
+
+
+ `;
+
+ SwapController.afterLoad('w-swap');
+
+ // trigger next browser render cycle
+ await Promise.resolve();
+
+ // should not error
+ expect(handleError).not.toHaveBeenCalled();
+
+ expect({ ...document.getElementById('form').dataset }).toEqual({
+ action: 'change->w-swap#searchLazy input->w-swap#searchLazy',
+ controller: 'w-swap',
+ wSwapSrcValue: 'path/to/page/search', // set from window.headerSearch
+ wSwapTargetValue: '#page-results', // set from window.headerSearch
+ });
+
+ expect({ ...document.getElementById('search').dataset }).toEqual({
+ wSwapTarget: 'input',
+ });
+ });
+ });
+
+ describe('performing a location update via actions on a controlled input', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ `;
+
+ window.history.replaceState(null, '', '?');
+ });
+
+ it('should not do a location based update if the URL query and the input query are equal', () => {
+ const input = document.getElementById('search');
+
+ // when values are empty
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ jest.runAllTimers(); // update is debounced
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ // when input value only has whitespace
+ input.value = ' ';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ jest.runAllTimers(); // update is debounced
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ // when input value and URL query only have whitespace
+ window.history.replaceState(null, '', '?q=%20%20&p=foo'); // 2 spaces
+ input.value = ' '; // 4 spaces
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ jest.runAllTimers(); // update is debounced
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ // when input value and URL query have the same value
+ window.history.replaceState(null, '', '?q=espresso');
+ input.value = 'espresso';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ jest.runAllTimers(); // update is debounced
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ // when input value and URL query have the same value (ignoring whitespace)
+ window.history.replaceState(null, '', '?q=%20espresso%20');
+ input.value = ' espresso ';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ jest.runAllTimers(); // update is debounced
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it('should allow for updating via a declared action on input changes', async () => {
+ const input = document.getElementById('search');
+ const icon = document.querySelector('.icon-search use');
+ const targetElement = document.getElementById('results');
+
+ const results = getMockResults();
+
+ const onSuccess = new Promise((resolve) => {
+ document.addEventListener('w-swap:success', resolve);
+ });
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect(icon.getAttribute('href')).toEqual('#icon-search');
+ expect(targetElement.getAttribute('aria-busy')).toBeNull();
+
+ input.value = 'alpha';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ jest.runAllTimers(); // update is debounced
+
+ // visual loading state should be active & content busy
+ await Promise.resolve(); // trigger next rendering
+ expect(targetElement.getAttribute('aria-busy')).toEqual('true');
+ expect(icon.getAttribute('href')).toEqual('#icon-spinner');
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/admin/images/results/?q=alpha',
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: '/admin/images/results/?q=alpha',
+ results,
+ });
+
+ // should update HTML
+ expect(targetElement.querySelectorAll('li')).toHaveLength(3);
+
+ await flushPromises();
+
+ // should update the current URL
+ expect(window.location.search).toEqual('?q=alpha');
+
+ // should reset the icon & busy state
+ expect(icon.getAttribute('href')).toEqual('#icon-search');
+ expect(targetElement.getAttribute('aria-busy')).toBeNull();
+ });
+
+ it('should correctly clear any params based on the action param value', async () => {
+ const MOCK_SEARCH = '?k=keep&q=alpha&r=remove-me&s=stay&x=exclude-me';
+ window.history.replaceState(null, '', MOCK_SEARCH);
+ const input = document.getElementById('search');
+
+ // update clear param - check we can handle space separated values
+ input.setAttribute('data-w-swap-clear-param', 'r x');
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(getMockResults()),
+ }),
+ );
+
+ expect(window.location.search).toEqual(MOCK_SEARCH);
+
+ input.value = 'beta';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ // run all timers & promises
+ await flushPromises(jest.runAllTimers());
+
+ // should update the current URL
+ expect(window.location.search).toEqual('?k=keep&q=beta&s=stay');
+ });
+
+ it('should handle both clearing values in the URL and using a custom query param from input', async () => {
+ const MOCK_SEARCH = '?k=keep&query=alpha&r=remove-me';
+ window.history.replaceState(null, '', MOCK_SEARCH);
+ const input = document.getElementById('search');
+ input.setAttribute('name', 'query');
+
+ // update clear param value to a single (non-default) value
+ input.setAttribute('data-w-swap-clear-param', 'r');
+
+ fetch.mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(getMockResults()),
+ }),
+ );
+
+ expect(window.location.search).toEqual(MOCK_SEARCH);
+
+ input.value = 'a new search string!';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ // run all timers & promises
+ await flushPromises(jest.runAllTimers());
+
+ // should update the current URL, removing any cleared params
+ expect(window.location.search).toEqual(
+ '?k=keep&query=a+new+search+string%21',
+ );
+
+ // should clear the location params if the input is updated to an empty value
+ input.value = '';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ // run all timers & promises
+ await flushPromises(jest.runAllTimers());
+
+ // should update the current URL, removing the query param
+ expect(window.location.search).toEqual('?k=keep');
+ });
+
+ it('should handle repeated input and correctly resolve the requested data', async () => {
+ window.history.replaceState(null, '', '?q=first&p=3');
+
+ const input = document.getElementById('search');
+
+ const onSuccess = new Promise((resolve) => {
+ document.addEventListener('w-swap:success', resolve);
+ });
+
+ const delays = [200, 20, 400]; // emulate changing results timings
+
+ fetch.mockImplementation(
+ (query) =>
+ new Promise((resolve) => {
+ const delay = delays.pop();
+ setTimeout(() => {
+ resolve({
+ ok: true,
+ status: 200,
+ text: () =>
+ Promise.resolve(
+ getMockResults({
+ attrs: [
+ 'id="new-results"',
+ `data-query="${query}"`,
+ `data-delay="${delay}"`,
+ ],
+ }),
+ ),
+ });
+ }, delay);
+ }),
+ );
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ input.value = 'alpha';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ setTimeout(() => {
+ input.value = 'beta';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ }, 210);
+
+ setTimeout(() => {
+ input.value = 'delta';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+ }, 420);
+
+ jest.runAllTimers(); // update is debounced
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+ expect(global.fetch).toHaveBeenLastCalledWith(
+ '/admin/images/results/?q=delta',
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: '/admin/images/results/?q=beta',
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ const resultsElement = document.getElementById('results');
+ expect(resultsElement.querySelectorAll('li')).toHaveLength(3);
+ expect(
+ resultsElement.querySelector('[data-query]').dataset.query,
+ ).toEqual('/admin/images/results/?q=delta');
+
+ await flushPromises();
+
+ // should update the current URL & clear the page param
+ expect(window.location.search).toEqual('?q=delta');
+ });
+
+ it('should handle search results API failures gracefully', async () => {
+ const icon = document.querySelector('.icon-search use');
+ const input = document.getElementById('search');
+
+ const onErrorEvent = jest.fn();
+ document.addEventListener('w-swap:error', onErrorEvent);
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: false,
+ status: 500,
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ input.value = 'alpha';
+ input.dispatchEvent(new CustomEvent('keyup', { bubbles: true }));
+
+ jest.runAllTimers(); // update is debounced
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/admin/images/results/?q=alpha',
+ expect.any(Object),
+ );
+
+ expect(onErrorEvent).not.toHaveBeenCalled();
+
+ await Promise.resolve(); // trigger next rendering
+ expect(icon.getAttribute('href')).toEqual('#icon-spinner');
+
+ await flushPromises(); // resolve all promises
+
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenLastCalledWith(
+ 'Error fetching /admin/images/results/?q=alpha',
+ expect.any(Error),
+ );
+
+ // should not update any HTML
+ expect(document.getElementById('results').innerHTML).toEqual('');
+
+ // should have dispatched a custom event for the error
+ expect(onErrorEvent).toHaveBeenCalledTimes(1);
+ expect(onErrorEvent.mock.calls[0][0].detail).toEqual({
+ error: expect.any(Error),
+ requestUrl: '/admin/images/results/?q=alpha',
+ });
+
+ await Promise.resolve(); // trigger next rendering
+
+ // should reset the icon
+ expect(icon.getAttribute('href')).toEqual('#icon-search');
+ });
+ });
+
+ describe('performing a location update via actions on a controlled form', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ `;
+
+ window.history.replaceState(null, '', '?');
+ });
+
+ it('should allow for searching via a declared action on input changes', async () => {
+ const input = document.getElementById('search');
+ const icon = document.querySelector('.icon-search use');
+
+ const results = getMockResults();
+
+ const onBegin = jest.fn();
+ document.addEventListener('w-swap:begin', onBegin);
+
+ const onSuccess = new Promise((resolve) => {
+ document.addEventListener('w-swap:success', resolve);
+ });
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect(icon.getAttribute('href')).toEqual('#icon-search');
+
+ input.value = 'alpha';
+ input.dispatchEvent(new CustomEvent('input', { bubbles: true }));
+
+ jest.runAllTimers(); // update is debounced
+
+ expect(onBegin).toHaveBeenCalledTimes(1);
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+ expect(icon.getAttribute('href')).toEqual('#icon-spinner');
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/path/to/form/action/?q=alpha',
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: '/path/to/form/action/?q=alpha',
+ results,
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('other-results').querySelectorAll('li'),
+ ).toHaveLength(3);
+
+ await flushPromises();
+
+ // should update the current URL
+ expect(window.location.search).toEqual('?q=alpha');
+
+ // should reset the icon
+ expect(icon.getAttribute('href')).toEqual('#icon-search');
+
+ expect(onBegin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should allow for blocking the request with custom events', async () => {
+ const input = document.getElementById('search');
+
+ const results = getMockResults({ total: 5 });
+
+ const beginEventHandler = jest.fn((event) => {
+ event.preventDefault();
+ });
+
+ document.addEventListener('w-swap:begin', beginEventHandler);
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ input.value = 'alpha';
+ input.dispatchEvent(new CustomEvent('input', { bubbles: true }));
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+ await Promise.resolve(requestAnimationFrame);
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl: '/path/to/form/action/?q=alpha',
+ });
+
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ document.removeEventListener('w-swap:begin', beginEventHandler);
+ });
+ });
+});
diff --git a/client/src/controllers/SwapController.ts b/client/src/controllers/SwapController.ts
new file mode 100644
index 0000000000..4bac441e5c
--- /dev/null
+++ b/client/src/controllers/SwapController.ts
@@ -0,0 +1,302 @@
+import { Controller } from '@hotwired/stimulus';
+import { debounce } from '../utils/debounce';
+import { domReady } from '../utils/domReady';
+
+declare global {
+ interface Window {
+ headerSearch?: { targetOutput: string; url: string };
+ }
+}
+
+/**
+ * Support legacy window global approach until header search
+ * can fully adopt data-attributes.
+ *
+ */
+const getGlobalHeaderSearchOptions = (): {
+ targetOutput?: string;
+ termInput?: string;
+ url?: string;
+} => window.headerSearch || {};
+
+/**
+ * Allow for an element to trigger an async query that will
+ * patch the results into a results DOM container. The query
+ * input can be the controlled element or the containing form.
+ * It supports the ability to update the URL with the query
+ * when processed.
+ *
+ * @example
+ *
+ *
+ */
+export class SwapController extends Controller<
+ HTMLFormElement | HTMLInputElement
+> {
+ static defaultClearParam = 'p';
+
+ static targets = ['input'];
+
+ static values = {
+ icon: { default: '', type: String },
+ loading: { default: false, type: Boolean },
+ src: { default: '', type: String },
+ target: { default: '#results', type: String },
+ wait: { default: 200, type: Number },
+ };
+
+ declare readonly hasInputTarget: boolean;
+ declare readonly hasTargetValue: boolean;
+ declare readonly hasUrlValue: boolean;
+ declare readonly inputTarget: HTMLInputElement;
+
+ declare iconValue: string;
+ declare loadingValue: boolean;
+ declare srcValue: string;
+ declare targetValue: string;
+ declare waitValue: number;
+
+ /** Allow cancelling of in flight async request if disconnected */
+ abortController?: AbortController;
+ /** The related icon element to attach the spinner to */
+ iconElement?: SVGUseElement | null;
+ /** Debounced function to search results and then replace the DOM */
+ searchLazy?: { (...args: any[]): void; cancel(): void };
+ /** Element that receives the fetch result HTML output */
+ targetElement?: HTMLElement;
+
+ /**
+ * Ensure we have backwards compatibility with setting window.headerSearch
+ * and allowing for elements without a controller attached to be set up.
+ *
+ * Will be removed in a future release.
+ */
+ static afterLoad(identifier: string) {
+ domReady().then(() => {
+ const { termInput, targetOutput, url } = getGlobalHeaderSearchOptions();
+
+ const input = termInput
+ ? (document.querySelector(termInput) as HTMLInputElement)
+ : null;
+
+ const form = input?.form;
+
+ if (!form) return;
+
+ if (!input.hasAttribute(`data-${identifier}-target`)) {
+ input.setAttribute(`data-${identifier}-target`, 'input');
+ }
+
+ Object.entries({
+ 'data-controller': identifier,
+ 'data-action': [
+ `change->${identifier}#searchLazy`,
+ `input->${identifier}#searchLazy`,
+ ].join(' '),
+ [`data-${identifier}-src-value`]: url,
+ [`data-${identifier}-target-value`]: targetOutput,
+ }).forEach(([key, value]) => {
+ if (!form.hasAttribute(key)) {
+ form.setAttribute(key, value as string);
+ }
+ });
+ });
+ }
+
+ connect() {
+ const formContainer = this.hasInputTarget
+ ? this.inputTarget.form
+ : this.element;
+ this.srcValue =
+ this.srcValue || formContainer?.getAttribute('action') || '';
+ this.targetElement = this.getTarget(this.targetValue);
+
+ // set up icons
+ this.iconElement = null;
+ const iconContainer = (
+ this.hasInputTarget ? this.inputTarget : this.element
+ ).parentElement;
+
+ this.iconElement = iconContainer?.querySelector('use') || null;
+ this.iconValue = this.iconElement?.getAttribute('href') || '';
+
+ // set up initial loading state (if set originally in the HTML)
+ this.loadingValue = false;
+
+ // set up debounced method
+ this.searchLazy = debounce(this.search.bind(this), this.waitValue);
+ }
+
+ getTarget(targetValue = this.targetValue) {
+ const targetElement = document.querySelector(targetValue);
+
+ const foundTarget = targetElement && targetElement instanceof HTMLElement;
+ const hasValidUrlValue = !!this.srcValue;
+
+ const errors: string[] = [];
+
+ if (!foundTarget) {
+ errors.push(`Cannot find valid target element at "${targetValue}"`);
+ }
+
+ if (!hasValidUrlValue) {
+ errors.push(`Cannot find valid src URL value`);
+ }
+
+ if (errors.length) {
+ throw new Error(errors.join(', '));
+ }
+
+ return targetElement as HTMLElement;
+ }
+
+ /**
+ * Toggle the visual spinner icon if available and ensure content about
+ * to be replaced is flagged as busy.
+ */
+ loadingValueChanged(isLoading: boolean) {
+ if (isLoading) {
+ this.targetElement?.setAttribute('aria-busy', 'true');
+ this.iconElement?.setAttribute('href', '#icon-spinner');
+ } else {
+ this.targetElement?.removeAttribute('aria-busy');
+ this.iconElement?.setAttribute('href', this.iconValue);
+ }
+ }
+
+ /**
+ * Perform a search based on a single input query, and only if that query's value
+ * differs from the current matching URL param. Once complete, update the URL param.
+ * Additionally, clear the `'p'` pagination param in the URL if present, can be overridden
+ * via action params if needed.
+ */
+ search(
+ data?: CustomEvent<{ clear: string }> & {
+ params?: { clear?: string };
+ },
+ ) {
+ /** Params to be cleared when updating the location (e.g. ['p'] for page). */
+ const clearParams = (
+ data?.detail?.clear ||
+ data?.params?.clear ||
+ (this.constructor as typeof SwapController).defaultClearParam
+ ).split(' ');
+
+ const searchInput = this.hasInputTarget ? this.inputTarget : this.element;
+ const queryParam = searchInput.name;
+ const searchParams = new URLSearchParams(window.location.search);
+ const currentQuery = searchParams.get(queryParam) || '';
+ const newQuery = searchInput.value || '';
+
+ // only do the query if it has changed for trimmed queries
+ // for example - " " === "" and "first word " ==== "first word"
+ if (currentQuery.trim() === newQuery.trim()) return;
+
+ // Update search query param ('q') to the new value or remove if empty
+ if (newQuery) {
+ searchParams.set(queryParam, newQuery);
+ } else {
+ searchParams.delete(queryParam);
+ }
+
+ // clear any params (e.g. page/p) if needed
+ clearParams.forEach((param) => {
+ searchParams.delete(param);
+ });
+
+ const queryString = '?' + searchParams.toString();
+ const url = this.srcValue;
+
+ this.replace(url + queryString).then(() => {
+ window.history.replaceState(null, '', queryString);
+ });
+ }
+
+ /**
+ * Abort any existing requests & set up new abort controller, then fetch and replace
+ * the HTML target with the new results.
+ * Cancel any in progress results request using the AbortController so that
+ * a faster response does not replace an in flight request.
+ */
+ async replace(
+ data:
+ | string
+ | (CustomEvent<{ url: string }> & { params?: { url?: string } }),
+ ) {
+ /** Parse a request URL from the supplied param, as a string or inside a custom event */
+ const requestUrl =
+ typeof data === 'string'
+ ? data
+ : data.detail.url || data.params?.url || '';
+
+ if (this.abortController) this.abortController.abort();
+ this.abortController = new AbortController();
+ const { signal } = this.abortController;
+
+ this.loadingValue = true;
+
+ const beginEvent = this.dispatch('begin', {
+ cancelable: true,
+ detail: { requestUrl },
+ // Stimulus dispatch target element type issue https://github.com/hotwired/stimulus/issues/642
+ target: this.targetElement as HTMLInputElement,
+ }) as CustomEvent<{ requestUrl: string }>;
+
+ if (beginEvent.defaultPrevented) return Promise.resolve();
+
+ return fetch(requestUrl, {
+ headers: { 'x-requested-with': 'XMLHttpRequest' },
+ signal,
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.text();
+ })
+ .then((results) => {
+ const targetElement = this.targetElement as HTMLElement;
+ targetElement.innerHTML = results;
+ this.dispatch('success', {
+ cancelable: false,
+ detail: { requestUrl, results },
+ // Stimulus dispatch target element type issue https://github.com/hotwired/stimulus/issues/642
+ target: targetElement as HTMLInputElement,
+ });
+ return results;
+ })
+ .catch((error) => {
+ if (signal.aborted) return;
+ this.dispatch('error', {
+ cancelable: false,
+ detail: { error, requestUrl },
+ // Stimulus dispatch target element type issue https://github.com/hotwired/stimulus/issues/642
+ target: this.targetElement as HTMLInputElement,
+ });
+ // eslint-disable-next-line no-console
+ console.error(`Error fetching ${requestUrl}`, error);
+ })
+ .finally(() => {
+ if (signal === this.abortController?.signal) {
+ this.loadingValue = false;
+ }
+ });
+ }
+
+ /**
+ * When disconnecting, ensure we reset any visual related state values and
+ * cancel any in-flight requests.
+ */
+ disconnect() {
+ this.loadingValue = false;
+ this.searchLazy?.cancel();
+ }
+}
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index e10ebab0e0..c89c330bde 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -12,6 +12,7 @@ import { ProgressController } from './ProgressController';
import { SkipLinkController } from './SkipLinkController';
import { SlugController } from './SlugController';
import { SubmitController } from './SubmitController';
+import { SwapController } from './SwapController';
import { SyncController } from './SyncController';
import { TagController } from './TagController';
import { UpgradeController } from './UpgradeController';
@@ -32,6 +33,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: SkipLinkController, identifier: 'w-skip-link' },
{ controllerConstructor: SlugController, identifier: 'w-slug' },
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
+ { controllerConstructor: SwapController, identifier: 'w-swap' },
{ controllerConstructor: SyncController, identifier: 'w-sync' },
{ controllerConstructor: TagController, identifier: 'w-tag' },
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
diff --git a/client/src/entrypoints/admin/bulk-actions.js b/client/src/entrypoints/admin/bulk-actions.js
index a32c88ddd0..e0651e49ed 100644
--- a/client/src/entrypoints/admin/bulk-actions.js
+++ b/client/src/entrypoints/admin/bulk-actions.js
@@ -4,13 +4,4 @@ import {
} from '../../includes/bulk-actions';
document.addEventListener('DOMContentLoaded', addBulkActionListeners);
-
-if (window.headerSearch) {
- const termInput = document.querySelector(window.headerSearch.termInput);
- if (termInput) {
- termInput.addEventListener(
- 'search-success',
- rebindBulkActionsEventListeners,
- );
- }
-}
+document.addEventListener('w-swap:success', rebindBulkActionsEventListeners);
diff --git a/client/src/entrypoints/admin/core.js b/client/src/entrypoints/admin/core.js
index 95faf73590..dd6baececb 100644
--- a/client/src/entrypoints/admin/core.js
+++ b/client/src/entrypoints/admin/core.js
@@ -3,7 +3,6 @@ import $ from 'jquery';
import { coreControllerDefinitions } from '../../controllers';
import { escapeHtml } from '../../utils/text';
import { initStimulus } from '../../includes/initStimulus';
-import { initTooltips } from '../../includes/initTooltips';
/** initialise Wagtail Stimulus application with core controller definitions */
window.Stimulus = initStimulus({ definitions: coreControllerDefinitions });
@@ -216,62 +215,6 @@ $(() => {
.on('dragleave dragend drop', function onDragLeave() {
$(this).removeClass('hovered');
});
-
- /* Header search behaviour */
- if (window.headerSearch) {
- let searchCurrentIndex = 0;
- let searchNextIndex = 0;
- const $input = $(window.headerSearch.termInput);
- const $inputContainer = $input.parent();
- const $icon = $inputContainer.find('use');
- const baseIcon = $icon.attr('href');
-
- $input.on('keyup cut paste change', () => {
- clearTimeout($input.data('timer'));
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- $input.data('timer', setTimeout(search, 200));
- });
-
- // auto focus on search box
- $input.trigger('focus');
-
- // eslint-disable-next-line func-names
- const search = function () {
- const newQuery = $input.val();
- const searchParams = new URLSearchParams(window.location.search);
- const currentQuery = searchParams.get('q') || '';
- // only do the query if it has changed for trimmed queries
- // for example - " " === "" and "firstword " ==== "firstword"
- if (currentQuery.trim() !== newQuery.trim()) {
- $icon.attr('href', '#icon-spinner');
- searchNextIndex += 1;
- const index = searchNextIndex;
-
- // Update q, reset to first page, and keep other query params
- searchParams.set('q', newQuery);
- searchParams.delete('p');
- const queryString = searchParams.toString();
-
- $.ajax({
- url: window.headerSearch.url,
- data: queryString,
- success(data) {
- if (index > searchCurrentIndex) {
- searchCurrentIndex = index;
- $(window.headerSearch.targetOutput).html(data).slideDown(800);
- window.history.replaceState(null, null, '?' + queryString);
- $input[0].dispatchEvent(new Event('search-success'));
- }
- },
- complete() {
- // Reinitialise any tooltips
- initTooltips();
- $icon.attr('href', baseIcon);
- },
- });
- }
- };
- }
});
// =============================================================================
diff --git a/client/src/entrypoints/admin/wagtailadmin.js b/client/src/entrypoints/admin/wagtailadmin.js
index 4390e087a6..f076689c11 100644
--- a/client/src/entrypoints/admin/wagtailadmin.js
+++ b/client/src/entrypoints/admin/wagtailadmin.js
@@ -36,3 +36,11 @@ window.addEventListener('load', () => {
initAnchoredPanels();
initMinimap();
});
+
+/**
+ * When search results are successful, reinitialise widgets
+ * that could be inside the newly injected DOM.
+ */
+window.addEventListener('w-swap:success', () => {
+ initTooltips(); // reinitialise any tooltips
+});
diff --git a/docs/releases/5.1.md b/docs/releases/5.1.md
index b661dc4938..a49be23897 100644
--- a/docs/releases/5.1.md
+++ b/docs/releases/5.1.md
@@ -68,6 +68,7 @@ Thank you to Damilola for his work, and to Google for sponsoring this project.
* Ensure that `gettext_lazy` works correctly when using `verbose_name` on a generic Settings models (Sébastien Corbin)
* Remove unnecessary usage of `innerHTML` when modifying DOM content (LB (Ben) Johnston)
* Avoid `ValueError` when extending `PagesAPIViewSet` and setting `meta_fields` to an empty list (Henry Harutyunyan, Alex Morega)
+ * Improve accessibility for header search, remove autofocus on page load, advise screen readers that content has changed when results update (LB (Ben) Johnston)
### Documentation
@@ -104,6 +105,7 @@ Thank you to Damilola for his work, and to Google for sponsoring this project.
* Convert the `CONTRIBUTORS` file to Markdown (Dan Braghis)
* Move `django-filter` version upper bound to v24 (Yuekui)
* Update Pillow dependency to allow 10.x, only include support for >= 9.1.0 (Yuekui)
+ * Migrate header search behaviour to `w-swap`, a Stimulus controller (LB (Ben) Johnston)
## Upgrade considerations