/** * HTML-Overlays über dem Spiel-Canvas (Disclaimer, Pause, Namenseingabe). * * Warum HTML statt Canvas-Text: Formulare, Schieberegler und längere Texte sind so * für Screenreader und Tastatur bedienbar, und Browser-Features (Autokorrektur aus, * Textauswahl) funktionieren ohne Nachbau. * * Sicherheit: Dynamische Inhalte werden ausschließlich per `textContent` gesetzt, * niemals per `innerHTML` – Spielernamen aus der Highscore-Liste können also * kein HTML einschleusen. * @module systems/Overlay */ /** * Erzeugt ein Element mit Klasse und optionalem Text. * @param {string} tag * @param {string} [className] * @param {string} [text] * @returns {HTMLElement} */ export function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } /** * @typedef {Object} OverlayButton * @property {string} label * @property {() => void} onClick * @property {boolean} [secondary] * @property {string} [testId] data-testid für End-to-End-Tests. */ /** * @typedef {Object} OverlayOptions * @property {string} [title] * @property {Node[]} [content] * @property {OverlayButton[]} [buttons] * @property {string} [id] * @property {() => void} [onEscape] Wird bei Esc aufgerufen (z. B. Schließen/Fortsetzen). */ export class Overlay { /** * Öffnet ein Overlay im Element #overlay-root. * @param {OverlayOptions} options */ constructor(options) { const root = document.getElementById('overlay-root') ?? document.body; this.previousFocus = document.activeElement; this.onEscape = options.onEscape; this.element = el('div', 'overlay'); if (options.id) this.element.id = options.id; this.element.setAttribute('role', 'dialog'); this.element.setAttribute('aria-modal', 'true'); this.panel = el('div', 'overlay__panel'); this.element.append(this.panel); if (options.title) { const heading = el('h2', 'disclaimer__title', options.title); heading.id = `${options.id ?? 'overlay'}-title`; this.element.setAttribute('aria-labelledby', heading.id); this.panel.append(heading); } for (const node of options.content ?? []) this.panel.append(node); this.actions = el('div', 'overlay__actions'); for (const button of options.buttons ?? []) this.addButton(button); if (this.actions.childElementCount > 0) this.panel.append(this.actions); this.handleKeyDown = (event) => { // Tasten im Overlay gehören dem Overlay: Ohne stopPropagation würde z. B. das // Enter, mit dem man den Disclaimer bestätigt, danach auch das Menü auslösen. event.stopPropagation(); if (event.key === 'Escape' && this.onEscape) { event.preventDefault(); this.onEscape(); } }; this.element.addEventListener('keydown', this.handleKeyDown); root.append(this.element); // Fokus ins Overlay, damit Tastaturnutzer sofort bedienen können. const focusTarget = this.panel.querySelector('input, button, a'); if (focusTarget instanceof HTMLElement) focusTarget.focus(); else { this.panel.tabIndex = -1; this.panel.focus(); } } /** * @param {OverlayButton} button * @returns {HTMLButtonElement} */ addButton(button) { const node = /** @type {HTMLButtonElement} */ (el('button', `button${button.secondary ? ' button--secondary' : ''}`, button.label)); node.type = 'button'; if (button.testId) node.dataset.testid = button.testId; node.addEventListener('click', () => button.onClick()); this.actions.append(node); return node; } /** Entfernt das Overlay und gibt den Fokus zurück an das Spiel. */ close() { this.element.removeEventListener('keydown', this.handleKeyDown); this.element.remove(); const game = document.querySelector('#game canvas'); if (game instanceof HTMLElement) { game.tabIndex = -1; game.focus({ preventScroll: true }); } else if (this.previousFocus instanceof HTMLElement) { this.previousFocus.focus({ preventScroll: true }); } } }