/**
* 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 });
}
}
}