The share image and featured image of a page become image fields with the library dialog, as do image settings of plugins. The editor offers forms, sections, categories and tags as lists, a page search next to every link target and in text links, the redirect target has a page search, and the form editor picks the reply field, the condition and the option images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
6.7 KiB
JavaScript
100 lines
6.7 KiB
JavaScript
export function setupPicker(picker, onSelect = () => {}) {
|
|
if (picker.dataset.ready) return;
|
|
picker.dataset.ready = 'true';
|
|
const input = picker.querySelector('[role="combobox"]');
|
|
const value = picker.querySelector('.picker-value');
|
|
const results = picker.querySelector('[role="listbox"]');
|
|
const status = picker.querySelector('.picker-status');
|
|
let controller, timer, current = -1, items = [];
|
|
const close = () => {
|
|
results.hidden = true; input.setAttribute('aria-expanded', 'false');
|
|
input.removeAttribute('aria-activedescendant'); current = -1;
|
|
};
|
|
const select = item => {
|
|
// Most fields store the page id; a setting that keeps an address asks for the path instead.
|
|
value.value = item?.[picker.dataset.value || 'id'] ?? '';
|
|
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
|
|
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
|
|
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
|
|
};
|
|
const activate = index => {
|
|
current = Math.max(0, Math.min(items.length - 1, index));
|
|
[...results.children].forEach((node, i) => node.setAttribute('aria-selected', String(i === current)));
|
|
const option = results.children[current];
|
|
if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); }
|
|
};
|
|
const search = async query => {
|
|
controller?.abort(); controller = new AbortController();
|
|
const signal = controller.signal;
|
|
const url = new URL(picker.dataset.source, location.href);
|
|
url.searchParams.set('q', query); url.searchParams.set('type', picker.dataset.type || '');
|
|
url.searchParams.set('parents', picker.dataset.parents || '0');
|
|
status.textContent = 'Wird gesucht …';
|
|
try {
|
|
const response = await fetch(url, { signal, credentials: 'same-origin' });
|
|
if (!response.ok || response.redirected) throw new Error();
|
|
const data = await response.json();
|
|
if (signal.aborted) return;
|
|
items = data.items; current = -1; results.replaceChildren();
|
|
items.forEach((item, index) => {
|
|
const option = document.createElement('button'); option.type = 'button'; option.tabIndex = -1;
|
|
option.id = `${input.id}-option-${index}`; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false');
|
|
const title = document.createElement('strong'); title.textContent = item.title;
|
|
const detail = document.createElement('span'); detail.textContent = `/${item.path === 'home' ? '' : item.path} · ${item.type === 'post' ? 'Beitrag' : 'Seite'}${item.published ? '' : ' · Entwurf'}`;
|
|
option.append(title, detail); option.addEventListener('mousedown', event => event.preventDefault());
|
|
option.addEventListener('click', () => { select(item); input.focus({ preventScroll: true }); close(); }); results.append(option);
|
|
});
|
|
results.hidden = !items.length; input.setAttribute('aria-expanded', String(Boolean(items.length)));
|
|
status.textContent = items.length ? `${items.length} Treffer${items.length === 20 ? ' · Suche eingrenzen für weitere Ergebnisse' : ''}.` : 'Keine passenden Inhalte gefunden.';
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') { close(); status.textContent = 'Inhalte konnten nicht geladen werden. Bitte erneut suchen oder neu anmelden.'; }
|
|
}
|
|
};
|
|
input.addEventListener('focus', () => search(''));
|
|
input.addEventListener('input', () => {
|
|
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
|
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
|
const free = picker.dataset.free === '1';
|
|
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
|
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
|
value.dispatchEvent(new Event('change', { bubbles: true }));
|
|
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
|
});
|
|
input.addEventListener('keydown', event => {
|
|
if (event.key === 'Escape') { event.preventDefault(); controller?.abort(); clearTimeout(timer); close(); }
|
|
if (['ArrowDown', 'ArrowUp'].includes(event.key) && !results.hidden) { event.preventDefault(); activate(current + (event.key === 'ArrowDown' ? 1 : -1)); }
|
|
if (event.key === 'Enter' && current >= 0 && !results.hidden) { event.preventDefault(); select(items[current]); }
|
|
});
|
|
picker.addEventListener('focusout', event => { if (!picker.contains(event.relatedTarget)) { controller?.abort(); clearTimeout(timer); close(); } });
|
|
picker.querySelector('.picker-clear').addEventListener('click', () => { controller?.abort(); clearTimeout(timer); select(null); input.focus(); });
|
|
}
|
|
|
|
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
|
|
|
/**
|
|
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
|
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
|
*/
|
|
export function openPageDialog({ onChoose } = {}) {
|
|
const opener = document.activeElement;
|
|
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
|
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
|
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
|
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
|
<div class="content-picker">
|
|
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
|
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
|
<input type="hidden" class="picker-value">
|
|
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
|
<p class="picker-status field-help" role="status"></p>
|
|
</div>`;
|
|
const picker = dialog.querySelector('.content-picker');
|
|
picker.dataset.source = source; picker.classList.add('menu-add');
|
|
document.body.append(dialog);
|
|
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
|
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
|
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
|
dialog.showModal();
|
|
dialog.querySelector('#page-dialog-search').focus();
|
|
}
|