Files
2026-09-24 17:57:54 +02:00

122 lines
4.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
}
}
}