571 lines
19 KiB
JavaScript
571 lines
19 KiB
JavaScript
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||
|
||
(function (ns) {
|
||
'use strict';
|
||
|
||
const DROPDOWN_ANIMATION_MS = 180;
|
||
const pageTitles = Object.freeze({
|
||
privacy: 'Datenschutzerklärung',
|
||
imprint: 'Impressum',
|
||
get technology() { return ns.I18n.t("info.storage"); }
|
||
});
|
||
function menuEntries() { return [
|
||
['privacy', ns.I18n.t("info.privacy")],
|
||
['imprint', ns.I18n.t("info.imprint")],
|
||
['technology', ns.I18n.t("info.storage")]
|
||
]; }
|
||
const selectors = Object.freeze({
|
||
content: '.info-dialog-content',
|
||
cookieToggle: '.cookie-toggle',
|
||
infoControl: '.info-control',
|
||
infoDropdown: '.info-dropdown',
|
||
infoPage: '[data-info-page]',
|
||
infoTarget: '[data-info-target]',
|
||
progressToggle: '.progress-storage-toggle'
|
||
});
|
||
|
||
let elements = null;
|
||
let dropdownOpen = false;
|
||
let dropdownCloseTimer = null;
|
||
let documentEventsBound = false;
|
||
|
||
function ready(callback) {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||
return;
|
||
}
|
||
callback();
|
||
}
|
||
|
||
function byId(id) {
|
||
return typeof document.getElementById === 'function' ? document.getElementById(id) : null;
|
||
}
|
||
|
||
function qs(selector, root = document) {
|
||
return typeof root?.querySelector === 'function' ? root.querySelector(selector) : null;
|
||
}
|
||
|
||
function qsa(selector, root = document) {
|
||
if (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]) => assignAttribute(element, name, value));
|
||
if (text) element.textContent = text;
|
||
return element;
|
||
}
|
||
|
||
function assignAttribute(element, 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);
|
||
}
|
||
|
||
function setAttribute(element, name, value) {
|
||
if (typeof element?.setAttribute !== 'function') return;
|
||
element.setAttribute(name, value);
|
||
}
|
||
|
||
function setHidden(element, hidden) {
|
||
if (!element) return;
|
||
element.hidden = Boolean(hidden);
|
||
}
|
||
|
||
function setSwitchState(toggle, checked) {
|
||
if (!toggle) return;
|
||
toggle.checked = Boolean(checked);
|
||
setAttribute(toggle, 'aria-checked', checked ? 'true' : 'false');
|
||
}
|
||
|
||
function toggleClass(element, className, enabled) {
|
||
if (typeof element?.classList?.toggle === 'function') {
|
||
element.classList.toggle(className, enabled);
|
||
}
|
||
}
|
||
|
||
function append(parent, child) {
|
||
if (!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 runNextFrame(callback) {
|
||
if (typeof globalThis.requestAnimationFrame === 'function') {
|
||
globalThis.requestAnimationFrame(callback);
|
||
return;
|
||
}
|
||
callback();
|
||
}
|
||
|
||
function schedule(callback, timeout) {
|
||
if (typeof globalThis.setTimeout !== 'function') {
|
||
callback();
|
||
return null;
|
||
}
|
||
return globalThis.setTimeout(callback, timeout);
|
||
}
|
||
|
||
function cancelSchedule(timer) {
|
||
if (timer !== null && typeof globalThis.clearTimeout === 'function') globalThis.clearTimeout(timer);
|
||
}
|
||
|
||
function ensureInfoControl() {
|
||
const control = ensureControlContainer();
|
||
const infoButton = ensureInfoButton(control);
|
||
const dropdown = ensureDropdown(control);
|
||
configureInfoButton(infoButton);
|
||
configureDropdown(dropdown);
|
||
return { control, infoButton, dropdown };
|
||
}
|
||
|
||
function ensureControlContainer() {
|
||
const existing = qs(selectors.infoControl);
|
||
if (existing || typeof document.createElement !== 'function') return existing;
|
||
const control = createElement('div', { className: 'info-control' });
|
||
prependToBody(control);
|
||
return control;
|
||
}
|
||
|
||
function ensureInfoButton(control) {
|
||
const existing = byId('info-button') || qs('.info-button');
|
||
if (existing || !control) return existing;
|
||
const infoButton = createElement('button', {
|
||
id: 'info-button',
|
||
className: 'info-button',
|
||
type: 'button',
|
||
'aria-label': ns.I18n.t("info.open"),
|
||
title: ns.I18n.t("info.title")
|
||
});
|
||
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
||
append(control, infoButton);
|
||
return infoButton;
|
||
}
|
||
|
||
function ensureDropdown(control) {
|
||
const existing = byId('info-dropdown') || qs(selectors.infoDropdown);
|
||
if (existing || !control) return existing;
|
||
const dropdown = createElement('nav', {
|
||
id: 'info-dropdown',
|
||
className: 'info-dropdown',
|
||
'aria-label': ns.I18n.t("info.menu-label")
|
||
});
|
||
append(control, dropdown);
|
||
return dropdown;
|
||
}
|
||
|
||
function configureInfoButton(infoButton) {
|
||
if (!infoButton) return;
|
||
setAttribute(infoButton, 'type', 'button');
|
||
setAttribute(infoButton, 'aria-haspopup', 'true');
|
||
setAttribute(infoButton, 'aria-expanded', 'false');
|
||
setAttribute(infoButton, 'aria-controls', 'info-dropdown');
|
||
setInfoButtonLabel(infoButton);
|
||
ensureInfoButtonIcon(infoButton);
|
||
}
|
||
|
||
function setInfoButtonLabel(infoButton) {
|
||
if (typeof infoButton.getAttribute === 'function' && infoButton.getAttribute('aria-label')) return;
|
||
setAttribute(infoButton, 'aria-label', ns.I18n.t("info.open"));
|
||
}
|
||
|
||
function ensureInfoButtonIcon(infoButton) {
|
||
if (String(infoButton.textContent ?? '').trim()) return;
|
||
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
||
}
|
||
|
||
function configureDropdown(dropdown) {
|
||
if (!dropdown) return;
|
||
setAttribute(dropdown, 'aria-label', ns.I18n.t("info.menu-label"));
|
||
setHidden(dropdown, true);
|
||
menuEntries().forEach(([target, label]) => ensureMenuButton(dropdown, target, label));
|
||
}
|
||
|
||
function ensureMenuButton(dropdown, target, label) {
|
||
let button = qs(`[data-info-target="${target}"]`, dropdown);
|
||
button = button || qsa(selectors.infoTarget, dropdown).find(item => item.dataset?.infoTarget === target);
|
||
if (!button) {
|
||
button = createElement('button', { type: 'button', dataset: { infoTarget: target } }, label);
|
||
append(dropdown, button);
|
||
}
|
||
configureMenuButton(button, target, label);
|
||
}
|
||
|
||
function configureMenuButton(button, target, label) {
|
||
if (!button) return;
|
||
setAttribute(button, 'type', 'button');
|
||
button.dataset.infoTarget = target;
|
||
if (!String(button.textContent ?? '').trim()) button.textContent = label;
|
||
}
|
||
|
||
function ensureInfoDialog() {
|
||
const dialog = ensureDialogElement();
|
||
const shell = ensureDialogShell(dialog);
|
||
const header = ensureDialogHeader(shell);
|
||
const title = ensureDialogTitle(dialog, header);
|
||
const backButton = configureBackButton(byId('info-back') || qs('#info-back', dialog));
|
||
const legacyMenu = configureLegacyMenu(byId('info-menu') || qs('#info-menu', dialog));
|
||
const closeButton = ensureCloseButton(dialog, header);
|
||
const content = ensureDialogContent(dialog, shell);
|
||
configureDialog(dialog);
|
||
ensurePages(content);
|
||
return { dialog, title, backButton, legacyMenu, closeButton, content };
|
||
}
|
||
|
||
function ensureDialogElement() {
|
||
const existing = byId('info-dialog') || qs('#info-dialog');
|
||
if (existing || typeof document.createElement !== 'function') return existing;
|
||
const dialog = createElement('dialog', {
|
||
id: 'info-dialog',
|
||
className: 'info-dialog',
|
||
'aria-labelledby': 'info-dialog-title'
|
||
});
|
||
append(document.body, dialog);
|
||
return dialog;
|
||
}
|
||
|
||
function ensureDialogShell(dialog) {
|
||
if (!dialog) return null;
|
||
const existing = qs('.info-dialog-shell', dialog);
|
||
if (existing || typeof document.createElement !== 'function') return existing;
|
||
const shell = createElement('div', { className: 'info-dialog-shell' });
|
||
append(dialog, shell);
|
||
return shell;
|
||
}
|
||
|
||
function ensureDialogHeader(shell) {
|
||
if (!shell) return null;
|
||
const existing = qs('.info-dialog-header', shell);
|
||
if (existing || typeof document.createElement !== 'function') return existing;
|
||
const header = createElement('header', { className: 'info-dialog-header' });
|
||
append(shell, header);
|
||
return header;
|
||
}
|
||
|
||
function ensureDialogTitle(dialog, header) {
|
||
const existing = byId('info-dialog-title') || qs('#info-dialog-title', dialog);
|
||
if (existing || !header) return existing;
|
||
const title = createElement('h2', { id: 'info-dialog-title' }, pageTitles.privacy);
|
||
append(header, title);
|
||
return title;
|
||
}
|
||
|
||
function configureBackButton(backButton) {
|
||
setAttribute(backButton, 'type', 'button');
|
||
setAttribute(backButton, 'aria-label', ns.I18n.t("info.back"));
|
||
setHidden(backButton, true);
|
||
return backButton;
|
||
}
|
||
|
||
function configureLegacyMenu(legacyMenu) {
|
||
setHidden(legacyMenu, true);
|
||
return legacyMenu;
|
||
}
|
||
|
||
function ensureCloseButton(dialog, header) {
|
||
const existing = byId('info-close') || qs('#info-close', dialog);
|
||
if (existing || !header) return configureCloseButton(existing);
|
||
const closeButton = createElement('button', {
|
||
id: 'info-close',
|
||
type: 'button',
|
||
'aria-label': ns.I18n.t("info.close-label"),
|
||
title: ns.I18n.t("info.close")
|
||
}, '×');
|
||
append(header, closeButton);
|
||
return configureCloseButton(closeButton);
|
||
}
|
||
|
||
function configureCloseButton(closeButton) {
|
||
setAttribute(closeButton, 'type', 'button');
|
||
setAttribute(closeButton, 'aria-label', ns.I18n.t("info.close-label"));
|
||
if (closeButton && !String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×';
|
||
return closeButton;
|
||
}
|
||
|
||
function ensureDialogContent(dialog, shell) {
|
||
const existing = qs(selectors.content, dialog) || qs(selectors.content);
|
||
if (existing || !shell) return existing;
|
||
const content = createElement('div', { className: 'info-dialog-content' });
|
||
append(shell, content);
|
||
return content;
|
||
}
|
||
|
||
function configureDialog(dialog) {
|
||
setAttribute(dialog, 'aria-labelledby', 'info-dialog-title');
|
||
}
|
||
|
||
function ensurePages(content) {
|
||
if (!content) return;
|
||
Object.keys(pageTitles).forEach(name => ensureInfoPage(content, name));
|
||
}
|
||
|
||
function ensureInfoPage(content, name) {
|
||
const existing = qs(`[data-info-page="${name}"]`, content) || qs(`[data-info-page="${name}"]`);
|
||
const page = existing || createFallbackPage(content, name);
|
||
if (!page) return null;
|
||
page.dataset.infoPage = name;
|
||
setHidden(page, name !== 'privacy');
|
||
return page;
|
||
}
|
||
|
||
function createFallbackPage(content, name) {
|
||
const page = createElement('article', { className: 'info-page', dataset: { infoPage: name } });
|
||
append(page, createElement('p', {}, ns.I18n.t("info.missing", { value1: pageTitles[name] })));
|
||
append(content, page);
|
||
return page;
|
||
}
|
||
|
||
function ensureInfoUi() {
|
||
return { ...ensureInfoControl(), ...ensureInfoDialog() };
|
||
}
|
||
|
||
function isActivation(event) {
|
||
if (!event) return false;
|
||
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;
|
||
}
|
||
|
||
function activate(element, handler) {
|
||
if (typeof element?.addEventListener !== 'function') return;
|
||
if (!markBound(element, handler)) return;
|
||
element.addEventListener('pointerup', event => handleActivation(event, handler));
|
||
element.addEventListener('keydown', event => handleActivation(event, handler));
|
||
}
|
||
|
||
function markBound(element, handler) {
|
||
if (!element.dataset) return true;
|
||
const key = handler.name ? `wegwichtelLegalUiBound${handler.name}` : 'wegwichtelLegalUiBound';
|
||
if (element.dataset[key] === 'true') return false;
|
||
element.dataset[key] = 'true';
|
||
return true;
|
||
}
|
||
|
||
function handleActivation(event, handler) {
|
||
if (!isActivation(event)) return;
|
||
event.preventDefault?.();
|
||
event.stopPropagation?.();
|
||
handler(event);
|
||
}
|
||
|
||
function setDropdownOpen(open) {
|
||
if (!elements?.dropdown || !elements?.infoButton) return;
|
||
dropdownOpen = Boolean(open);
|
||
setAttribute(elements.infoButton, 'aria-expanded', dropdownOpen ? 'true' : 'false');
|
||
cancelSchedule(dropdownCloseTimer);
|
||
dropdownCloseTimer = null;
|
||
if (dropdownOpen) openDropdown();
|
||
else closeDropdown();
|
||
}
|
||
|
||
function openDropdown() {
|
||
setHidden(elements.dropdown, false);
|
||
runNextFrame(() => runNextFrame(() => {
|
||
if (dropdownOpen) toggleClass(elements.dropdown, 'is-open', true);
|
||
}));
|
||
}
|
||
|
||
function closeDropdown() {
|
||
toggleClass(elements.dropdown, 'is-open', false);
|
||
dropdownCloseTimer = schedule(() => {
|
||
if (!dropdownOpen) setHidden(elements.dropdown, true);
|
||
}, DROPDOWN_ANIMATION_MS);
|
||
}
|
||
|
||
function handleInfoButton() {
|
||
setDropdownOpen(!dropdownOpen);
|
||
}
|
||
|
||
function elementContains(element, target) {
|
||
return Boolean(element && target && (element === target || element.contains?.(target)));
|
||
}
|
||
|
||
function isInsideInfoControl(target) {
|
||
return elementContains(elements?.infoButton, target) || elementContains(elements?.dropdown, target);
|
||
}
|
||
|
||
function isProgressStorageEnabled() {
|
||
return ns?.PrivacyStorage?.isProgressStorageEnabled?.() === true;
|
||
}
|
||
|
||
function setProgressStorageEnabled(enabled) {
|
||
return ns?.PrivacyStorage?.setProgressStorageEnabled?.(enabled) === true;
|
||
}
|
||
|
||
function syncStorageToggles() {
|
||
const enabled = isProgressStorageEnabled();
|
||
qsa(selectors.progressToggle).forEach(toggle => setSwitchState(toggle, enabled));
|
||
qsa(selectors.cookieToggle).forEach(toggle => setSwitchState(toggle, false));
|
||
}
|
||
|
||
function pageNameOrDefault(name) {
|
||
return Object.hasOwn(pageTitles, name) ? name : 'privacy';
|
||
}
|
||
|
||
function showPage(name) {
|
||
const pageName = pageNameOrDefault(name);
|
||
const page = qs(`[data-info-page="${pageName}"]`, elements?.content) || qs(`[data-info-page="${pageName}"]`);
|
||
if (!page) return;
|
||
updateDialogTitle(pageName);
|
||
qsa(selectors.infoPage).forEach(item => setHidden(item, item !== page));
|
||
if (elements?.content) elements.content.scrollTop = 0;
|
||
}
|
||
|
||
function updateDialogTitle(pageName) {
|
||
if (!elements?.title) return;
|
||
const isLegalPage = pageName === 'privacy' || pageName === 'imprint';
|
||
if (isLegalPage) delete elements.title.dataset.i18n;
|
||
else elements.title.dataset.i18n = 'info.storage';
|
||
setAttribute(elements.title, 'lang', isLegalPage ? 'de' : ns.I18n.language);
|
||
elements.title.textContent = pageTitles[pageName] ?? ns.I18n.t("info.title");
|
||
}
|
||
|
||
function focusSafely(element) {
|
||
if (typeof element?.focus !== 'function') return;
|
||
try {
|
||
element.focus({ preventScroll: true });
|
||
} catch {
|
||
element.focus();
|
||
}
|
||
}
|
||
|
||
function openDialog(name = 'privacy') {
|
||
if (!elements?.dialog) return;
|
||
setDropdownOpen(false);
|
||
setHidden(elements.legacyMenu, true);
|
||
setHidden(elements.backButton, true);
|
||
showPage(pageNameOrDefault(name));
|
||
syncStorageToggles();
|
||
showDialog();
|
||
focusSafely(elements.closeButton ?? elements.dialog);
|
||
}
|
||
|
||
function showDialog() {
|
||
if (elements.dialog.open) return;
|
||
if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal();
|
||
else setAttribute(elements.dialog, 'open', '');
|
||
}
|
||
|
||
function closeDialog() {
|
||
closeNativeDialog();
|
||
setDropdownOpen(false);
|
||
focusSafely(elements?.infoButton);
|
||
}
|
||
|
||
function closeNativeDialog() {
|
||
if (!elements?.dialog) return;
|
||
if (typeof elements.dialog.close === 'function') elements.dialog.close();
|
||
else elements.dialog.removeAttribute?.('open');
|
||
}
|
||
|
||
function bindStorageToggles() {
|
||
qsa(selectors.progressToggle).forEach(bindStorageToggle);
|
||
}
|
||
|
||
function bindStorageToggle(toggle) {
|
||
if (toggle.dataset?.wegwichtelStorageBound === 'true') return;
|
||
if (toggle.dataset) toggle.dataset.wegwichtelStorageBound = 'true';
|
||
toggle.addEventListener('change', event => handleStorageToggleChange(event, toggle));
|
||
}
|
||
|
||
function handleStorageToggleChange(event, fallbackToggle) {
|
||
const source = event.currentTarget || event.target || fallbackToggle;
|
||
const enabled = setProgressStorageEnabled(Boolean(source.checked));
|
||
syncStorageToggles();
|
||
document.dispatchEvent(new CustomEvent('wegwichtel:progress-storage-changed', { detail: { enabled } }));
|
||
}
|
||
|
||
function infoTargetButtons() {
|
||
const root = elements?.dropdown || elements?.legacyMenu || document;
|
||
return qsa(selectors.infoTarget, root);
|
||
}
|
||
|
||
function bindDialog() {
|
||
activate(elements?.infoButton, handleInfoButton);
|
||
activate(elements?.closeButton, closeDialog);
|
||
activate(elements?.backButton, () => setDropdownOpen(true));
|
||
infoTargetButtons().forEach(button => activate(button, () => openDialog(button.dataset?.infoTarget)));
|
||
bindDocumentEvents();
|
||
bindDialogEvents();
|
||
}
|
||
|
||
function bindDocumentEvents() {
|
||
if (documentEventsBound) return;
|
||
documentEventsBound = true;
|
||
document.addEventListener('pointerup', closeDropdownOnOutsidePointer);
|
||
document.addEventListener('keydown', closeDropdownOnEscape);
|
||
}
|
||
|
||
function closeDropdownOnOutsidePointer(event) {
|
||
if (dropdownOpen && !isInsideInfoControl(event.target)) setDropdownOpen(false);
|
||
}
|
||
|
||
function closeDropdownOnEscape(event) {
|
||
if (!dropdownOpen || event.key !== 'Escape' || elements?.dialog?.open) return;
|
||
event.preventDefault?.();
|
||
setDropdownOpen(false);
|
||
focusSafely(elements?.infoButton);
|
||
}
|
||
|
||
function bindDialogEvents() {
|
||
if (!elements?.dialog || elements.dialog.dataset?.wegwichtelLegalUiDialogBound === 'true') return;
|
||
if (elements.dialog.dataset) elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true';
|
||
elements.dialog.addEventListener('pointerup', closeDialogOnBackdropPointer);
|
||
elements.dialog.addEventListener('cancel', closeDialogOnCancel);
|
||
}
|
||
|
||
function closeDialogOnBackdropPointer(event) {
|
||
if (event.target !== elements.dialog || !isActivation(event)) return;
|
||
event.preventDefault?.();
|
||
event.stopPropagation?.();
|
||
closeDialog();
|
||
}
|
||
|
||
function closeDialogOnCancel(event) {
|
||
event.preventDefault?.();
|
||
closeDialog();
|
||
}
|
||
|
||
function init() {
|
||
elements = ensureInfoUi();
|
||
bindDialog();
|
||
bindStorageToggles();
|
||
syncStorageToggles();
|
||
ns.LegalUi = Object.freeze({
|
||
open: openDialog,
|
||
close: closeDialog,
|
||
toggleMenu: handleInfoButton,
|
||
closeMenu: () => setDropdownOpen(false),
|
||
syncStorageToggles
|
||
});
|
||
}
|
||
|
||
ready(init);
|
||
}(globalThis.Wegwichtel));
|