Wie sich das JavaScript den auslösenden Button merkt, warum der Fokus vor dem Schliessen aus dem Modal herausgenommen werden muss und wo Bootstrap diese Arbeit bereits selbst übernimmt, beleuchte ich in diesem Beitrag.
Hinweis: Alles in diesem Beitrag bezieht sich auf Bootstrap 5.3 – auch das Verhalten von Bootstrap selbst.
Was macht Bootstrap mit dem Fokus?
Öffnet sich ein Modal, setzt Bootstrap den Fokus auf den Modal selbst (über die Option focus, die standardmässig aktiv ist) und hält ihn dort fest: Mit der Tabulatortaste bedient man jetzt die Elemente im Modal, man befindet sich nicht mehr auf der Seite dahinter – diese sogenannte Fokus-Falle ist eingebaut und man muss nichts weiter machen.
In der Bootstrap-Dokumentation geht es beim Fokus nur um das Öffnen eines Modals: um die Option focus und um den Hinweis, dass das HTML-Attribut autofocus in einem Bootstrap-Modal keine Wirkung hat. Soll der Fokus beim Öffnen auf einem bestimmten Element landen, etwa auf einem Eingabefeld, muss man ihn im Ereignis shown.bs.modal selbst dorthin setzen.
Was passiert beim Schliessen des Modals mit dem Fokus?
Es steht nicht in der Bootstrap-Dokumentation und genau beim Schliessen des Modals hat Bootstrap zwei Lücken. Diese beiden Lücken liegen im Verhalten von Bootstrap selbst – nur fallen sie kaum auf.
Die erste Lücke betrifft die Rückgabe des Fokus an den Auslöser und diese Rückgabe gibt es nur in einem Fall:
Wenn der Modal über ein Element mit data-bs-toggle="modal" geöffnet wurde. Nur dann merkt sich Bootstrap, welches Element geklickt wurde, und setzt den Fokus nach dem Schliessen wieder darauf – nachzulesen ist dies nicht in der Dokumentation, sondern im Quellcode von Bootstrap. Öffnet man den Modal per JavaScript, fehlt diese Rückgabe und der Fokus landet nach dem Schliessen am Seitenanfang – auch dann, wenn man den Modal mit der Maus geöffnet hat und danach mit der Tabulatortaste weitermacht. Trägt der auslösende Button zusätzlich einen Tooltip, ist sein data-bs-toggle bereits mit tooltip belegt – ein Element kann nur ein data-bs-toggle haben. Bootstrap weiss dann nicht, wohin der Fokus nach dem Schliessen zurück soll.
Hinweis: Wie ein Tooltip auf dem auslösenden Button beim Öffnen und Schliessen des Modals zusätzlich gesteuert werden muss, habe ich im Beitrag «Modal oder nicht – das ist hier nicht die Frage» im Abschnitt Wenn Tooltip und Modal sich in die Quere kommen beschrieben.
Die zweite Lücke besteht in jedem Fall, egal wie der Modal geöffnet wurde.
Beim Schliessen setzt Bootstrap aria-hidden="true" auf den Modal und versteckt ihn damit vor assistiver Technik wie Screenreadern – auch wenn der Fokus in diesem Moment noch auf einem Element im Modal liegt. Ein fokussiertes Element in einem versteckten Bereich ist ein ungültiger Zustand, Chrome meldet ihn mit einer Warnung in der Konsole. Das passiert nicht nur mit der Tastatur: Je nach Browser bekommt der Schliessen-Button auch beim Mausklick den Fokus, in Chrome zum Beispiel.
Hinweis: Warum beides für die Bedienung wichtig ist, habe ich im Beitrag «Modal oder nicht – das ist hier nicht die Frage» im Abschnitt Wohin mit dem Fokus? am Beispiel meines Teilen-Dialogs ausführlich beschrieben.
Mit diesem Hintergrund geht es jetzt darum, beide Lücken an einer einzigen Stelle zu schliessen – für jeden Modal auf der Webseite.
Wie kann sich JavaScript merken, wer den Modal geöffnet hat?
Dies geht über event.relatedTarget im Ereignis show.bs.modal, darin übergibt Bootstrap das Element, das den Modal ausgelöst hat.
const modalTriggers = new WeakMap();
target.addEventListener( 'show.bs.modal', event => {
if ( event.relatedTarget ) {
modalTriggers.set( event.target, event.relatedTarget );
}
} );
Das JavaScript legt das auslösende Element in einer WeakMap ab, mit dem Modal als Schlüssel. So bekommt jeder Modal seinen eigenen Eintrag, ohne dass man für jeden Modal eine eigene Variable führen muss. Eine WeakMap hält ihre Schlüssel zudem nicht fest: Wird ein Modal aus dem DOM entfernt, kann der Browser den Eintrag mit aufräumen.
Wichtig ist, woher relatedTarget kommt. Bei data-bs-toggle="modal" setzt Bootstrap es selbst. Öffnet man den Modal per JavaScript, bleibt es leer – ausser man übergibt den Auslöser an show():
// Den Auslöser mitgeben, damit er im Ereignis als relatedTarget ankommt.
button.addEventListener( 'click', () => {
bootstrap.Modal.getOrCreateInstance( '#exampleModal' ).show( button );
} );
Ein Argument mehr, das ist alles. Ohne dieses Argument findet das JavaScript beim Schliessen keinen Button, zu dem es den Fokus zurückgeben kann.
Was passiert beim Schliessen des Modals?
Es passieren zwei Dinge, verteilt auf zwei Ereignisse: Bei hide.bs.modal nimmt das JavaScript den Fokus aus dem Modal, bei hidden.bs.modal gibt es ihn an den Auslöser zurück.
target.addEventListener( 'hide.bs.modal', event => {
const modalElement = event.target;
if ( modalElement.contains( document.activeElement ) ) {
document.activeElement.blur();
}
} );
hide.bs.modal löst aus, sobald das Schliessen beginnt – bevor Bootstrap den Modal ausblendet und aria-hidden setzt. Der Zeitpunkt passt also. Und das Ereignis gilt für jeden Weg, einen Modal zu schliessen: den Schliessen-Button, die Escape-Taste, einen Klick auf den Backdrop oder ein hide() aus dem eigenen Code. In Bootstrap laufen alle diese Wege über dieselbe Methode und damit über dasselbe Ereignis.
target.addEventListener( 'hidden.bs.modal', event => {
const modalElement = event.target;
const trigger = modalTriggers.get( modalElement );
if ( trigger && document.contains( trigger ) ) {
trigger.focus();
}
modalTriggers.delete( modalElement );
} );
hidden.bs.modal löst erst aus, wenn der Modal vollständig geschlossen ist – auch der Backdrop ist dann verschwunden. Jetzt holt das JavaScript das gemerkte Element aus der WeakMap und setzt den Fokus darauf. Vorher prüft es, ob das Element überhaupt noch im Dokument steht. Wurde das Element in der Zwischenzeit entfernt, gäbe es keinen Ort mehr, an den der Fokus zurückkehren könnte. Zum Schluss löscht das JavaScript den Eintrag, damit beim nächsten Öffnen nichts Altes übrig bleibt.
Wird der Modal über data-bs-toggle="modal" geöffnet, setzen Bootstrap und das JavaScript den Fokus nacheinander auf dasselbe Element. Das schadet nicht – der Fokus landet so oder so am richtigen Ort.
Mit der Maus sieht man von der Rückgabe meist nichts:
Nach einem Mausklick zeigen Browser den Fokusrahmen in der Regel nicht an. Aber aufgepasst, der Fokus liegt trotzdem auf dem auslösenden Button – reagiert dort ein Tooltip auf Fokus, geht er beim Schliessen auf.
Warum hängt das JavaScript am Dokument?
Weil die Ereignisse von Bootstrap im DOM nach oben aufsteigen – das sogenannte Bubbling. Ein einziger Listener an document bekommt so die Ereignisse aller Modals auf der Webseite mit.
Welcher Modal gerade gemeint ist, steht in event.target. Das JavaScript muss die Modals deshalb nicht einzeln suchen und verdrahten – und es erfasst auch Modals, die erst später ins DOM kommen, zum Beispiel per JavaScript nachgeladen. Ein zweiter Modal auf der Seite braucht keine einzige zusätzliche Zeile Code.
Was übernimmt das JavaScript nicht?
Wohin der Fokus beim Öffnen geht, bleibt Sache des einzelnen Modals. Bootstrap setzt den Fokus beim Öffnen auf den Modal selbst. Ob er besser auf einem Eingabefeld oder auf dem Schliessen-Button liegt, hängt vom Inhalt des Modals ab. Diese Entscheidung kann ein zentrales JavaScript nicht sinnvoll für alle Modals treffen, deshalb lässt dieses JavaScript sie bewusst aus.
Und ohne Auslöser gibt es keinen Rückweg. Öffnet sich ein Modal ganz ohne auslösendes Element, etwa automatisch nach ein paar Sekunden, bleibt relatedTarget leer und das JavaScript gibt den Fokus nirgendwohin zurück. Es nimmt ihn beim Schliessen zwar noch aus dem Modal, aber wohin er danach soll, muss man für diesen Fall selbst festlegen.
Der vollständige Code
Zusammengesetzt ist das modal-focus-JavaScript ein kleines Modul mit einer einzigen Funktion.
// modal-focus.js
const modalTriggers = new WeakMap();
export function initModalFocus( target ) {
if ( ! target ) {
return;
}
target.addEventListener( 'show.bs.modal', event => {
if ( event.relatedTarget ) {
modalTriggers.set( event.target, event.relatedTarget );
}
} );
target.addEventListener( 'hide.bs.modal', event => {
const modalElement = event.target;
if ( modalElement.contains( document.activeElement ) ) {
document.activeElement.blur();
}
} );
target.addEventListener( 'hidden.bs.modal', event => {
const modalElement = event.target;
const trigger = modalTriggers.get( modalElement );
if ( trigger && document.contains( trigger ) ) {
trigger.focus();
}
modalTriggers.delete( modalElement );
} );
}
Wird das JavaScript in einem Build-Schritt gebündelt bzw. kompiliert, genügen ein Import und ein einziger Aufruf:
import { initModalFocus } from './modal-focus.js';
initModalFocus( document );
Ohne Build-Schritt kann man es auch direkt im HTML einbinden, mit einem Script-Tag vom Typ module:
<script type="module">
import { initModalFocus } from './js/modal-focus.js';
initModalFocus( document );
</script>
Der Pfad muss dabei mit ./, ../ oder / beginnen oder eine vollständige, absolute URL sein. Einen blossen Dateinamen wie modal-focus.js versteht der Browser ohne Import-Map nicht. Auf Bootstrap muss das JavaScript nicht warten: Es hört nur auf die Ereignisse von Bootstrap und ruft Bootstrap selbst nie auf.
Auf das fertig geladene DOM muss das JavaScript dabei nicht warten. Die Listener hängen an document und das gibt es von Anfang an – auch Modals, die erst später im DOM stehen, werden so erfasst.
Fazit
Bootstrap nimmt einem beim Umgang mit dem Fokus in Modals vieles ab, aber nicht alles. Die Lücken sind klein und sie sind bei jedem Modal dieselben. Genau deshalb kann man diese Lücken an einer einzigen Stelle mit einem kleinen Zusatz-JavaScript schliessen.
Mit der Maus merkt man davon meist wenig. Mit der Tastatur oder einem Screenreader macht es den Unterschied, ob man einfach weiterlesen kann oder von vorne suchen muss, wo man war. Ist dieses kleine JavaScript eingebunden, schliesst man den Modal und ist danach wieder da, wo man war. Mehr soll gar nicht passieren.

Kommentare