0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-25 05:02:57 +01:00

Migrate header search to a Stimulus controller (w-swap)

- Removes the jQuery slide animation so content will be instantly replaced
- Removes the autofocus behaviour on the search fields as this is not helpful for screen readers / keyboard control
- Includes support for `window.header` if provided alongside dynamic adding of data-* attributes if not included
- Base implementation for #9950
- Co-authored-by: sag᠎e <laymonage@gmail.com>
This commit is contained in:
LB Johnston 2023-01-21 07:26:26 +10:00 committed by LB (Ben Johnston)
parent 1ea8a0e360
commit 04d1e8162b
8 changed files with 931 additions and 67 deletions

View File

@ -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)

View File

@ -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((_) => `<li>RESULT ${_}</li>`).join('');
return `<ul ${attrs.join(' ')}>${items}</ul>`;
};
beforeEach(() => {
application = Application.start();
application.register('w-swap', SwapController);
handleError = jest.fn();
application.handleError = handleError;
});
afterEach(() => {
application.stop();
document.body.innerHTML = '<main></main>';
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 = `
<div id="results"></div>
<input
id="search"
type="text"
name="q"
data-controller="w-swap"
data-w-swap-target-value=""
/>`;
// 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 = `
<div id="results"></div>
<input
id="search"
type="text"
name="q"
data-controller="w-swap"
data-w-swap-src-value="path/to/search"
data-w-swap-target-value="#resultX"
/>`;
// 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 = `
<div id="results"></div>
<input
id="search"
type="text"
name="q"
data-controller="w-swap"
data-w-swap-target-value="#results"
/>`;
// 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 = `
<form id="form"><input id="search" type="text" name="q" /></form>
<div id="page-results"></div>
`;
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 = `
<form class="search-form" action="/admin/images/" method="get" role="search">
<div class="w-field__input">
<svg class="icon icon-search" aria-hidden="true"><use href="#icon-search"></use></svg>
<input
id="search"
type="text"
name="q"
data-controller="w-swap"
data-action="keyup->w-swap#searchLazy"
data-w-swap-src-value="/admin/images/results/"
data-w-swap-target-value="#results"
/>
</div>
</form>
<div id="results"></div>
`;
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 = `
<form
class="search-form"
action="/path/to/form/action/"
method="get"
role="search"
data-controller="w-swap"
data-action="input->w-swap#searchLazy"
data-w-swap-target-value="#other-results"
>
<div class="w-field__input">
<svg class="icon icon-search" aria-hidden="true"><use href="#icon-search"></use></svg>
<input id="search" type="text" name="q" data-w-swap-target="input"/>
</div>
</form>
<div id="other-results"></div>
`;
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);
});
});
});

View File

@ -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
* <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="#results"
* />
*/
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();
}
}

View File

@ -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' },

View File

@ -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);

View File

@ -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);
},
});
}
};
}
});
// =============================================================================

View File

@ -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
});

View File

@ -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