diff --git a/public/js/legal-ui.js b/public/js/legal-ui.js index 9c1939c..3367119 100644 --- a/public/js/legal-ui.js +++ b/public/js/legal-ui.js @@ -7,24 +7,225 @@ imprint: 'Impressum', technology: 'Cookies und Technik' }); + const menuEntries = Object.freeze([ + ['privacy', 'Datenschutzerklärung'], + ['imprint', 'Impressum'], + ['technology', 'Cookies und verwendete JavaScripts'] + ]); - const dialog = document.getElementById('info-dialog'); - const title = document.getElementById('info-dialog-title') - ?? dialog?.querySelector('h1, h2, .info-dialog-title') - ?? null; - const dropdown = document.getElementById('info-dropdown') - ?? document.querySelector('.info-dropdown') - ?? null; - const infoButton = document.getElementById('info-button') - ?? document.querySelector('[aria-controls="info-dropdown"], .info-button') - ?? null; - const closeButton = document.getElementById('info-close') - ?? dialog?.querySelector('[data-info-close], .info-close, .info-dialog-close, button[aria-label="Dialog schließen"], button[aria-label="Schließen"]') - ?? null; - + let elements = null; let dropdownOpen = false; let dropdownCloseTimer = null; + function ready(callback) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback, { once: true }); + return; + } + callback(); + } + + function createElement(tagName, attributes = {}, text = '') { + const element = document.createElement(tagName); + Object.entries(attributes).forEach(([name, value]) => { + if (value === null || value === undefined) return; + if (name === 'className') { + element.className = value; + return; + } + if (name === 'dataset') { + Object.entries(value).forEach(([key, dataValue]) => { + element.dataset[key] = dataValue; + }); + return; + } + element.setAttribute(name, value); + }); + if (text) element.textContent = text; + 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'); + + if (!control) { + control = createElement('div', { className: 'info-control' }); + document.body.insertBefore(control, document.body.firstChild); + } + + if (!button) { + button = createElement('button', { + id: 'info-button', + className: 'info-button', + type: 'button', + 'aria-label': 'Informationsmenü öffnen', + title: 'Informationen' + }); + const icon = createElement('span', { 'aria-hidden': 'true' }, 'i'); + button.appendChild(icon); + } + + 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) { + dropdown = createElement('nav', { + id: 'info-dropdown', + className: 'info-dropdown', + 'aria-label': 'Rechtliche und technische Informationen' + }); + } + + 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); + } + menuButton.setAttribute('type', 'button'); + menuButton.dataset.infoTarget = target; + if (!menuButton.textContent.trim()) menuButton.textContent = label; + }); + + return { control, button, dropdown }; + } + + function moveExistingPagesInto(content, dialog) { + dialog.querySelectorAll('[data-info-page]').forEach(page => { + if (page.parentElement !== content) content.appendChild(page); + }); + } + + function ensureInfoPage(content, name) { + let page = content.querySelector(`[data-info-page="${name}"]`) + ?? document.querySelector(`[data-info-page="${name}"]`); + + if (!page) { + page = createElement('article', { + className: 'info-page', + dataset: { infoPage: name } + }); + page.appendChild(createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`)); + content.appendChild(page); + } + + 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) { + dialog = createElement('dialog', { + id: 'info-dialog', + className: 'info-dialog', + 'aria-labelledby': 'info-dialog-title' + }); + document.body.appendChild(dialog); + } + + dialog.id = 'info-dialog'; + dialog.classList.add('info-dialog'); + dialog.setAttribute('aria-labelledby', 'info-dialog-title'); + + 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 = shell.querySelector('.info-dialog-header'); + if (!header) { + header = createElement('header', { className: 'info-dialog-header' }); + shell.insertBefore(header, shell.firstChild); + } + + 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); + } + 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 + }; + } + function isActivation(event) { if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' '; if (event.type !== 'pointerup') return false; @@ -34,6 +235,8 @@ function activate(element, handler) { if (!element || typeof element.addEventListener !== 'function') return; + if (element.dataset.wegwichtelLegalUiBound === 'true') return; + element.dataset.wegwichtelLegalUiBound = 'true'; element.addEventListener('pointerup', event => { if (!isActivation(event)) return; event.preventDefault(); @@ -73,22 +276,23 @@ } function setDropdownOpen(open) { - if (!dropdown || !infoButton) return; + if (!elements?.dropdown || !elements?.infoButton) return; dropdownOpen = open; - infoButton.setAttribute('aria-expanded', open ? 'true' : 'false'); + elements.infoButton.setAttribute('aria-expanded', open ? 'true' : 'false'); + elements.infoButton.setAttribute('aria-label', open ? 'Informationsmenü schließen' : 'Informationsmenü öffnen'); cancelSchedule(dropdownCloseTimer); dropdownCloseTimer = null; if (open) { - dropdown.hidden = false; - runNextFrame(() => setClass(dropdown, 'is-open', true)); + elements.dropdown.hidden = false; + runNextFrame(() => setClass(elements.dropdown, 'is-open', true)); return; } - setClass(dropdown, 'is-open', false); + setClass(elements.dropdown, 'is-open', false); dropdownCloseTimer = schedule(() => { - if (dropdownOpen) return; - dropdown.hidden = true; + if (dropdownOpen || !elements?.dropdown) return; + elements.dropdown.hidden = true; }, DROPDOWN_ANIMATION_MS); } @@ -103,7 +307,7 @@ } function isInsideInfoControl(target) { - return elementContains(infoButton, target) || elementContains(dropdown, target); + return elementContains(elements?.infoButton, target) || elementContains(elements?.dropdown, target); } function isProgressStorageEnabled() { @@ -127,46 +331,53 @@ } function showPage(name) { - const page = document.querySelector(`[data-info-page="${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}"]`); if (!page) return; - if (title) title.textContent = pageTitles[name] ?? 'Informationen'; + if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? 'Informationen'; document.querySelectorAll('[data-info-page]').forEach(item => { item.hidden = item !== page; }); - const content = document.querySelector('.info-dialog-content'); - if (content) content.scrollTop = 0; + if (elements?.content) elements.content.scrollTop = 0; } function focusSafely(element) { if (!element || typeof element.focus !== 'function') return; - element.focus({ preventScroll: true }); + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } } function openDialog(name = 'privacy') { - if (!dialog) return; + if (!elements?.dialog) return; const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy'; setDropdownOpen(false); showPage(pageName); syncStorageToggles(); - if (!dialog.open) { - if (typeof dialog.showModal === 'function') dialog.showModal(); - else dialog.setAttribute('open', ''); + if (!elements.dialog.open) { + if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal(); + else elements.dialog.setAttribute('open', ''); } - focusSafely(closeButton ?? dialog); + focusSafely(elements.closeButton ?? elements.dialog); } function closeDialog() { - if (dialog) { - if (typeof dialog.close === 'function') dialog.close(); - else dialog.removeAttribute('open'); + if (elements?.dialog) { + if (typeof elements.dialog.close === 'function') elements.dialog.close(); + else elements.dialog.removeAttribute('open'); } setDropdownOpen(false); - focusSafely(infoButton); + focusSafely(elements?.infoButton); } function bindStorageToggles() { document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { + if (toggle.dataset.wegwichtelStorageBound === 'true') return; + toggle.dataset.wegwichtelStorageBound = 'true'; toggle.addEventListener('change', event => { const enabled = setProgressStorageEnabled(event.currentTarget.checked); syncStorageToggles(); @@ -177,44 +388,79 @@ }); } - activate(infoButton, toggleDropdown); - activate(closeButton, closeDialog); - document.querySelectorAll('[data-info-target]').forEach(button => { - activate(button, () => openDialog(button.dataset.infoTarget)); - }); - - document.addEventListener('pointerup', event => { - if (!dropdownOpen || isInsideInfoControl(event.target)) return; - setDropdownOpen(false); - }); - document.addEventListener('keydown', event => { - if (!dropdownOpen || event.key !== 'Escape' || dialog?.open) return; - event.preventDefault(); - setDropdownOpen(false); - focusSafely(infoButton); - }); - - if (dialog) { - dialog.addEventListener('pointerup', event => { - if (event.target !== dialog || !isActivation(event)) return; - event.preventDefault(); - event.stopPropagation(); - closeDialog(); + function bindDialog() { + activate(elements.infoButton, toggleDropdown); + activate(elements.closeButton, closeDialog); + elements.dropdown.querySelectorAll('[data-info-target]').forEach(button => { + activate(button, () => openDialog(button.dataset.infoTarget)); }); - dialog.addEventListener('cancel', event => { - event.preventDefault(); - closeDialog(); + + if (!document.documentElement.dataset.wegwichtelLegalUiDocumentBound) { + document.documentElement.dataset.wegwichtelLegalUiDocumentBound = '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; + event.preventDefault(); + setDropdownOpen(false); + focusSafely(elements?.infoButton); + }); + } + + if (elements.dialog.dataset.wegwichtelLegalUiDialogBound !== 'true') { + elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true'; + elements.dialog.addEventListener('pointerup', event => { + if (event.target !== elements.dialog || !isActivation(event)) return; + event.preventDefault(); + event.stopPropagation(); + closeDialog(); + }); + elements.dialog.addEventListener('cancel', event => { + event.preventDefault(); + closeDialog(); + }); + } + } + + function init() { + elements = ensureInfoUi(); + bindDialog(); + bindStorageToggles(); + syncStorageToggles(); + + ns.LegalUi = Object.freeze({ + open: openDialog, + close: closeDialog, + toggleMenu: toggleDropdown, + closeMenu: () => setDropdownOpen(false), + syncStorageToggles }); } - bindStorageToggles(); - syncStorageToggles(); - ns.LegalUi = Object.freeze({ - open: openDialog, - close: closeDialog, - toggleMenu: toggleDropdown, - closeMenu: () => setDropdownOpen(false), - syncStorageToggles + open: name => ready(() => { + if (!elements) init(); + openDialog(name); + }), + close: () => ready(() => { + if (!elements) init(); + closeDialog(); + }), + toggleMenu: () => ready(() => { + if (!elements) init(); + toggleDropdown(); + }), + closeMenu: () => ready(() => { + if (!elements) init(); + setDropdownOpen(false); + }), + syncStorageToggles: () => ready(() => { + if (!elements) init(); + syncStorageToggles(); + }) }); + + ready(init); }(globalThis.Wegwichtel ??= {}));