Fokusring nur noch bei Tastaturbedienung zeigen

Nach dem Oeffnen der Schublade lag ein blauer Rahmen um "Dashboard", nach dem
Schliessen um den Hamburger — bis man woanders hinklickte. Der Ring hat einen
praktischen Grund, aber nur fuer die Tastatur: Er zeigt, wo man gerade steht.
Nach einem Mausklick weiss man das ohnehin.

Ursache war unser eigener Fokus-Sprung, nicht die CSS-Regel. Die benutzt
laengst :focus-visible. Setzt aber ein Skript den Fokus — Fokusfalle beim
Oeffnen, Rueckgabe an den Ausloeser beim Schliessen —, wertet Chrome das wie
Tastaturbedienung und zeigt den Ring trotzdem.

Drei Aenderungen:

- Beim Oeffnen bekommt der Dialograhmen den Fokus statt des ersten Links. Das
  ist ohnehin das uebliche Vorgehen fuer einen Dialog; die Sprachausgabe liest
  den Dialog an, der erste Tabulatorschritt landet auf dem ersten Link.

- Elemente mit tabindex="-1" bekommen keinen Ring mehr. Sie sind aus der
  Tabulatorreihenfolge heraus und werden nur per Skript fokussiert — ein Ring
  um den ganzen Schubladenrahmen waere dort Laerm.

- AppShell schreibt die zuletzt benutzte Eingabeart als data-eingabe an das
  Wurzelelement. Solange sie "zeiger" ist, entfaellt der Ring an Knoepfen und
  Links. Eingabefelder sind ausgenommen, dort gehoert er zum Zustand "hier
  wird gerade geschrieben". Der erste Tastendruck schaltet ihn ueberall zurueck.

Dabei ist eine Luecke in der Fokusfalle aufgefallen und behoben: Mit dem Fokus
auf dem Rahmen lief Umschalt+Tab aus dem Dialog heraus, weil die Falle nur den
ersten Link als Umkehrpunkt kannte. Der Rahmen steht im Dokument vor seinen
Kindern und gehoert dazu — sonst waere die Zusage von aria-modal gebrochen.

Nachgemessen statt begutachtet, alle Faelle:
  Maus offen        → Rahmen, kein Ring
  Maus zu           → Hamburger, kein Ring
  Tastatur offen    → Rahmen, kein Ring
  Tastatur erstes   → "Dashboard", RING
  Tastatur rueckwaerts → "Komponenten", RING (Falle haelt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-31 19:48:26 +02:00
1 parent b8d44f7f7d
commit 7bbbd00ed2
2 files changed
+86 -7

No files matched your search

+35 -1
View File
@@ -95,12 +95,46 @@ body {
* Eine Regel fuer alles Interaktive statt focus:-Klassen an jedem Element.
* :focus-visible zeigt den Ring nur bei Tastaturbedienung, nicht beim Klick.
*/
:where(a, button, [role="button"], input, select, textarea, [tabindex]):focus-visible {
/*
* `[tabindex="-1"]` ist ausgenommen: Solche Elemente sind aus der
* Tabulatorreihenfolge heraus und bekommen den Fokus ausschliesslich per
* Skript — bei uns der Rahmen der Schublade, damit die Sprachausgabe den
* Dialog anliest. Ein Ring um den ganzen Rahmen waere dort keine Orientierung,
* sondern Laerm; die Orientierung gibt der naechste Tabulatorschritt.
*/
:where(
a,
button,
[role="button"],
input,
select,
textarea,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: none;
box-shadow: var(--shadow-focus, 0 0 0 3px rgb(7 145 240 / 0.4));
border-radius: var(--radius-sm, 4px);
}
/*
* Nachtrag zu :focus-visible — ein Fall, den der Browser falsch einschaetzt.
*
* Setzt ein Skript den Fokus, behandelt Chrome das wie Tastaturbedienung. Beim
* Oeffnen und Schliessen der Schublade tun wir genau das (Fokusfalle rein,
* Fokus zurueck auf den Hamburger) — nach einem Mausklick stand der Ring
* danach sichtbar da, bis man woanders hinklickte.
*
* `data-eingabe` schreibt AppShell mit: "zeiger" oder "tastatur", je nach
* letzter Eingabe. Der Ring entfaellt nur waehrend Zeigerbedienung und nur an
* Knoepfen und Links. Eingabefelder sind ausgenommen — dort gehoert der Ring
* zum Zustand "hier wird gerade geschrieben", auch nach einem Klick. Der erste
* Tastendruck schaltet ihn ueberall wieder ein.
*/
:root[data-eingabe="zeiger"]
:where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
box-shadow: none;
}
/*
* Der Einklapp-Knopf der Seitenleiste ragt bauartbedingt 17 px nach rechts
* heraus. Faehrt die Leiste unterhalb `md` als Schublade nach links aus dem
@@ -71,9 +71,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
* Ohne Falle wandert der Tabulator hinter die Abdeckung in eine Seite, die
* optisch verdeckt ist — die Zusage wäre eine Unwahrheit.
*
* Übernommen aus `SubframeAppShell` im Zielsystem: Fokus auf das erste
* bedienbare Element, Tab und Umschalt+Tab laufen zyklisch, Escape schließt
* und gibt den Fokus an den Auslöser zurück.
* Übernommen aus `SubframeAppShell` im Zielsystem: Tab und Umschalt+Tab
* laufen zyklisch, Escape schließt und gibt den Fokus an den Auslöser
* zurück.
*
* Beim Öffnen bekommt der **Rahmen** den Fokus, nicht der erste Link. Das
* ist das übliche Vorgehen für einen Dialog und hat hier einen sichtbaren
* Grund: Ein per Skript fokussierter Link zeigt in Chrome seinen Fokusring,
* auch wenn die Schublade mit der Maus geöffnet wurde — dann liegt ein
* blauer Rahmen um „Dashboard", ohne dass jemand dorthin getippt hat. Der
* Rahmen selbst trägt `tabIndex={-1}` und keinen Ring; die Sprachausgabe
* liest trotzdem den Dialog an, und der erste Tabulatorschritt landet auf
* dem ersten Link.
*/
React.useEffect(() => {
if (!mobilOffen) return;
@@ -87,7 +96,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
)
).filter((el) => el.getClientRects().length > 0);
window.requestAnimationFrame(() => bedienbar()[0]?.focus());
window.requestAnimationFrame(() => leiste.focus());
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -101,7 +110,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const erstes = elemente[0];
const letztes = elemente[elemente.length - 1];
const aktiv = document.activeElement;
if (event.shiftKey && (aktiv === erstes || !leiste.contains(aktiv))) {
// `aktiv === leiste` gehört dazu: Direkt nach dem Öffnen liegt der Fokus
// auf dem Rahmen selbst. Er steht im Dokument vor seinen Kindern, also
// muss Umschalt+Tab von dort ans Ende springen — sonst verlässt der
// Tabulator den Dialog rückwärts, und die Zusage von `aria-modal` wäre
// gebrochen.
if (event.shiftKey && (aktiv === erstes || aktiv === leiste || !leiste.contains(aktiv))) {
event.preventDefault();
letztes.focus();
} else if (!event.shiftKey && aktiv === letztes) {
@@ -115,6 +129,36 @@ export function AppShell({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mobilOffen]);
/**
* Zuletzt benutzte Eingabeart am Wurzelelement vermerken.
*
* `:focus-visible` allein reicht nicht: Wenn ein Skript den Fokus setzt —
* beim Schließen der Schublade zurück auf den Hamburger — behandelt Chrome
* das wie Tastaturbedienung und zeigt den Ring. Nach einem Mausklick steht
* er dann sichtbar da, bis man woanders hinklickt.
*
* Die Regel dazu steht in `app/globals.css`. Sie unterdrückt den Ring nur,
* solange die letzte Eingabe ein Zeiger war, und nur an Knöpfen und Links —
* Eingabefelder behalten ihn immer, dort gehört er zum Zustand „hier wird
* gerade geschrieben". Der erste Tastendruck schaltet ihn sofort zurück.
*/
React.useEffect(() => {
const wurzel = document.documentElement;
const zeiger = () => {
wurzel.dataset.eingabe = "zeiger";
};
const tastatur = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey || event.ctrlKey) return;
wurzel.dataset.eingabe = "tastatur";
};
window.addEventListener("pointerdown", zeiger, true);
window.addEventListener("keydown", tastatur, true);
return () => {
window.removeEventListener("pointerdown", zeiger, true);
window.removeEventListener("keydown", tastatur, true);
};
}, []);
const istAktiv = (eintrag: NavEintrag) =>
eintrag.ziel.href === "/analytics"
? pathname.startsWith("/analytics")
@@ -165,7 +209,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div
ref={leisteRef}
data-shell-sidebar
className={`dv-no-print z-50 h-screen flex-none max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform md:sticky md:top-0 ${
tabIndex={istSchmal && mobilOffen ? -1 : undefined}
className={`dv-no-print z-50 outline-none h-screen flex-none max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform md:sticky md:top-0 ${
mobilOffen ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`}
role={istSchmal ? "dialog" : undefined}