diff --git a/public/js/legal-ui.js b/public/js/legal-ui.js index 156d6fc..7ac1f8e 100644 --- a/public/js/legal-ui.js +++ b/public/js/legal-ui.js @@ -1,18 +1,22 @@ (function (ns) { 'use strict'; - const dialog = document.getElementById('info-dialog'); - const title = document.getElementById('info-dialog-title'); - const menu = document.getElementById('info-menu'); - const backButton = document.getElementById('info-back'); - const closeButton = document.getElementById('info-close'); - const infoButton = document.getElementById('info-button'); const pageTitles = Object.freeze({ privacy: 'Datenschutzerklärung', imprint: 'Impressum', technology: 'Cookies und Technik' }); + const menuEntries = Object.freeze([ + ['privacy', 'Datenschutzerklärung'], + ['imprint', 'Impressum'], + ['technology', 'Cookies und verwendete JavaScripts'] + ]); + + let elements = null; + let dropdownOpen = false; + let documentEventsBound = false; + function ready(callback) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', callback, { once: true }); @@ -21,7 +25,22 @@ callback(); } + function byId(id) { + return typeof document.getElementById === 'function' ? document.getElementById(id) : null; + } + + function qs(selector, root = document) { + return root && typeof root.querySelector === 'function' ? root.querySelector(selector) : null; + } + + function qsa(selector, root = document) { + if (root && typeof root.querySelectorAll === 'function') return Array.from(root.querySelectorAll(selector)); + if (root !== document && typeof document.querySelectorAll === 'function') return Array.from(document.querySelectorAll(selector)); + return []; + } + function createElement(tagName, attributes = {}, text = '') { + if (typeof document.createElement !== 'function') return null; const element = document.createElement(tagName); Object.entries(attributes).forEach(([name, value]) => { if (value === null || value === undefined) return; @@ -41,20 +60,52 @@ return element; } - function ensureInfoControl() { - let control = document.querySelector('.info-control'); - let button = document.getElementById('info-button') - ?? document.querySelector('[aria-controls="info-dropdown"], .info-button'); - let dropdown = document.getElementById('info-dropdown') - ?? document.querySelector('.info-dropdown'); + function setAttribute(element, name, value) { + if (!element || typeof element.setAttribute !== 'function') return; + element.setAttribute(name, value); + } - if (!control) { + function toggleClass(element, className, enabled) { + if (!element || !element.classList) return; + if (typeof element.classList.toggle === 'function') { + element.classList.toggle(className, enabled); + return; + } + if (enabled && typeof element.classList.add === 'function') element.classList.add(className); + if (!enabled && typeof element.classList.remove === 'function') element.classList.remove(className); + } + + function append(parent, child) { + if (!parent || !child || typeof parent.appendChild !== 'function') return false; + parent.appendChild(child); + return true; + } + + function prependToBody(element) { + if (!document.body || !element) return false; + if (typeof document.body.prepend === 'function') { + document.body.prepend(element); + return true; + } + if (typeof document.body.insertBefore === 'function') { + document.body.insertBefore(element, document.body.firstChild ?? null); + return true; + } + return append(document.body, element); + } + + function ensureInfoControl() { + let control = qs('.info-control'); + let infoButton = byId('info-button') || qs('.info-button'); + let dropdown = byId('info-dropdown') || qs('.info-dropdown'); + + if (!control && typeof document.createElement === 'function') { control = createElement('div', { className: 'info-control' }); - document.body.insertBefore(control, document.body.firstChild); + prependToBody(control); } - if (!button) { - button = createElement('button', { + if (!infoButton && control) { + infoButton = createElement('button', { id: 'info-button', className: 'info-button', type: 'button', @@ -62,217 +113,226 @@ title: 'Informationen' }); const icon = createElement('span', { 'aria-hidden': 'true' }, 'i'); - button.appendChild(icon); + append(infoButton, icon); + append(control, infoButton); } - if (button.parentElement !== control) { - control.insertBefore(button, control.firstChild); - } - - button.id = 'info-button'; - button.classList.add('info-button'); - button.setAttribute('type', 'button'); - button.setAttribute('aria-haspopup', 'true'); - button.setAttribute('aria-expanded', 'false'); - button.setAttribute('aria-controls', 'info-dropdown'); - if (!button.getAttribute('aria-label')) button.setAttribute('aria-label', 'Informationsmenü öffnen'); - if (!button.textContent.trim()) { - const icon = createElement('span', { 'aria-hidden': 'true' }, 'i'); - button.appendChild(icon); - } - - if (!dropdown) { + if (!dropdown && control) { dropdown = createElement('nav', { id: 'info-dropdown', className: 'info-dropdown', 'aria-label': 'Rechtliche und technische Informationen' }); + append(control, dropdown); } - if (dropdown.parentElement !== control) { - control.appendChild(dropdown); - } - - dropdown.id = 'info-dropdown'; - dropdown.classList.add('info-dropdown'); - dropdown.setAttribute('aria-label', dropdown.getAttribute('aria-label') ?? 'Rechtliche und technische Informationen'); - dropdown.hidden = true; - - menuEntries.forEach(([target, label]) => { - let menuButton = dropdown.querySelector(`[data-info-target="${target}"]`); - if (!menuButton) { - menuButton = createElement('button', { - type: 'button', - dataset: { infoTarget: target } - }, label); - dropdown.appendChild(menuButton); + if (infoButton) { + setAttribute(infoButton, 'type', 'button'); + setAttribute(infoButton, 'aria-haspopup', 'true'); + setAttribute(infoButton, 'aria-expanded', 'false'); + setAttribute(infoButton, 'aria-controls', 'info-dropdown'); + if (typeof infoButton.getAttribute !== 'function' || !infoButton.getAttribute('aria-label')) { + setAttribute(infoButton, 'aria-label', 'Informationsmenü öffnen'); } - menuButton.setAttribute('type', 'button'); - menuButton.dataset.infoTarget = target; - if (!menuButton.textContent.trim()) menuButton.textContent = label; - }); + if (!String(infoButton.textContent ?? '').trim() && typeof document.createElement === 'function') { + append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i')); + } + } - return { control, button, dropdown }; - } + if (dropdown) { + setAttribute(dropdown, 'aria-label', 'Rechtliche und technische Informationen'); + dropdown.hidden = true; - function moveExistingPagesInto(content, dialog) { - dialog.querySelectorAll('[data-info-page]').forEach(page => { - if (page.parentElement !== content) content.appendChild(page); - }); + menuEntries.forEach(([target, label]) => { + let button = qs(`[data-info-target="${target}"]`, dropdown) + || qsa('[data-info-target]').find(item => item.dataset?.infoTarget === target); + if (!button && typeof document.createElement === 'function') { + button = createElement('button', { + type: 'button', + dataset: { infoTarget: target } + }, label); + append(dropdown, button); + } + if (!button) return; + setAttribute(button, 'type', 'button'); + button.dataset.infoTarget = target; + if (!String(button.textContent ?? '').trim()) button.textContent = label; + }); + } + + return { control, infoButton, dropdown }; } function ensureInfoPage(content, name) { - let page = content.querySelector(`[data-info-page="${name}"]`) - ?? document.querySelector(`[data-info-page="${name}"]`); - - if (!page) { + let page = qs(`[data-info-page="${name}"]`, content) || qs(`[data-info-page="${name}"]`); + if (!page && content) { page = createElement('article', { className: 'info-page', dataset: { infoPage: name } }); - page.appendChild(createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`)); - content.appendChild(page); + append(page, createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`)); + append(content, page); + } + if (page) { + page.dataset.infoPage = name; + if (name !== 'privacy') page.hidden = true; } - - page.classList.add('info-page'); - page.dataset.infoPage = name; - page.hidden = true; - if (page.parentElement !== content) content.appendChild(page); return page; } function ensureInfoDialog() { - let dialog = document.getElementById('info-dialog'); - if (!dialog) { + let dialog = byId('info-dialog') || qs('#info-dialog'); + let title = byId('info-dialog-title') || qs('#info-dialog-title', dialog); + let closeButton = byId('info-close') || qs('#info-close', dialog); + let content = qs('.info-dialog-content', dialog) || qs('.info-dialog-content'); + + if (!dialog && typeof document.createElement === 'function') { dialog = createElement('dialog', { id: 'info-dialog', className: 'info-dialog', 'aria-labelledby': 'info-dialog-title' }); - document.body.appendChild(dialog); + append(document.body, dialog); } - dialog.id = 'info-dialog'; - dialog.classList.add('info-dialog'); - dialog.setAttribute('aria-labelledby', 'info-dialog-title'); + if (dialog && (!title || !closeButton || !content) && typeof document.createElement === 'function') { + let shell = qs('.info-dialog-shell', dialog); + if (!shell) { + shell = createElement('div', { className: 'info-dialog-shell' }); + append(dialog, shell); + } - let shell = dialog.querySelector('.info-dialog-shell'); - if (!shell) { - shell = createElement('div', { className: 'info-dialog-shell' }); - while (dialog.firstChild) shell.appendChild(dialog.firstChild); - dialog.appendChild(shell); + let header = qs('.info-dialog-header', shell); + if (!header) { + header = createElement('header', { className: 'info-dialog-header' }); + append(shell, header); + } + + if (!title) { + title = createElement('h2', { id: 'info-dialog-title' }, pageTitles.privacy); + append(header, title); + } + + if (!closeButton) { + closeButton = createElement('button', { + id: 'info-close', + type: 'button', + 'aria-label': 'Informationsfenster schließen', + title: 'Schließen' + }, '×'); + append(header, closeButton); + } + + if (!content) { + content = createElement('div', { className: 'info-dialog-content' }); + append(shell, content); + } } - let header = shell.querySelector('.info-dialog-header'); - if (!header) { - header = createElement('header', { className: 'info-dialog-header' }); - shell.insertBefore(header, shell.firstChild); + if (dialog) { + setAttribute(dialog, 'aria-labelledby', 'info-dialog-title'); + } + if (closeButton) { + setAttribute(closeButton, 'type', 'button'); + setAttribute(closeButton, 'aria-label', 'Informationsfenster schließen'); + if (!String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×'; } - let title = document.getElementById('info-dialog-title') - ?? header.querySelector('h1, h2, .info-dialog-title'); - if (!title) { - title = createElement('h2', { id: 'info-dialog-title' }, 'Informationen'); - header.insertBefore(title, header.firstChild); + if (content) { + Object.keys(pageTitles).forEach(name => ensureInfoPage(content, name)); } - title.id = 'info-dialog-title'; - if (!title.textContent.trim()) title.textContent = 'Informationen'; - - let closeButton = document.getElementById('info-close') - ?? header.querySelector('[data-info-close], .info-close, .info-dialog-close, button[aria-label="Dialog schließen"], button[aria-label="Schließen"]'); - if (!closeButton) { - closeButton = createElement('button', { - id: 'info-close', - type: 'button', - 'aria-label': 'Informationsfenster schließen', - title: 'Schließen' - }, '×'); - header.appendChild(closeButton); - } - closeButton.id = 'info-close'; - closeButton.setAttribute('type', 'button'); - closeButton.setAttribute('aria-label', closeButton.getAttribute('aria-label') ?? 'Informationsfenster schließen'); - closeButton.setAttribute('title', closeButton.getAttribute('title') ?? 'Schließen'); - if (!closeButton.textContent.trim()) closeButton.textContent = '×'; - if (closeButton.parentElement !== header) header.appendChild(closeButton); - - let content = shell.querySelector('.info-dialog-content'); - if (!content) { - content = createElement('div', { className: 'info-dialog-content' }); - shell.appendChild(content); - } - - moveExistingPagesInto(content, dialog); - Object.keys(pageTitles).forEach(name => ensureInfoPage(content, name)); return { dialog, title, closeButton, content }; } function ensureInfoUi() { - const control = ensureInfoControl(); - const dialog = ensureInfoDialog(); return { - control: control.control, - infoButton: control.button, - dropdown: control.dropdown, - dialog: dialog.dialog, - title: dialog.title, - closeButton: dialog.closeButton, - content: dialog.content + ...ensureInfoControl(), + ...ensureInfoDialog() }; } function isActivation(event) { + if (!event) return false; + if (event.type === 'pointerup') { + if (event.isPrimary === false) return false; + if (event.pointerType === 'mouse' && event.button !== 0) return false; + return true; + } if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' '; - if (event.type !== 'pointerup') return false; - if (event.isPrimary === false) return false; - return event.pointerType !== 'mouse' || event.button === 0; + return false; } function activate(element, handler) { + if (!element || typeof element.addEventListener !== 'function') return; + if (element.dataset) { + const key = handler.name ? `wegwichtelLegalUiBound${handler.name}` : 'wegwichtelLegalUiBound'; + if (element.dataset[key] === 'true') return; + element.dataset[key] = 'true'; + } element.addEventListener('pointerup', event => { if (!isActivation(event)) return; - event.preventDefault(); - event.stopPropagation(); + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); handler(event); }); element.addEventListener('keydown', event => { if (!isActivation(event)) return; - event.preventDefault(); - event.stopPropagation(); + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); handler(event); }); } - function syncStorageToggles() { - const enabled = isProgressStorageEnabled(); - document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { - toggle.checked = enabled; - }); - document.querySelectorAll('.cookie-toggle').forEach(toggle => { - toggle.checked = false; - }); + function setDropdownOpen(open) { + if (!elements?.dropdown || !elements?.infoButton) return; + dropdownOpen = Boolean(open); + elements.dropdown.hidden = !dropdownOpen; + toggleClass(elements.dropdown, 'is-open', dropdownOpen); + setAttribute(elements.infoButton, 'aria-expanded', dropdownOpen ? 'true' : 'false'); } - function showMenu() { - title.textContent = 'Informationen'; - menu.hidden = false; - backButton.hidden = true; - document.querySelectorAll('[data-info-page]').forEach(page => { - page.hidden = true; + function toggleDropdown() { + setDropdownOpen(!dropdownOpen); + } + + function elementContains(element, target) { + if (!element || !target) return false; + if (element === target) return true; + return typeof element.contains === 'function' && element.contains(target); + } + + function isInsideInfoControl(target) { + return elementContains(elements?.infoButton, target) || elementContains(elements?.dropdown, target); + } + + function isProgressStorageEnabled() { + if (!ns.PrivacyStorage || typeof ns.PrivacyStorage.isProgressStorageEnabled !== 'function') return false; + return ns.PrivacyStorage.isProgressStorageEnabled(); + } + + function setProgressStorageEnabled(enabled) { + if (!ns.PrivacyStorage || typeof ns.PrivacyStorage.setProgressStorageEnabled !== 'function') return false; + return ns.PrivacyStorage.setProgressStorageEnabled(enabled); + } + + function syncStorageToggles() { + const enabled = isProgressStorageEnabled(); + qsa('.progress-storage-toggle').forEach(toggle => { + toggle.checked = enabled; + }); + qsa('.cookie-toggle').forEach(toggle => { + toggle.checked = false; }); } function showPage(name) { const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy'; - const page = elements?.content?.querySelector(`[data-info-page="${pageName}"]`) - ?? document.querySelector(`[data-info-page="${pageName}"]`); + const page = qs(`[data-info-page="${pageName}"]`, elements?.content) || qs(`[data-info-page="${pageName}"]`); if (!page) return; - title.textContent = pageTitles[name] ?? 'Informationen'; - menu.hidden = true; - backButton.hidden = false; - document.querySelectorAll('[data-info-page]').forEach(item => { + if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? 'Informationen'; + qsa('[data-info-page]').forEach(item => { item.hidden = item !== page; }); if (elements?.content) elements.content.scrollTop = 0; @@ -287,25 +347,32 @@ } } - function openDialog() { - if (dialog.open) return; - showMenu(); + function openDialog(name = 'privacy') { + if (!elements?.dialog) return; + const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy'; + setDropdownOpen(false); + showPage(pageName); syncStorageToggles(); - if (typeof dialog.showModal === 'function') dialog.showModal(); - else dialog.setAttribute('open', ''); - closeButton.focus(); + if (!elements.dialog.open) { + if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal(); + else setAttribute(elements.dialog, 'open', ''); + } + focusSafely(elements.closeButton ?? elements.dialog); } function closeDialog() { - if (typeof dialog.close === 'function') dialog.close(); - else dialog.removeAttribute('open'); - infoButton.focus(); + if (elements?.dialog) { + if (typeof elements.dialog.close === 'function') elements.dialog.close(); + else if (typeof elements.dialog.removeAttribute === 'function') elements.dialog.removeAttribute('open'); + } + setDropdownOpen(false); + focusSafely(elements?.infoButton); } function bindStorageToggles() { - document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { - if (toggle.dataset.wegwichtelStorageBound === 'true') return; - toggle.dataset.wegwichtelStorageBound = 'true'; + qsa('.progress-storage-toggle').forEach(toggle => { + if (toggle.dataset?.wegwichtelStorageBound === 'true') return; + if (toggle.dataset) toggle.dataset.wegwichtelStorageBound = 'true'; toggle.addEventListener('change', event => { const enabled = setProgressStorageEnabled(event.currentTarget.checked); syncStorageToggles(); @@ -316,23 +383,38 @@ }); } - activate(infoButton, openDialog); - activate(closeButton, closeDialog); - activate(backButton, showMenu); - document.querySelectorAll('[data-info-target]').forEach(button => { - activate(button, () => showPage(button.dataset.infoTarget)); - }); + function bindDialog() { + activate(elements?.infoButton, toggleDropdown); + activate(elements?.closeButton, closeDialog); - if (elements.dialog.dataset.wegwichtelLegalUiDialogBound !== 'true') { - elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true'; + qsa('[data-info-target]', elements?.dropdown).forEach(button => { + activate(button, () => openDialog(button.dataset?.infoTarget)); + }); + + if (!documentEventsBound) { + documentEventsBound = true; + document.addEventListener('pointerup', event => { + if (!dropdownOpen || isInsideInfoControl(event.target)) return; + setDropdownOpen(false); + }); + document.addEventListener('keydown', event => { + if (!dropdownOpen || event.key !== 'Escape' || elements?.dialog?.open) return; + if (typeof event.preventDefault === 'function') event.preventDefault(); + setDropdownOpen(false); + focusSafely(elements?.infoButton); + }); + } + + if (elements?.dialog && elements.dialog.dataset?.wegwichtelLegalUiDialogBound !== 'true') { + if (elements.dialog.dataset) elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true'; elements.dialog.addEventListener('pointerup', event => { if (event.target !== elements.dialog || !isActivation(event)) return; - event.preventDefault(); - event.stopPropagation(); + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); closeDialog(); }); elements.dialog.addEventListener('cancel', event => { - event.preventDefault(); + if (typeof event.preventDefault === 'function') event.preventDefault(); closeDialog(); }); } @@ -353,5 +435,5 @@ }); } - ns.LegalUi = Object.freeze({ open: openDialog, close: closeDialog, syncStorageToggles }); -}(globalThis.Wegwichtel ??= {})); + ready(init); +}(globalThis.Wegwichtel = globalThis.Wegwichtel || {}));