Files
Hellion-NewTab/src/js/dialog.js
T
JonKazama-Hellion 55e371f506 fix(a11y): Dialog-Vorrang in Fokusfalle, eindeutige Dialog-IDs, Bookmark nur Enter
- settings.js: _makeTrap bricht ab, wenn ein .dialog-overlay offen ist, damit
  der Dialog-keydown-Handler Escape/Tab allein behandelt (kein Doppelschluss,
  Fokusfalle bleibt dicht)
- dialog.js: aria-labelledby/-describedby zeigen auf instanz-eindeutige IDs
  (Date.now + Modul-Zaehler) statt feste dialogTitle/dialogBody, damit kurz
  gestapelte Dialoge dem Screenreader nicht den falschen Titel liefern
- boards.js: Bookmark-keydown reagiert bei role=link nur noch auf Enter, Space
  entfernt (Space ist Button-Semantik)
2026-06-13 21:11:49 +02:00

181 lines
6.6 KiB
JavaScript

/* =============================================
HELLION NEWTAB — dialog.js
Custom Dialog System (ersetzt native alert/confirm)
============================================= */
const HellionDialog = {
/** SVG-Icons je nach Dialog-Typ */
_icons: {
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
danger: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>'
},
/**
* Erzeugt das SVG-Icon-Element
* @param {string} type - info | success | warning | danger
* @returns {SVGElement}
*/
_createIcon(type) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.className.baseVal = 'dialog-icon type-' + type;
// SVG-Pfade müssen per innerHTML gesetzt werden (kein User-Input, nur statische Pfade)
svg.innerHTML = this._icons[type] || this._icons.info;
return svg;
},
/**
* Erstellt und zeigt einen Dialog
* @param {Object} config
* @returns {Promise<boolean>}
*/
_show(config) {
return new Promise(resolve => {
const prevFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
const box = document.createElement('div');
box.className = 'dialog-box';
box.setAttribute('role', config.isConfirm ? 'alertdialog' : 'dialog');
box.setAttribute('aria-modal', 'true');
// Eindeutige IDs pro Dialog-Instanz: kurz gestapelte Dialoge (timer.js/
// image-ref.js feuern teils ohne await) duerfen sich keine festen IDs
// teilen, sonst liest der Screenreader ueber aria-* den falschen Titel.
const uid = 'dlg-' + Date.now().toString(36) + '-' + (HellionDialog._seq = (HellionDialog._seq || 0) + 1);
box.setAttribute('aria-labelledby', uid + '-title');
box.setAttribute('aria-describedby', uid + '-body');
// Header
const header = document.createElement('div');
header.className = 'dialog-header';
header.appendChild(this._createIcon(config.type));
const titleSpan = document.createElement('span');
titleSpan.id = uid + '-title';
titleSpan.textContent = config.title;
header.appendChild(titleSpan);
// Body
const body = document.createElement('div');
body.className = 'dialog-body';
body.id = uid + '-body';
body.textContent = config.message;
// Actions
const actions = document.createElement('div');
actions.className = 'dialog-actions';
function cleanup(result) {
document.removeEventListener('keydown', keyHandler);
withViewTransition(() => {
overlay.classList.remove('active');
overlay.remove();
});
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
resolve(result);
}
// Cancel-Button (nur bei confirm)
if (config.isConfirm) {
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-secondary';
cancelBtn.textContent = config.cancelText;
cancelBtn.addEventListener('click', () => cleanup(false));
actions.appendChild(cancelBtn);
}
// Confirm/OK-Button
const confirmBtn = document.createElement('button');
confirmBtn.className = config.type === 'danger' && config.isConfirm ? 'btn-danger' : 'btn-primary';
confirmBtn.textContent = config.confirmText;
confirmBtn.addEventListener('click', () => cleanup(config.isConfirm ? true : undefined));
actions.appendChild(confirmBtn);
box.append(header, body, actions);
overlay.appendChild(box);
// Overlay-Klick schließt
overlay.addEventListener('click', e => {
if (e.target === overlay) cleanup(config.isConfirm ? false : undefined);
});
// Keyboard
function keyHandler(e) {
if (e.key === 'Enter') {
e.preventDefault();
cleanup(config.isConfirm ? true : undefined);
}
if (e.key === 'Escape') {
e.preventDefault();
cleanup(config.isConfirm ? false : undefined);
}
if (e.key === 'Tab') {
// Fokus-Falle: nur die Buttons im actions-Container sind fokussierbar
const items = Array.from(actions.querySelectorAll('button'));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
}
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// View-Transition uebernimmt das Fade; Fokus bleibt erhalten
withViewTransition(() => {
overlay.classList.add('active');
confirmBtn.focus();
});
});
},
/**
* Zeigt einen Alert-Dialog (ersetzt window.alert)
* @param {string} message - Nachricht
* @param {Object} [options] - { title, confirmText, type }
* @returns {Promise<void>}
*/
alert(message, options) {
const opts = options || {};
return this._show({
message,
title: opts.title || t('dialog.default_title'),
confirmText: opts.confirmText || t('dialog.ok'),
cancelText: '',
type: opts.type || 'info',
isConfirm: false
});
},
/**
* Zeigt einen Confirm-Dialog (ersetzt window.confirm)
* @param {string} message - Nachricht
* @param {Object} [options] - { title, confirmText, cancelText, type }
* @returns {Promise<boolean>}
*/
confirm(message, options) {
const opts = options || {};
return this._show({
message,
title: opts.title || t('dialog.confirm_title'),
confirmText: opts.confirmText || t('dialog.ok'),
cancelText: opts.cancelText || t('dialog.cancel'),
type: opts.type || 'info',
isConfirm: true
});
}
};