Ergänzt AJAX-Livesuche für Seiten, Beiträge und Mediathek
This commit is contained in:
1 parent
11b60fc5d8
commit
41d87f62a4
20 files changed
+703
-311
No files matched your search
@@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
|
||||
const form = document.querySelector('[data-live-search]');
|
||||
if (form) {
|
||||
const results = document.querySelector('#list-results');
|
||||
const status = document.querySelector('#search-status');
|
||||
const error = document.querySelector('#search-error');
|
||||
const search = form.querySelector('input[type="search"]');
|
||||
const fields = [...form.querySelectorAll('input[name],select[name]')];
|
||||
let timer, controller, sequence = 0, composing = false;
|
||||
|
||||
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
|
||||
const updateReset = () => {
|
||||
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
|
||||
};
|
||||
const urlFor = parameters => {
|
||||
const url = new URL(form.action);
|
||||
for (const [name, value] of Object.entries(parameters)) {
|
||||
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
|
||||
if (String(value) !== fallback) url.searchParams.set(name, String(value));
|
||||
}
|
||||
return url;
|
||||
};
|
||||
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
|
||||
const restoreFilters = url => {
|
||||
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
|
||||
updateReset();
|
||||
};
|
||||
|
||||
async function load(url, navigation = 'replace', focusResults = false) {
|
||||
cancel(); const request = sequence;
|
||||
controller = new AbortController(); const signal = controller.signal;
|
||||
results.setAttribute('aria-busy', 'true'); error.hidden = true;
|
||||
status.textContent = 'Ergebnisse werden gesucht …';
|
||||
updateReset();
|
||||
try {
|
||||
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
|
||||
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
|
||||
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
|
||||
const data = await response.json();
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
|
||||
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
|
||||
results.replaceChildren(...parsed.body.childNodes);
|
||||
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
// Keep create/upload forms outside the replaced list on the same filtered screen.
|
||||
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
|
||||
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
|
||||
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
|
||||
}
|
||||
const canonical = urlFor(data.parameters);
|
||||
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
|
||||
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
|
||||
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
|
||||
} catch (failure) {
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
|
||||
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
|
||||
} finally {
|
||||
if (request === sequence) results.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
cancel(); updateReset();
|
||||
if (composing) return;
|
||||
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
|
||||
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
|
||||
};
|
||||
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
|
||||
search.addEventListener('compositionend', () => { composing = false; queue(); });
|
||||
search.addEventListener('input', queue);
|
||||
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
|
||||
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
|
||||
form.querySelector('[data-search-reset]').addEventListener('click', event => {
|
||||
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
|
||||
});
|
||||
results.addEventListener('click', event => {
|
||||
const link = event.target.closest('.pager a');
|
||||
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault(); load(new URL(link.href), 'push', true);
|
||||
});
|
||||
// A delayed response must not overwrite a form the user has just started editing.
|
||||
results.addEventListener('input', () => {
|
||||
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
|
||||
});
|
||||
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
|
||||
window.addEventListener('pagehide', cancel);
|
||||
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
|
||||
form.classList.add('live-search-ready');
|
||||
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
|
||||
search.setAttribute('aria-controls', results.id);
|
||||
updateReset();
|
||||
}
|
||||
Reference in new issue
Block a user