Add device visibility and responsive navigation for 1.10.0
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 14:46:55 +02:00
1 parent eea6747d9e
commit 37c2942d1d
18 files changed
+493 -26

No files matched your search

+15 -1
View File
@@ -255,7 +255,21 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 21;
export const SCHEMA_VERSION = 22;
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
export function setVisibility(document, id, value) {
if (!Object.hasOwn(VISIBILITY, value)) throw new Error('Unbekannte Sichtbarkeit.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
if (value === 'all') {
if (block.layout) delete block.layout.visibility;
} else {
upgradeDocument(document, 22);
(block.layout ??= {}).visibility = value;
}
}
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
export function upgradeDocument(document, version = 2) {
+1
View File
@@ -60,6 +60,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.tree-item:hover{background:#f3f6ef}
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
.tree-icon{font-size:12px;min-width:15px}
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
.drop-target{outline:2px dashed #315b49}
.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}
.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}
+26
View File
@@ -8,6 +8,7 @@ import { preview, rowPreview } from './library-previews.js';
import { decorateContainers } from './preview-containers.js';
import { initMotion } from './motion.js';
import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility } from './editor-model.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -125,6 +126,12 @@ function renderTree() {
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 = displayText;
button.append(icon, text); item.append(button);
if (block.layout?.visibility && block.layout.visibility !== 'all') {
const badge = document.createElement('small'); badge.className = 'tree-visibility';
badge.textContent = VISIBILITY[block.layout.visibility];
button.title = badge.textContent; button.append(badge);
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
}
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
list.append(item);
}
@@ -177,6 +184,19 @@ function renderProperties() {
panel.append(note);
return;
}
if (block) {
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Sichtbarkeit';
const select = document.createElement('select'); select.id = 'block-visibility';
for (const [value, text] of Object.entries(VISIBILITY)) select.append(new Option(text, value));
select.value = block.layout?.visibility ?? 'all';
select.addEventListener('change', () => {
history.change(doc => setVisibility(doc, block.id, select.value));
renderTree(); controls(); schedulePreview();
});
const help = document.createElement('p'); help.className = 'field-help'; help.id = 'visibility-help';
help.textContent = 'Desktop ab 1025 px · Tablet 768–1024 px · Mobil bis 767 px. Ausgeblendete Elemente bleiben links in der Struktur auswählbar. Ein ausgeblendeter Elternbereich blendet auch seinen Inhalt aus.';
select.setAttribute('aria-describedby', help.id); panel.append(label, select, help);
}
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
let imagePreview;
@@ -217,6 +237,7 @@ function renderProperties() {
if (key === 'caption') upgradeDocument(doc, 3);
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
if (key === 'newTab') upgradeDocument(doc, 20);
if (key === 'collapse') upgradeDocument(doc, 22);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, options.choices ? null : `${selected || 'page'}:${key}`);
if (key === 'src') imagePreview?.update({ src: input.value });
@@ -297,6 +318,7 @@ function renderProperties() {
if (block.type === 'menu') {
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
}
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
if (block.type === 'icon') {
@@ -744,6 +766,10 @@ async function updatePreview() {
}
doc.addEventListener('click', event => {
event.preventDefault();
const menuSummary = event.target.closest('.block-menu summary');
if (menuSummary && !busy && !inlineEdit) {
menuSummary.parentElement.open = !menuSummary.parentElement.open;
}
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
stopInlineEdit();
+18
View File
@@ -332,6 +332,24 @@ body.consent-open{padding-bottom:260px}
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
.menu-burger-body{padding:14px 0 4px}
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
/* Only one copy participates in layout, focus order and the accessibility tree. */
.menu-collapse-tablet>.menu-burger,.menu-collapse-mobile>.menu-burger{display:none}
@media (max-width:1024px),(hover:none) and (pointer:coarse){
.menu-collapse-tablet>.menu-burger{display:block}
.menu-collapse-tablet>.menu-list{display:none}
}
@media (max-width:767px){
.menu-collapse-mobile>.menu-burger{display:block}
.menu-collapse-mobile>.menu-list{display:none}
}
.menu-burger>summary{min-height:44px;min-width:44px}
.menu-burger-body{overflow-wrap:anywhere}
/* Visibility never overwrites the block's grid/flex/display settings. */
.visibility-hidden{display:none!important}
.slider-controls button[hidden]{display:none}
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
+12 -5
View File
@@ -11,6 +11,7 @@ export function initSliders(doc = document) {
const seconds = Number(slider.dataset.autoplay || 0);
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
const controls = doc.createElement('div'); controls.className = 'slider-controls';
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
const button = (text, label, action) => {
const node = doc.createElement('button'); node.type = 'button'; node.textContent = text;
@@ -19,18 +20,22 @@ export function initSliders(doc = document) {
controls.append(node); return node;
};
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const available = () => slides.map((slide, i) => slide.getClientRects().length ? i : -1).filter(i => i >= 0);
const active = () => {
let index = 0, distance = Infinity;
slides.forEach((slide, i) => { const delta = Math.abs(left(slide) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
available().forEach(i => { const delta = Math.abs(left(slides[i]) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
return index;
};
const go = index => {
if (!available().includes(index)) return;
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
};
const step = direction => {
const visible = available(), position = visible.indexOf(active());
if (!visible.length) return;
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
const first = slider.scrollLeft <= 2;
go(direction > 0 ? (last ? 0 : Math.min(slides.length - 1, active() + 1)) : (first ? slides.length - 1 : Math.max(0, active() - 1)));
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
};
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1)));
@@ -45,10 +50,11 @@ export function initSliders(doc = document) {
}
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
const update = () => {
const fits = slider.scrollWidth <= slider.clientWidth + 2;
const visible = available();
const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2;
controls.hidden = fits; previous.disabled = next.disabled = fits;
const index = active(); count.textContent = `${index + 1} / ${slides.length}`;
dots.forEach((dot, i) => dot.setAttribute('aria-pressed', String(i === index)));
const index = active(); count.textContent = `${Math.max(0, visible.indexOf(index) + 1)} / ${visible.length}`;
dots.forEach((dot, i) => { dot.hidden = !visible.includes(i); dot.setAttribute('aria-pressed', String(i === index)); });
if (play) {
play.textContent = playing ? 'Pause' : 'Wiedergabe';
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
@@ -62,6 +68,7 @@ export function initSliders(doc = document) {
const pause = () => { playing = false; update(); clearTimeout(timer); };
slider.after(controls); slider.classList.add('slider-controlled'); update();
slider.addEventListener('scroll', update, { passive: true, signal });
view.addEventListener('resize', update, { passive: true, signal });
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal });
for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal });
+11 -3
View File
@@ -25,14 +25,19 @@
panels[index].querySelector('.tabs-heading')?.classList.add('visually-hidden');
});
const available = () => links.map((link, i) => getComputedStyle(link.parentElement).display !== 'none' ? i : -1).filter(i => i >= 0);
let selected = -1;
const select = (index, moveFocus = true) => {
const visible = available();
if (!visible.includes(index)) index = visible[0] ?? -1;
selected = index;
links.forEach((link, i) => {
const current = i === index;
link.setAttribute('aria-selected', String(current));
link.tabIndex = current ? 0 : -1;
panels[i].hidden = !current;
});
if (moveFocus) links[index].focus();
if (moveFocus && index >= 0) links[index].focus();
};
list.addEventListener('click', event => {
@@ -46,15 +51,18 @@
list.addEventListener('keydown', event => {
const index = links.indexOf(event.target.closest('a'));
if (index === -1) return;
const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: links.length - 1 };
const visible = available(), position = visible.indexOf(index);
if (!visible.length) return;
const moves = { ArrowRight: position + 1, ArrowLeft: position - 1, Home: 0, End: visible.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select((moves[event.key] + links.length) % links.length);
select(visible[(moves[event.key] + visible.length) % visible.length]);
});
// A link from elsewhere may point straight at one panel.
const deepLink = panels.findIndex(panel => panel.id === location.hash.slice(1));
select(deepLink === -1 ? 0 : deepLink, false);
window.addEventListener('resize', () => select(selected, false));
group.classList.add('tabs-ready');
};