122 lines
4.1 KiB
JavaScript
122 lines
4.1 KiB
JavaScript
/**
|
||
* 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 });
|
||
}
|
||
}
|
||
}
|