Add device visibility and responsive navigation for 1.10.0
This commit is contained in:
1 parent
eea6747d9e
commit
37c2942d1d
18 files changed
+493
-26
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user