Quelltext des aktuellen Standes
/**
* Dialoge mit der Tastatur bedienbar machen – modulübergreifend, ohne dass
* eine einzelne Vorlage etwas dafür tun muss.
*
* Die Dialoge in OpenNIT werden seit jeher über `style.display` ein- und
* ausgeblendet. Dieses Skript beobachtet genau das und ergänzt, was zur
* Bedienung ohne Maus fehlt:
*
* - Der Dialog meldet sich als solcher an (`role="dialog"`, `aria-modal`)
* und bekommt seine Überschrift als Namen.
* - Der Fokus springt beim Öffnen hinein und beim Schließen dorthin zurück,
* wo er herkam – sonst landet man nach dem Schließen am Seitenanfang.
* - Tabulator läuft im Dialog im Kreis, statt hinter ihm weiterzuwandern.
* - Escape schließt den obersten Dialog. Gibt es eine Schließen-Schaltfläche,
* wird sie betätigt, damit die Aufräumarbeit der Seite mitläuft.
*
* Dialoge, die ihre eigene Escape-Behandlung mitbringen, stören sich daran
* nicht: Zweimal schließen ist dasselbe wie einmal schließen.
*/
(function () {
'use strict';
var AUSWAHL = '.modal-overlay';
var FOKUSSIERBAR = [
'a[href]', 'area[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])', 'textarea:not([disabled])', 'iframe', 'object', 'embed',
'[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])'
].join(',');
var SCHLIESSER = '.modal-close, [data-modal-close], [class*="modal-close"]';
/**
* Ebenen, die ein Bedienelement des Dialogs außerhalb von ihm aufspannt –
* aufgeklappte Auswahllisten hängen am `<body>`, damit sie nicht am Rand
* des Dialogs abgeschnitten werden. Sie zählen zum Dialog: Der Fokus darf
* dort stehen (etwa im Suchfeld der Liste), ohne zurückgeholt zu werden.
*/
var EBENE = '[data-dialog-layer]';
/** Liegt das Element im Dialog oder in einer seiner Ebenen? */
function gehoertDazu(dialog, el) {
if (!el || el.nodeType !== 1) { return false; }
if (dialog.contains(el)) { return true; }
return !!(el.closest && el.closest(EBENE));
}
/** Die gerade offenen Dialoge, zuletzt geöffneter zuoberst. */
var stapel = [];
/**
* Das zuletzt fokussierte Element außerhalb jedes Dialogs.
*
* Beim Öffnen reicht `document.activeElement` nicht: Der Dialog wird über
* seine Sichtbarkeit erkannt, und bis die Beobachtung anschlägt, hat der
* Klick den Fokus längst abgegeben. Hier steht deshalb mit, wo er zuletzt
* gestanden hat – das ist die Stelle, an die er zurückgehört.
*/
var letzterFokus = null;
/**
* Gibt das Element zurück, wenn es außerhalb jedes Dialogs liegt.
*
* Manche Seiten setzen den Fokus beim Öffnen selbst in ihr Dialogfeld –
* und zwar bevor die Beobachtung den Dialog überhaupt kennt. Ohne diese
* Prüfung würde die gemerkte Herkunft dadurch überschrieben.
*/
function ausserhalb(el) {
if (!el || el === document.body || el.nodeType !== 1) { return null; }
if (!el.closest) { return el; }
return (el.closest(AUSWAHL) || el.closest(EBENE)) ? null : el;
}
function sichtbar(el) {
if (!el || el.hidden) { return false; }
if (el.style && el.style.display === 'none') { return false; }
return el.offsetParent !== null || el.getClientRects().length > 0;
}
function fokussierbare(el) {
return Array.prototype.filter.call(el.querySelectorAll(FOKUSSIERBAR), function (k) {
return sichtbar(k) && !k.hasAttribute('inert');
});
}
/**
* Der Name des Dialogs: die Überschrift im Kopf, sonst das, was die
* Vorlage selbst angegeben hat.
*/
function benennen(el) {
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) { return; }
var titel = el.querySelector('.modal-card-title, .modal-header h2, .modal-header h3, [class*="modal"] h2, [class*="modal"] h3');
if (!titel) { return; }
if (!titel.id) {
titel.id = 'onit-dialogtitel-' + Math.random().toString(36).slice(2, 10);
}
el.setAttribute('aria-labelledby', titel.id);
}
function oeffnen(el) {
if (stapel.indexOf(el) !== -1) { return; }
el.setAttribute('role', el.getAttribute('role') || 'dialog');
el.setAttribute('aria-modal', 'true');
if (!el.hasAttribute('tabindex')) { el.setAttribute('tabindex', '-1'); }
benennen(el);
stapel.push({ el: el, herkunft: ausserhalb(document.activeElement) || letzterFokus });
// Der Fokus geht auf das erste Bedienelement; gibt es keines, auf den
// Dialog selbst, damit die Vorlese-Software ihn ansagen kann.
var ziele = fokussierbare(el);
var ziel = ziele.length > 0 ? ziele[0] : el;
window.setTimeout(function () {
try { ziel.focus({ preventScroll: true }); } catch (e) { /* Fokus ist nicht erzwingbar */ }
}, 0);
}
function schliessen(el) {
var i = stapelIndex(el);
if (i === -1) { return; }
var eintrag = stapel.splice(i, 1)[0];
el.setAttribute('aria-modal', 'false');
zurueckgeben(eintrag.herkunft);
}
/**
* Gibt den Fokus dorthin zurück, wo er herkam.
*
* Der Auslöser ist oft selbst nur vorübergehend sichtbar – ein Eintrag in
* einem Menü, das sich mit dem Dialog schließt. Ein unsichtbares Element
* nimmt den Fokus nicht an; dann übernimmt das nächste erreichbare
* Bedienelement in seiner Umgebung, sonst der Anfang des Inhalts. Ohne
* diesen Rückweg begänne die Tastatur wieder am Seitenanfang.
*/
function zurueckgeben(herkunft) {
if (herkunft && typeof herkunft.focus === 'function' && document.contains(herkunft)) {
try { herkunft.focus({ preventScroll: true }); } catch (e) { /* Fokus ist nicht erzwingbar */ }
if (document.activeElement === herkunft) { return; }
}
var knoten = herkunft && document.contains(herkunft) ? herkunft.parentElement : null;
while (knoten && knoten !== document.body) {
var ziele = fokussierbare(knoten);
if (ziele.length > 0) {
try { ziele[0].focus({ preventScroll: true }); } catch (e) { /* s. o. */ }
if (document.activeElement === ziele[0]) { return; }
}
knoten = knoten.parentElement;
}
var inhalt = document.getElementById('main-content');
if (inhalt) {
try { inhalt.focus({ preventScroll: true }); } catch (e) { /* s. o. */ }
}
}
function stapelIndex(el) {
for (var i = 0; i < stapel.length; i++) {
if (stapel[i].el === el) { return i; }
}
return -1;
}
function oberster() {
return stapel.length > 0 ? stapel[stapel.length - 1].el : null;
}
/** Meldet jede Sichtbarkeitsänderung eines Dialogs an oeffnen/schliessen. */
function beobachten(el) {
if (el.dataset.onitDialog === '1') { return; }
el.dataset.onitDialog = '1';
new MutationObserver(function () {
if (sichtbar(el)) { oeffnen(el); } else { schliessen(el); }
}).observe(el, { attributes: true, attributeFilter: ['style', 'class', 'hidden'] });
if (sichtbar(el)) { oeffnen(el); }
}
function alleBeobachten(wurzel) {
Array.prototype.forEach.call((wurzel || document).querySelectorAll(AUSWAHL), beobachten);
}
document.addEventListener('keydown', function (e) {
var dialog = oberster();
if (!dialog) { return; }
if (e.key === 'Escape') {
var knopf = dialog.querySelector(SCHLIESSER);
if (knopf) {
knopf.click();
} else if (dialog.classList.contains('open')) {
// Über eine Klasse eingeblendete Dialoge auch über die Klasse
// schließen – ein eingebautes display:none ließe sich mit ihr
// nicht mehr überstimmen.
dialog.classList.remove('open');
} else {
dialog.style.display = 'none';
}
return;
}
if (e.key !== 'Tab') { return; }
// In einer aufgeklappten Auswahlliste führt die Liste selbst durch ihre
// Einträge und übernimmt die Markierung beim Tabulator.
if (e.target && e.target.closest && e.target.closest(EBENE)) { return; }
var ziele = fokussierbare(dialog);
if (ziele.length === 0) {
e.preventDefault();
dialog.focus({ preventScroll: true });
return;
}
var erstes = ziele[0];
var letztes = ziele[ziele.length - 1];
if (e.shiftKey && document.activeElement === erstes) {
e.preventDefault();
letztes.focus();
} else if (!e.shiftKey && document.activeElement === letztes) {
e.preventDefault();
erstes.focus();
}
});
// Wandert der Fokus doch hinter den Dialog – etwa weil der Browser ihn
// aus einem Skript heraus versetzt –, wird er zurückgeholt. Solange kein
// Dialog offen ist, wird hier die Herkunft für den nächsten mitgeschrieben.
document.addEventListener('focusin', function (e) {
var dialog = oberster();
if (!dialog) {
var draussen = ausserhalb(e.target);
if (draussen) { letzterFokus = draussen; }
return;
}
if (gehoertDazu(dialog, e.target)) { return; }
var ziele = fokussierbare(dialog);
(ziele.length > 0 ? ziele[0] : dialog).focus({ preventScroll: true });
});
function start() {
alleBeobachten(document);
// Dialoge, die erst nachgeladen werden, werden ebenso erfasst.
new MutationObserver(function (aenderungen) {
aenderungen.forEach(function (aenderung) {
Array.prototype.forEach.call(aenderung.addedNodes, function (knoten) {
if (knoten.nodeType !== 1) { return; }
if (knoten.matches && knoten.matches(AUSWAHL)) { beobachten(knoten); }
alleBeobachten(knoten);
});
});
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();