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 }) {