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:
1 parent
ce68729233
commit
6ac9eef93b
11 files changed
+221
-9
No files matched your search
@@ -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"];
|
||||
|
||||
@@ -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>
|
||||
<Sidebar user={user} />
|
||||
<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>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
<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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user