From 7bbbd00ed25de6d6e0a2a1981843d943748f2c62 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Mon, 31 Aug 2026 19:48:26 +0200 Subject: [PATCH] Fokusring nur noch bei Tastaturbedienung zeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX --- app/globals.css | 36 +++++++++++- .../dashboard/components/layout/AppShell.tsx | 57 +++++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/globals.css b/app/globals.css index 206da8c..4f53a3d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 diff --git a/features/dashboard/components/layout/AppShell.tsx b/features/dashboard/components/layout/AppShell.tsx index 2acbcf1..2d0831d 100644 --- a/features/dashboard/components/layout/AppShell.tsx +++ b/features/dashboard/components/layout/AppShell.tsx @@ -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 }) {