fix: expose accordion contents and add clear insert actions (1.8.3)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

This commit is contained in:
kantarci committed 2026-09-14 12:53:16 +02:00
1 parent a108e7d143
commit a3029966cb
12 files changed
+257 -18

No files matched your search

+2
View File
@@ -30,6 +30,8 @@ Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website
## Anmelden und bearbeiten ## Anmelden und bearbeiten
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`. **Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position. **Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
+1 -1
View File
@@ -1 +1 @@
1.8.2 1.8.3
+10
View File
@@ -2,6 +2,16 @@
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von `<details>`. Einträge mit `props.open: false` blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
`preview-containers.js` öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
**Migration und Rückweg:** Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026) ## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt. Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
+12
View File
@@ -1,5 +1,17 @@
# Projektstatus # Projektstatus
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
**Bedienung:** Rechte Eintragsübersicht, Struktur und Struktursuche zeigen Eintragstitel. Ein neues Akkordeon wählt den ersten Eintrag samt Inhaltsoptionen. Bei Auswahl der Gruppe werden Texte nicht mehr unbemerkt außerhalb des Akkordeons eingefügt. Die gespeicherte Einstellung „Beim Laden geöffnet“ gilt weiterhin für Besucher; Öffnen im Editor verändert weder Daten noch Speicherstatus. Keine Migration und kein geänderter öffentlicher Renderer (ADR 085).
**Geprüft:** `npm run test:accordion` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: sichtbarer Inhalt trotz geschlossenem Besucherzustand, Einfügen über Tastatur/Vorschau/Feinschliff, Textbearbeitung, Anhängen und Drag-and-drop von Buttons in Einträge, Undo/Redo, weitere/leere/verschachtelte Akkordeons, Suche nach Titeln, Speichern/Laden und Veröffentlichung. Veröffentlichte Einträge behalten ihre Öffnungswerte und lassen sich ohne JavaScript per Maus/Enter bedienen; Vorschauhilfen fehlen im öffentlichen HTML und in gespeicherten Daten. Desktop 1600/Mobil 390 ohne axe-Verstöße im Editor oder horizontalen Überlauf; Screenshots visuell geprüft (`reports/accordion/`). Originaldaten unverändert.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
**Veröffentlichung / nächster Schritt:** Release 1.8.3 aus dem geprüften Commit bereitstellen. Danach über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings werden hier nicht verändert.
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026) ## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084). **Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084).
+2 -1
View File
@@ -14,7 +14,8 @@
"test:image-size": "node scripts/image-size-check.mjs", "test:image-size": "node scripts/image-size-check.mjs",
"test:link-media": "node scripts/link-media-check.mjs", "test:link-media": "node scripts/link-media-check.mjs",
"test:404": "node scripts/not-found-check.mjs", "test:404": "node scripts/not-found-check.mjs",
"test:colors": "node scripts/color-picker-check.mjs" "test:colors": "node scripts/color-picker-check.mjs",
"test:accordion": "node scripts/accordion-check.mjs"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.0", "@axe-core/playwright": "^4.10.0",
+2
View File
@@ -148,6 +148,8 @@ export function insertionTarget(document, selected, type) {
} }
const probe = { type }; const probe = { type };
let item = locate(document.blocks, selected); let item = locate(document.blocks, selected);
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length }; if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
while (item) { while (item) {
const parentId = item.parent?.id ?? null; const parentId = item.parent?.id ?? null;
+3
View File
@@ -110,6 +110,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px} .property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600} .property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001} .property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7} .design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
.design-reset{margin-top:24px;white-space:normal;width:100%} .design-reset{margin-top:24px;white-space:normal;width:100%}
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)} .breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
+57 -8
View File
@@ -101,7 +101,7 @@ function controls() {
function renderTree() { function renderTree() {
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null; const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
const query = $('#tree-search').value.trim().toLocaleLowerCase('de'); const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches); const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''} ${block.props?.title || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
const build = blocks => { const build = blocks => {
const list = document.createElement('ul'); list.className = 'structure-list'; const list = document.createElement('ul'); list.className = 'structure-list';
for (const block of blocks) { for (const block of blocks) {
@@ -119,9 +119,10 @@ function renderTree() {
button.className = 'tree-item'; button.type = 'button'; button.draggable = true; button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
button.dataset.blockId = block.id; button.dataset.blockId = block.id;
button.setAttribute('aria-pressed', String(block.id === selected)); button.setAttribute('aria-pressed', String(block.id === selected));
button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`); const displayText = block.props?.text || block.props?.alt || block.props?.title || blockLabel(block);
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText}`);
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block); const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || blockLabel(block); const text = document.createElement('span'); text.textContent = displayText;
button.append(icon, text); item.append(button); button.append(icon, text); item.append(button);
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children)); if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
list.append(item); list.append(item);
@@ -323,9 +324,28 @@ function renderProperties() {
} }
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' }); if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' }); if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
if (block.type === 'accordion') {
const hint = document.createElement('p'); hint.className = 'field-help';
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
for (const [index, entry] of (block.children ?? []).entries()) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'accordion-entry-choice';
button.textContent = `${index + 1}. ${entry.props?.title || 'Ohne Titel'}`;
button.setAttribute('aria-label', `Eintrag ${index + 1}: ${entry.props?.title || 'Ohne Titel'} bearbeiten`);
button.addEventListener('click', () => { propertyPanel = 'content'; choose(entry.id); });
entries.append(button);
}
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
panel.append(hint, entries, add);
}
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 }); 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.' });
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
add.textContent = '+ Inhalt hinzufügen';
add.addEventListener('click', () => beginContainerInsert(block.id));
panel.append(add);
} }
if (block.type === 'legal') { if (block.type === 'legal') {
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' }); field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
@@ -722,14 +742,16 @@ async function updatePreview() {
const id = insert.dataset.editorTarget; const id = insert.dataset.editorTarget;
if (!locate(history.document.blocks, id)) return; if (!locate(history.document.blocks, id)) return;
choose(id, false); choose(id, false);
if (insert.hasAttribute('data-editor-row')) { if (insert.hasAttribute('data-editor-add-entry')) {
addAccordionEntry(id);
} else if (insert.hasAttribute('data-editor-row')) {
setPendingInsert(null); setPendingInsert(null);
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0)); selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
propertyPanel = 'content'; changed(); propertyPanel = 'content'; changed();
} else if (locate(history.document.blocks, id).block.type === 'row') { } else if (locate(history.document.blocks, id).block.type === 'row') {
selected = history.change(content => insertBlockAt(content, 'column', id, 0)); selected = history.change(content => insertBlockAt(content, 'column', id, 0));
changed(); changed();
} else setPendingInsert({ parentId: id, index: 0 }); } else beginContainerInsert(id);
return; return;
} }
if (inlineEdit) return; if (inlineEdit) return;
@@ -857,9 +879,28 @@ function addElement(type) {
selected = history.change(doc => at selected = history.change(doc => at
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected) ? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
: insertBlock(doc, type, selected)); : insertBlock(doc, type, selected));
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
changed(); changed();
} }
function beginContainerInsert(id) {
const block = locate(history.document.blocks, id)?.block;
if (!block || busy) return;
propertyPanel = 'content';
choose(id, false);
setPendingInsert({ parentId: id, index: block.children?.length ?? 0 });
}
function addAccordionEntry(id) {
const block = locate(history.document.blocks, id)?.block;
if (busy || block?.type !== 'accordion') return;
setPendingInsert(null);
selected = history.change(doc => insertBlockAt(doc, 'accordionitem', id, block.children?.length ?? 0));
collapsed.delete(id); propertyPanel = 'content'; changed();
$('#property-title')?.focus();
}
/** Marks where the next added element goes, and says so instead of leaving it invisible. */ /** Marks where the next added element goes, and says so instead of leaving it invisible. */
function setPendingInsert(target) { function setPendingInsert(target) {
pendingInsert = target; pendingInsert = target;
@@ -945,7 +986,11 @@ function updateLibrary() {
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null) ? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
: insertionTarget(history.document, selected, type); : insertionTarget(history.document, selected, type);
button.disabled = !target; button.disabled = !target;
if (!target) { button.title = whyNot(type); continue; } if (!target) {
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
? 'Wähle zuerst einen Akkordeon-Eintrag, um darin Inhalte einzufügen.' : whyNot(type);
continue;
}
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null; const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`; button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
} }
@@ -1092,7 +1137,11 @@ overlay = createCanvasOverlay({
const id = history.change(doc => payload.columns const id = history.change(doc => payload.columns
? insertRowAt(doc, payload.columns, plan.parentId, plan.index) ? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
: insertBlockAt(doc, payload.type, plan.parentId, plan.index)); : insertBlockAt(doc, payload.type, plan.parentId, plan.index));
if (id) { selected = id; changed(); } if (id) {
selected = payload.type === 'accordion' ? locate(history.document.blocks, id)?.block.children?.[0]?.id ?? id : id;
if (['accordion', 'accordionitem'].includes(payload.type)) propertyPanel = 'content';
changed();
}
return; return;
} }
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null); const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
+19 -8
View File
@@ -5,22 +5,28 @@ export function decorateContainers(preview, content) {
const visit = (blocks, parent = null) => { const visit = (blocks, parent = null) => {
blocks.forEach((block, index) => { blocks.forEach((block, index) => {
const node = preview.getElementById(block.id); const node = preview.getElementById(block.id);
if (node && ['section', 'row', 'column'].includes(block.type)) { // Editing must expose the body without changing the visitor's saved open/closed state.
if (node && block.type === 'accordionitem') node.open = true;
if (node && ['section', 'row', 'column', 'accordion', 'accordionitem'].includes(block.type)) {
const label = block.type === 'column' && parent?.type === 'row' const label = block.type === 'column' && parent?.type === 'row'
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block); ? `Spalte ${index + 1} von ${blocks.length}` : block.type === 'accordionitem'
? `Eintrag „${block.props?.title || 'Ohne Titel'}“` : blockLabel(block);
node.classList.add('editor-container'); node.classList.add('editor-container');
node.dataset.editorLabel = label; node.dataset.editorLabel = label;
if (!block.children?.length) { if (!block.children?.length || ['accordion', 'accordionitem'].includes(block.type)) {
node.classList.add('editor-empty-container'); const compact = Boolean(block.children?.length);
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node; if (!compact) node.classList.add('editor-empty-container');
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner')
: block.type === 'accordionitem' ? node.querySelector(':scope > .accordion-body') : node;
if (!host) return; if (!host) return;
const empty = preview.createElement('div'); const empty = preview.createElement('div');
empty.className = 'editor-empty'; empty.className = compact ? 'editor-empty editor-add-content' : 'editor-empty';
const name = preview.createElement('span'); const name = preview.createElement('span');
name.className = 'editor-empty-name'; name.textContent = label; name.className = 'editor-empty-name'; name.textContent = label;
const hint = preview.createElement('span'); const hint = preview.createElement('span');
hint.className = 'editor-empty-hint'; hint.className = 'editor-empty-hint';
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen'; hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen'
: block.type === 'accordion' ? 'Eintrag hinzufügen und darin Inhalte gestalten' : 'Element hierher ziehen oder hinzufügen';
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions'; const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
const add = (text, attribute, value, accessibleName) => { const add = (text, attribute, value, accessibleName) => {
const button = preview.createElement('button'); const button = preview.createElement('button');
@@ -31,10 +37,15 @@ export function decorateContainers(preview, content) {
actions.append(button); actions.append(button);
}; };
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`); add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
if (block.type === 'accordion') {
actions.replaceChildren();
add('+ Eintrag hinzufügen', 'data-editor-add-entry', '', 'Akkordeon: Eintrag hinzufügen');
}
if (block.type === 'section') { if (block.type === 'section') {
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`); for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
} }
empty.append(name, hint, actions); if (!compact) empty.append(name, hint);
empty.append(actions);
host.append(empty); host.append(empty);
} }
} }
+2
View File
@@ -20,4 +20,6 @@
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px} .editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px} .editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858} .editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}} @media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
+136
View File
@@ -0,0 +1,136 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/accordion'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
data.document.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [
{ id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] },
{ id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '<p>Vorhandener Inhalt bleibt.</p>' } }] },
] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const frame = page.frameLocator('#preview');
const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); };
await ready();
await frame.locator('#qa-entry > summary').click();
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document');
await frame.locator('#qa-entry [data-editor-insert]').focus();
await frame.locator('#qa-entry [data-editor-insert]').press('Enter');
assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/);
await page.locator('#library [data-add="text"]').click();
await ready(); await frame.locator('#qa-entry .block-text').waitFor();
const textId = await frame.locator('#qa-entry .block-text').getAttribute('id');
await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready();
assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.');
await page.locator('#undo').click(); await page.locator('#undo').click(); await ready();
assert.equal(await frame.locator(`#${textId}`).count(), 0);
await page.locator('#redo').click(); await page.locator('#redo').click(); await ready();
assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1);
checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo');
await select('qa-accordion');
assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion');
await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click();
await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click();
await page.locator('#library [data-add="button"]').click(); await ready();
const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id');
assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content');
await select('qa-open');
await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } });
await ready(); await frame.locator('#qa-open a.button').waitFor();
const draggedId = await frame.locator('#qa-open a.button').getAttribute('id');
await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0);
await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1);
checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo');
await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click();
await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready();
const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id');
assert.equal(await frame.locator('#qa-accordion > details').count(), 3);
assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true);
await select('qa-empty-accordion');
await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready();
assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1);
await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready();
await page.locator('#tree-search').fill('Ein neuer Eintrag');
assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1);
await page.locator('#tree-search').fill('');
await select('qa-entry'); await page.locator('[data-panel="design"]').click();
await page.locator('#library [data-add="accordion"]').click(); await ready();
assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag');
assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage');
await page.locator('#undo').click(); await ready();
checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await ready(); await select('qa-entry');
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
const saved = await (await context.request.get(api)).json();
const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion');
assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true);
assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]);
assert.ok(!JSON.stringify(saved.document).includes('editor-empty'));
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false });
const visitor = await plain.newPage(); await visitor.goto(origin);
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true);
assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0);
await visitor.locator('#qa-entry > summary').click();
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true);
assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true);
await visitor.locator('#qa-entry > summary').press('Enter');
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
await plain.close();
checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids');
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready();
await page.locator('[data-viewport="mobile"]').click(); await ready();
await frame.locator('#qa-entry').scrollIntoViewIfNeeded();
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true });
}
checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Accordion checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+11
View File
@@ -224,6 +224,17 @@ test('v6 containers accept only their own children and an accordion starts with
assert.equal(canContain(block, { type: 'accordionitem' }), true); assert.equal(canContain(block, { type: 'accordionitem' }), true);
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false); assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
assert.equal(canContain(block.children[0], { type: 'text' }), true); assert.equal(canContain(block.children[0], { type: 'text' }), true);
// Selecting the group must not silently put a text outside it. Choose the entry first.
assert.equal(insertionTarget(history.document, accordion, 'text'), null);
assert.equal(insertBlock(history.document, 'text', accordion), null);
const entry = block.children[0].id;
const text = history.change(doc => insertBlock(doc, 'text', entry));
assert.equal(locate(history.document.blocks, text).parent.id, entry);
history.undo();
assert.equal(locate(history.document.blocks, entry).block.children.length, 0);
history.redo();
assert.equal(locate(history.document.blocks, text).parent.id, entry);
history.undo();
// A logo strip takes images only, like a gallery. // A logo strip takes images only, like a gallery.
const logos = history.change(doc => insertBlock(doc, 'logos', 's2')); const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));