A button inside a summary is an interactive element in an interactive element; screen readers announce it unreliably. The handle keeps its look and its mouse, touch and arrow key behaviour. Also corrects the anchor hints and waits for the menu error in the admin check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
19 KiB
JavaScript
277 lines
19 KiB
JavaScript
import { setupPicker } from './page-picker.js';
|
|
import './link-field.js';
|
|
|
|
const form = document.querySelector('#menu-editor');
|
|
const list = form.querySelector('#menu-items');
|
|
const status = form.querySelector('#menu-status');
|
|
const error = form.querySelector('#menu-error');
|
|
const history = [];
|
|
let serial = list.children.length, pendingPage = null, busy = false;
|
|
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
|
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' };
|
|
const menuIcons = JSON.parse(form.dataset.icons || '[]');
|
|
const chrome = () => {
|
|
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
|
|
handle.title = 'Ziehen · Pfeiltasten ändern Reihenfolge und Ebene';
|
|
handle.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg>';
|
|
const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chevron.setAttribute('class','menu-chevron'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('width','20'); chevron.setAttribute('height','20'); chevron.setAttribute('aria-hidden','true');
|
|
chevron.innerHTML = '<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>';
|
|
return { handle, chevron };
|
|
};
|
|
const rows = () => [...list.children].map(row => {
|
|
const data = { level: Number(row.dataset.level) };
|
|
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
|
|
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
|
|
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
|
return data;
|
|
});
|
|
const state = () => ({ name: form.elements.name.value, rows: rows() });
|
|
const key = value => JSON.stringify(value);
|
|
let last = state(), saved = key(last);
|
|
const dirty = () => key(state()) !== saved;
|
|
|
|
function refresh() {
|
|
[...list.children].forEach((row, index) => {
|
|
row.classList.toggle('menu-child', row.dataset.level === '2');
|
|
row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; });
|
|
row.querySelector('[data-key="position"]').value = index;
|
|
row.querySelector('[data-key="level"]').value = row.dataset.level;
|
|
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
|
|
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
|
|
row.querySelector('.menu-entry-title').textContent = name;
|
|
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
|
|
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
|
|
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
|
|
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
|
|
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
|
|
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
|
|
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
|
|
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
|
|
row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1';
|
|
row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`);
|
|
row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; });
|
|
row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`);
|
|
row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`);
|
|
});
|
|
form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`;
|
|
form.querySelector('#menu-empty').hidden = Boolean(list.children.length);
|
|
form.querySelector('#menu-undo').disabled = !history.length || busy;
|
|
status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand';
|
|
}
|
|
|
|
function createRow(data) {
|
|
const id = `new-${serial++}`;
|
|
const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1);
|
|
const field = (key, value, label, type = 'text') => {
|
|
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
|
|
input.id = `menu-${key}-${id}`;
|
|
if (type !== 'hidden') {
|
|
const caption = el('label', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
|
|
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else body.append(caption, input);
|
|
}
|
|
else row.append(input);
|
|
return input;
|
|
};
|
|
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
|
const details = el('details'); const summary = el('summary');
|
|
const { handle, chevron } = chrome();
|
|
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
|
|
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
|
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
|
|
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
|
if (data.type === 'page') {
|
|
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
|
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
|
|
picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt';
|
|
const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true;
|
|
input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant');
|
|
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
|
|
body.append(picker); setupPicker(picker);
|
|
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
|
anchorChoice(row);
|
|
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
|
|
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
|
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
|
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
|
|
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
|
|
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
|
|
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
|
|
}
|
|
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
|
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
|
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
|
}
|
|
row.append(actions); return row;
|
|
}
|
|
|
|
/*
|
|
* Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was
|
|
* gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen.
|
|
*/
|
|
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
|
function anchorChoice(row) {
|
|
const input = row.querySelector('input[data-key="anchor"]');
|
|
const pageValue = row.querySelector('.picker-value');
|
|
if (!input || !pageValue || input.dataset.enhanced) return;
|
|
input.dataset.enhanced = '1';
|
|
const label = row.querySelector(`label[for="${input.id}"]`);
|
|
const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden';
|
|
if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; }
|
|
input.after(select);
|
|
const help = el('p', 'field-help', ''); select.after(help);
|
|
let request = 0;
|
|
const load = async () => {
|
|
const mine = ++request, current = input.value;
|
|
select.replaceChildren(new Option('Seitenanfang', ''));
|
|
if (current) select.append(new Option(`#${current}`, current));
|
|
select.value = current;
|
|
help.textContent = '';
|
|
if (!pageValue.value) { select.disabled = true; return; }
|
|
select.disabled = false;
|
|
try {
|
|
const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' });
|
|
if (!response.ok || response.redirected) throw new Error();
|
|
const { items } = await response.json();
|
|
if (mine !== request) return;
|
|
select.replaceChildren(new Option('Seitenanfang', ''));
|
|
for (const item of items) select.append(new Option(item.label, item.anchor));
|
|
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
|
|
select.value = current;
|
|
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
|
|
} catch {
|
|
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
|
|
}
|
|
};
|
|
select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); });
|
|
pageValue.addEventListener('change', () => { input.value = ''; load(); });
|
|
load();
|
|
}
|
|
list.querySelectorAll('.menu-entry').forEach(anchorChoice);
|
|
function linkButtons(input) {
|
|
// Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen.
|
|
if (input.closest('[data-link-field]')) return;
|
|
input.dataset.linkInput = '';
|
|
const box = el('div', 'link-field'); box.dataset.linkField = '';
|
|
input.before(box); box.append(input);
|
|
}
|
|
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
|
|
|
|
function restore(value) {
|
|
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
|
|
}
|
|
function commit(before) {
|
|
refresh(); const after = state();
|
|
if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); }
|
|
last = after; refresh();
|
|
}
|
|
function add(data) {
|
|
if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; }
|
|
const before = state(); const row = createRow(data); list.append(row); commit(before);
|
|
row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus();
|
|
}
|
|
|
|
const addPicker = form.querySelector('.menu-add .content-picker');
|
|
addPicker.querySelector('.picker-value').addEventListener('change', () => {
|
|
const id = addPicker.querySelector('.picker-value').value;
|
|
pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null;
|
|
form.querySelector('#menu-add-page').disabled = !pendingPage;
|
|
});
|
|
form.querySelector('#menu-add-page').addEventListener('click', () => {
|
|
if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 });
|
|
});
|
|
form.querySelector('#menu-add-url').addEventListener('click', () => {
|
|
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
|
|
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
|
|
const newTab = form.querySelector('#link-new-tab');
|
|
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
|
|
});
|
|
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
|
|
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
|
list.addEventListener('click', event => {
|
|
const button = event.target.closest('[data-menu-action]'); if (!button || busy) return;
|
|
const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction;
|
|
const entries = [...list.children], index = entries.indexOf(row);
|
|
const bundle = [row];
|
|
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
|
|
if (action === 'remove') {
|
|
row.remove();
|
|
// Removing a heading never deletes its links: promote its children visibly.
|
|
bundle.slice(1).forEach(child => { child.dataset.level = '1'; });
|
|
}
|
|
if (action === 'indent') row.dataset.level = '2';
|
|
if (action === 'outdent') row.dataset.level = '1';
|
|
if (action === 'up' && index > 0) {
|
|
let target = index - 1;
|
|
if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--;
|
|
bundle.forEach(item => list.insertBefore(item, entries[target]));
|
|
}
|
|
if (action === 'down') {
|
|
let target = index + bundle.length;
|
|
if (entries[target]) {
|
|
target++;
|
|
if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++;
|
|
bundle.forEach(item => list.insertBefore(item, entries[target] || null));
|
|
}
|
|
}
|
|
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
|
|
commit(before); if (row.isConnected) row.querySelector('summary').focus();
|
|
});
|
|
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
|
|
// Pointer capture supports mouse, pen and touch. A parent travels with its children.
|
|
let drag = null;
|
|
list.addEventListener('click', event => { if (event.target.closest('[data-menu-drag]')) { event.preventDefault(); event.stopPropagation(); } });
|
|
list.addEventListener('keydown', event => {
|
|
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy) return;
|
|
const action = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }[event.key];
|
|
if (action) { event.preventDefault(); const row = handle.closest('.menu-entry'); row.querySelector(`[data-menu-action="${action}"]`).click(); handle.focus(); }
|
|
});
|
|
list.addEventListener('pointerdown', event => {
|
|
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy || event.button !== 0) return;
|
|
event.preventDefault(); handle.focus(); handle.setPointerCapture(event.pointerId);
|
|
const row = handle.closest('.menu-entry'), entries = [...list.children], index = entries.indexOf(row), bundle = [row];
|
|
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
|
|
drag = { handle, row, bundle, before: state(), x: event.clientX, y: event.clientY, pointer: event.pointerId, active: false, level: row.dataset.level };
|
|
});
|
|
list.addEventListener('pointermove', event => {
|
|
if (!drag || event.pointerId !== drag.pointer) return;
|
|
if (!drag.active && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
|
|
drag.active = true; drag.bundle.forEach(row => row.classList.add('menu-dragging'));
|
|
const others = [...list.children].filter(row => !drag.bundle.includes(row));
|
|
let target = others.find(row => event.clientY < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2) || null;
|
|
// A main entry never splits the children of another main entry.
|
|
if ((drag.bundle.length > 1 || drag.level === '1') && target?.dataset.level === '2') {
|
|
let index = others.indexOf(target); while (index > 0 && others[index].dataset.level === '2') index--; target = others[index];
|
|
}
|
|
drag.bundle.forEach(row => list.insertBefore(row, target));
|
|
if (drag.bundle.length === 1) drag.row.dataset.level = event.clientX - drag.x > 36 ? '2' : event.clientX - drag.x < -36 ? '1' : drag.level;
|
|
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
|
|
refresh();
|
|
if (event.clientY > window.innerHeight - 65) window.scrollBy(0, 18); else if (event.clientY < 65) window.scrollBy(0, -18);
|
|
});
|
|
function endDrag(cancel = false) {
|
|
if (!drag) return;
|
|
const previous = drag; drag = null;
|
|
previous.bundle.forEach(row => row.classList.remove('menu-dragging'));
|
|
if (previous.handle.hasPointerCapture(previous.pointer)) previous.handle.releasePointerCapture(previous.pointer);
|
|
if (cancel) restore(previous.before); else if (previous.active) { commit(previous.before); previous.handle.focus(); status.textContent = 'Reihenfolge geändert · noch nicht gespeichert'; }
|
|
}
|
|
list.addEventListener('pointerup', () => endDrag()); list.addEventListener('pointercancel', () => endDrag(true));
|
|
document.addEventListener('keydown', event => { if (event.key === 'Escape' && drag) { event.preventDefault(); endDrag(true); } });
|
|
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
|
|
form.noValidate = true;
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault(); if (busy) return;
|
|
const invalid = form.querySelector(':invalid');
|
|
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
|
|
refresh(); const body = new FormData(form); const snapshot = key(state());
|
|
busy = true; error.hidden = true; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = true; }); refresh();
|
|
try {
|
|
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
|
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
|
|
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
|
|
form.elements.expectedVersion.value = result.version; saved = snapshot;
|
|
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
|
|
finally { busy = false; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
|
|
});
|
|
refresh(); last = state(); saved = key(last);
|