OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

dialog.js

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