fix: Beute-Editor fängt den Fokus, SVG-Icon im Kopf

- Auf /colonies/[id]/hive bekommen Sprunglink, Seitenleiste, Kopf,
  Offline-Band, untere Navigation und „+ Neu“ das Attribut inert; der
  erste Tab landet auf „Zurück“ im Editor (vorher Stopp 34).
- metadata.icons verlinkt /icon.svg neben favicon.ico und apple-icon.
- Tests: alle THEME_COLORS gleich dem jeweiligen --background,
  ausdrücklich gewähltes Dunkel färbt die theme-color.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:20:28 +02:00
1 parent ce68729233
commit 6ac9eef93b
11 files changed
+218 -6

No files matched your search

+53 -1
View File
@@ -63,12 +63,14 @@ vi.mock("@/components/offline/offline-band", () => ({
userId,
pageContentRef,
syncEnabled,
inert,
}: {
userId: string;
pageContentRef: { current: HTMLElement | null };
syncEnabled?: boolean;
inert?: boolean;
}) => (
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)} inert={inert}>
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
@@ -92,6 +94,15 @@ vi.mock("@/lib/photos/store", () => ({
// and since the page itself scrolls on the window, the bar would never stick.
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
/** Tab stops in document order as a browser sees them: jsdom and user-event ignore `inert`. */
function tabStops(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).filter(
(element) => element.closest("[inert]") === null,
);
}
function expectNoScrollContainerAbove(element: HTMLElement) {
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
@@ -127,6 +138,47 @@ describe("AppLayout", () => {
expect(content).toHaveFocus();
});
it("hides the whole shell from the keyboard and screen readers behind the full-screen Beute editor", () => {
pathname = "/colonies/c1/hive";
permissions = ["inspections:create"];
render(
<AppLayout>
<main>
<button type="button">Zurück</button>
<h1>Umbauen</h1>
</main>
</AppLayout>,
);
const back = screen.getByRole("button", { name: "Zurück" });
// The first Tab lands in the editor, not on 33 links of the sidebar and header behind it.
expect(tabStops()[0]).toBe(back);
expect(back.closest("[inert]")).toBeNull();
for (const shellPart of [
screen.getByText("Zum Inhalt springen"),
screen.getAllByRole("link", { name: /Dashboard|Start/ })[0],
screen.getByRole("button", { name: "Konto-Menü" }),
screen.getByTestId("offline-band"),
...document.querySelectorAll<HTMLElement>("aside, header, nav"),
]) {
expect(shellPart.closest("[inert]"), shellPart.outerHTML.slice(0, 60)).not.toBeNull();
}
// The phone's „+ Neu" would float over the editor: it is not there at all.
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
it("keeps the shell reachable on every other page", () => {
pathname = "/colonies/c1";
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(document.querySelector("[inert]")).toBeNull();
expect(tabStops()[0]).toBe(screen.getByRole("link", { name: "Zum Inhalt springen" }));
});
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
pathname = "/suggestions";
permissions = ["inspections:create"];
+17 -1
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { isFullScreenPage } from "./nav-config";
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
@@ -72,6 +73,13 @@ export default function AppLayout({ children }: { children: ReactNode }) {
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
: "pb-(--bottom-nav-height)";
// A full-screen page (the Beute editor) covers the shell: everything of the
// shell turns inert, so the first Tab lands in the page and screen readers
// skip what cannot be seen. Decided by route, like the quick-add button:
// known on the first render and the same for the page's notice and loading
// states. `contents` wrappers keep the boxes where they are in the layout.
const isShellInert = isFullScreenPage(pathname);
// The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
@@ -79,6 +87,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
@@ -89,9 +98,13 @@ export default function AppLayout({ children }: { children: ReactNode }) {
>
Zum Inhalt springen
</a>
<div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<header
inert={isShellInert}
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden"
@@ -143,6 +156,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
inert={isShellInert}
/>
<div
id={MAIN_CONTENT_ID}
@@ -153,8 +167,10 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<PageTransition>{children}</PageTransition>
</div>
</div>
<div className="contents" inert={isShellInert}>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
@@ -3,6 +3,7 @@ import {
ADMIN_GROUP,
BOTTOM_NAV_HREFS,
NAV_GROUPS,
isFullScreenPage,
visibleNavGroups,
} from "./nav-config";
@@ -93,4 +94,13 @@ describe("nav-config", () => {
expect(group?.label).toBe("Betrieb");
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
});
it("knows the Beute editor („Umbauen“) as the one full-screen page", () => {
expect(isFullScreenPage("/colonies/c1/hive")).toBe(true);
expect(isFullScreenPage("/colonies/c%201/hive/")).toBe(true);
expect(isFullScreenPage("/colonies/c1")).toBe(false);
expect(isFullScreenPage("/colonies")).toBe(false);
expect(isFullScreenPage("/colonies/c1/hive/history")).toBe(false);
expect(isFullScreenPage("/apiaries/a1/hive")).toBe(false);
});
});
@@ -226,3 +226,15 @@ export function bottomNavItems(): NavItem[] {
export function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
// „Umbauen" (spec 9.5): its own route, drawn over the whole screen inside the shell.
const FULL_SCREEN_PAGE = /^\/colonies\/[^/]+\/hive\/?$/;
/**
* Whether the page covers the whole screen. The shell behind it (skip link,
* sidebar, header, offline band, bottom nav) then turns inert, so the first
* Tab lands in the page and screen readers do not read what cannot be seen.
*/
export function isFullScreenPage(pathname: string): boolean {
return FULL_SCREEN_PAGE.test(pathname);
}
@@ -10,6 +10,7 @@ import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
import { isFullScreenPage } from "./nav-config";
import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the
@@ -24,6 +25,8 @@ const FORM_ROUTE = /\/(new|edit)(\/|$)/;
export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean {
return (
!FORM_ROUTE.test(pathname) &&
// A full-screen page (the Beute editor) would sit under the floating button.
!isFullScreenPage(pathname) &&
QUICK_ACTIONS.some((action) => permissions.includes(action.permission))
);
}
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment node
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { Icon, IconDescriptor } from "next/dist/lib/metadata/types/metadata-types";
import { THEME_COLORS } from "@/lib/appearance/appearance-core";
import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
import { metadata, viewport } from "./layout";
// next/font only works inside the Next.js compiler.
vi.mock("next/font/local", () => ({ default: () => ({ variable: "font" }) }));
// The <head> of every page: icons (spec 12.2) and the browser chrome colour (task A2/A4/A9).
const ROOT = process.cwd();
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
function descriptors(icons: Icon | Icon[] | undefined): IconDescriptor[] {
return [icons ?? []].flat().map((icon) => (typeof icon === "object" && "url" in icon ? icon : { url: String(icon) }));
}
describe("root layout head", () => {
it("links the SVG icon next to favicon.ico and keeps the apple-touch-icon", () => {
const icons = metadata.icons as { icon?: Icon[]; apple?: Icon[] };
expect(descriptors(icons.icon)).toContainEqual(expect.objectContaining({ url: "/icon.svg", type: "image/svg+xml" }));
expect(existsSync(path.join(ROOT, "public/icon.svg"))).toBe(true);
// As soon as metadata.icons is set, Next.js drops the file-based apple-icon from
// the head (favicon.ico stays): it has to be listed here.
expect(descriptors(icons.apple)).toContainEqual(
expect.objectContaining({ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }),
);
expect(existsSync(path.join(ROOT, "src/app/apple-icon.png"))).toBe(true);
expect(existsSync(path.join(ROOT, "src/app/favicon.ico"))).toBe(true);
});
it("THEME_COLORS are the --background of the four colour sets in globals.css", () => {
const background = (set: Parameters<typeof tokenSet>[1]) => hexOf(oklchOf(tokenSet(css, set).background));
expect(THEME_COLORS).toEqual({
light: background("light"),
dark: background("dark"),
sunLight: background("sun-light"),
sunDark: background("sun-dark"),
});
expect(THEME_COLORS.dark).toBe("#1a120d");
});
it("before the inline script runs, the dark device gets the dark page colour", () => {
expect(viewport.themeColor).toEqual([
{ media: "(prefers-color-scheme: light)", color: THEME_COLORS.light },
{ media: "(prefers-color-scheme: dark)", color: THEME_COLORS.dark },
]);
});
});
+7
View File
@@ -35,6 +35,13 @@ const plexMono = localFont({
export const metadata: Metadata = {
title: "ApiCore – Imkereiverwaltung",
description: "Digitale Imkereiverwaltung für Wabenhain",
// favicon.ico (src/app) stays first by file convention. The SVG lives in
// public/ for the manifest and the service worker. Setting icons here drops
// the file-based apple-icon from the head, so it is listed again.
icons: {
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
},
};
export const viewport: Viewport = {
@@ -103,6 +103,28 @@ describe("appearanceScript", () => {
}
});
it("an explicit dark choice paints both media theme-color metas dark, on a light device too", () => {
stubMedia(false, false);
const metas = (["light", "dark"] as const).map((scheme) => {
const meta = document.createElement("meta");
meta.name = "theme-color";
meta.media = `(prefers-color-scheme: ${scheme})`;
meta.content = scheme === "light" ? "#f9f4ea" : "#1a120d";
document.head.append(meta);
return meta;
});
window.localStorage.setItem(APPEARANCE_KEYS.theme, "dark");
run(appearanceScript());
expect(metas.map((meta) => meta.content)).toEqual(["#1a120d", "#1a120d"]);
// Light is the default (dark only on request): a dark device without a choice stays light.
window.localStorage.clear();
stubMedia(true, false);
run(appearanceScript());
expect(metas.map((meta) => meta.content)).toEqual(["#f9f4ea", "#f9f4ea"]);
});
it("has no build flag for dark any more (task A4)", () => {
expect(appearanceScript()).not.toMatch(/DARK=/);
});
@@ -34,11 +34,11 @@ function setOnLine(value: boolean) {
}
/** The band as the layout places it: above the page content wrapper. */
function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) {
function BandOnPage({ syncEnabled, inert }: { syncEnabled?: boolean; inert?: boolean }) {
const pageContentRef = useRef<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} />
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} inert={inert} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
@@ -46,7 +46,7 @@ function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) {
);
}
function renderBand(props: { syncEnabled?: boolean } = {}) {
function renderBand(props: { syncEnabled?: boolean; inert?: boolean } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
@@ -85,6 +85,19 @@ describe("OfflineBand", () => {
vi.restoreAllMocks();
});
it("leaves the tab order and the accessibility tree while a full-screen page covers it", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
const { unmount } = renderBand({ inert: true });
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
expect(document.querySelector('[aria-live="polite"]')).toHaveAttribute("inert");
unmount();
renderBand();
expect(screen.getByRole("status")).not.toHaveAttribute("inert");
});
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
@@ -38,6 +38,8 @@ interface OfflineBandProps {
* nor sends - the server would only answer 401.
*/
syncEnabled?: boolean;
/** True while a full-screen page covers the shell: out of the tab order and the accessibility tree. */
inert?: boolean;
}
/**
@@ -45,7 +47,7 @@ interface OfflineBandProps {
* device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back.
*/
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
export function OfflineBand({ userId, pageContentRef, syncEnabled = true, inert }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
@@ -136,6 +138,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
role="status"
aria-live="polite"
tabIndex={-1}
inert={inert}
className={cn(
hasContent &&
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
@@ -140,6 +140,25 @@ describe("useAppearance", () => {
expect(document.documentElement).toHaveClass("dark");
});
it("picking dark sets every theme-color meta to the dark page colour, and back", () => {
const metas = ["light", "dark"].map((scheme) => {
const meta = document.createElement("meta");
meta.name = "theme-color";
meta.media = `(prefers-color-scheme: ${scheme})`;
document.head.append(meta);
return meta;
});
const { result } = renderHook(() => useAppearance());
// Earlier tests leave a sun choice in the module's per-visit fallback.
act(() => result.current.setContrast("normal"));
act(() => result.current.setTheme("dark"));
expect(metas.map((meta) => meta.content)).toEqual(["#1a120d", "#1a120d"]);
act(() => result.current.setTheme("light"));
expect(metas.map((meta) => meta.content)).toEqual(["#f9f4ea", "#f9f4ea"]);
});
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
const effective = renderHook(() => useEffectiveMotionLevel());
const appearance = renderHook(() => useAppearance());