Put the menu drag handle next to the summary instead of inside it

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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-22 10:59:21 +02:00
1 parent 24c1d9c871
commit 5d55a65c22
7 files changed
+43 -11

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Projektstatus # Projektstatus
## Fassung 1.22.4: Ziehknopf im Menü neben der Zusammenfassung (22.09.2026)
**Anlass:** Umut: „ja räum das auf und Release 1.22.4“ – die bekannte axe-Meldung `nested-interactive` im Menü-Editor.
- Der Ziehknopf eines Menüeintrags stand in der `<summary>`, also ein Knopf in einem Knopf. Screenreader lesen so etwas unzuverlässig vor, und die Tastatur erreicht es nicht sauber. Jetzt steht er als Geschwister vor `<details>`, per CSS über die linke Ecke der Zusammenfassung gelegt (`menu-entry.html.twig`, `createRow` in `menu-editor.js`). Aussehen und Bedienung bleiben gleich: Ziehen mit Maus, Stift und Finger, Pfeiltasten auf dem Knopf.
- Der Hinweis zu fehlenden Sprungzielen im Menü und die Hilfe zum Feld „Sprungziel“ im Editor nennen jetzt die richtige Stelle (Sektion, Zeile oder Spalte) und den Weg über die Auswahl im Menü.
- `test:admin`: Die Fehlermeldung nach einem abgewiesenen Speichern wurde direkt nach der Serverantwort abgefragt, sie erscheint aber erst, nachdem die Antwort gelesen ist. Jetzt wartet der Test auf sie. Der Check scheitert weiterhin an der älteren Stelle beim Klick in den Strukturbaum des Editors.
**Geprüft:** Die Editor-Prüfung prüft die ganze Menüseite mit axe, ohne Ausnahme. Sie stellt sicher, dass in keiner Zusammenfassung ein Knopf steht, und verschiebt einen Eintrag per Pfeiltaste (Fokus bleibt auf dem Knopf). Bildschirmfoto `reports/editor/menu-editor.png` angesehen.
## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026) ## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026)
**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2. **Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2.
+6
View File
@@ -998,3 +998,9 @@ details.card > summary:hover { background: #fafbf8 }
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer } .term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 } .term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
.term-chip[aria-pressed="true"]::before { content: '✓ '; } .term-chip[aria-pressed="true"]::before { content: '✓ '; }
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
.menu-entry { position: relative }
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
.menu-entry summary { padding-left: 58px; min-height: 60px }
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
+1 -1
View File
@@ -530,7 +530,7 @@ function renderContent(block, panel) {
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id)); add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
panel.append(hint, entries, add); panel.append(hint, entries, add);
} }
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' }); if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt. Im Menü lässt es sich dann als Sprungziel dieser Seite auswählen, ein Button springt mit #kontakt hierher.' });
if (block.type === 'accordionitem') { if (block.type === 'accordionitem') {
field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' }); field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
+4 -3
View File
@@ -75,8 +75,9 @@ function createRow(data) {
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden'); for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
const details = el('details'); const summary = el('summary'); const details = el('details'); const summary = el('summary');
const { handle, chevron } = chrome(); const { handle, chevron } = chrome();
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron); // Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details); 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; const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
if (data.type === 'page') { if (data.type === 'page') {
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready; const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
@@ -136,7 +137,7 @@ function anchorChoice(row) {
for (const item of items) select.append(new Option(item.label, item.anchor)); 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)); if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
select.value = current; select.value = current;
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor bekommt eine Sektion unter „Erweitert“ einen Anker.'; help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
} catch { } catch {
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.'; if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
} }
+2 -2
View File
@@ -143,7 +143,7 @@ try {
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click(); await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
assert.equal((await menuSave()).status(), 422); assert.equal((await menuSave()).status(), 422);
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)'); assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
assert.equal(await page.locator('#menu-error').isVisible(), true); await page.locator('#menu-error').waitFor({ state: 'visible' });
await entries.last().locator('summary').click(); await entries.last().locator('summary').click();
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt'); await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
assert.equal((await menuSave()).status(), 200); assert.equal((await menuSave()).status(), 200);
@@ -155,7 +155,7 @@ try {
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand'); await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
assert.equal((await menuSave()).status(), 409); assert.equal((await menuSave()).status(), 409);
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand'); assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
assert.equal(await page.locator('#menu-error').isVisible(), true); await page.locator('#menu-error').waitFor({ state: 'visible' });
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true }); checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
await page.goto(origin + '/admin/forms'); await page.goto(origin + '/admin/forms');
+16 -4
View File
@@ -1189,6 +1189,8 @@ try {
await page.locator('dialog.page-dialog [role="option"]').first().click(); await page.locator('dialog.page-dialog [role="option"]').first().click();
await page.locator('dialog.page-dialog').waitFor({ state: 'detached' }); await page.locator('dialog.page-dialog').waitFor({ state: 'detached' });
assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/); assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/);
await page.locator('#link-label').fill('Eigener Prüflink');
await page.locator('#menu-add-url').click();
await page.locator('#menu-search').focus(); await page.locator('#menu-search').focus();
await page.locator('#menu-search-results [role="option"]').first().click(); await page.locator('#menu-search-results [role="option"]').first().click();
await page.locator('#menu-add-page').click(); await page.locator('#menu-add-page').click();
@@ -1196,11 +1198,21 @@ try {
const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true }); const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true });
assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice'); assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice');
assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang'); assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang');
// Nur die neuen Felder: die Zusammenfassung der Menüeinträge trägt seit jeher einen Ziehknopf // Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
// (nested-interactive), das ist eine eigene, bekannte Baustelle. // mehr in einem Knopf.
await neuerEintrag.locator('summary').click(); await neuerEintrag.locator('summary').click();
const menuAxe = await new AxeBuilder({ page }).include('#menu-items .menu-entry:last-child .menu-entry-fields').include('.menu-add').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await axe('menu editor');
assert.deepEqual(menuAxe.violations.map(v => v.id), [], 'anchor choice and link field'); await page.screenshot({ path: 'reports/editor/menu-editor.png' });
assert.equal(await neuerEintrag.locator('summary [data-menu-drag]').count(), 0, 'no button inside the summary');
// Ziehknopf per Tastatur: Pfeil nach oben verschiebt den Eintrag.
const eintraege = page.locator('#menu-items .menu-entry');
const anzahl = await eintraege.count();
assert.ok(anzahl >= 2);
const titel = await neuerEintrag.locator('.menu-entry-title').textContent();
await eintraege.nth(anzahl - 1).locator('[data-menu-drag]').focus();
await page.keyboard.press('ArrowUp');
assert.equal(await eintraege.nth(anzahl - 2).locator('.menu-entry-title').textContent(), titel, 'the entry moved up');
assert.equal(await eintraege.nth(anzahl - 2).locator('[data-menu-drag]').evaluate(node => node === document.activeElement), true, 'the handle keeps focus');
checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true }); checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true });
// Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld. // Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld.
await page.goto(origin + '/admin/posts'); await page.goto(origin + '/admin/posts');
+4 -1
View File
@@ -2,8 +2,11 @@
<input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}"> <input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}">
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}"> <input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}"> <input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
{# Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr: ein Knopf in einem Knopf ist
für Screenreader und Tastatur nicht bedienbar (axe: nested-interactive). #}
<button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><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></button>
<details> <details>
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><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></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary> <summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="menu-entry-fields"> <div class="menu-entry-fields">
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}"> <label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
{% if row.type == 'page' %} {% if row.type == 'page' %}