feat(t5): Wächter vollständig, Hinweis Sonnenmodus, Nacharbeiten (T5a A10)
- Baukasten-Wächter: jede Regel prüft eigene Beispiele (muss fangen, muss ignorieren), veraltete Ausnahmen und Ordner-Ausschlüsse schlagen fehl; neue Regeln für text-primary ohne -ink, dark:, Schrift unter 13 px (Tailwind, SVG, CSS, style, *_SIZE-Konstanten) und Verlaufs-Hintergründe. Aufgeteilt in baukasten-guard, touch-target-guard und einen Scan-Helfer. - Hinweis „Neu: Sonnenmodus für draußen“ auf dem Dashboard, einmal je Gerät, „Ausprobieren“ schaltet live um; Fokus geht danach zur Überschrift. - Varroa-Diagramm: Beschriftungen als HTML mit fester Größe statt SVG-Text. - Medikamente zeigen die Tabs auch leer; Zahlen in Tabs nicht beim Laden oder Fehler; inaktive Tabs mit Hover für die Maus; TabPicker (Auswahl unter 640 px) für Automatisierung und Statistik; Rollen-Dialoge breit genug für die Rechte-Matrix. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
355a353671
commit
427969d8b9
42 files changed
+1053
-400
No files matched your search
@@ -2,7 +2,9 @@ import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
|
||||
function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
|
||||
const [value, setValue] = useState<EntityLifecycleTab>("active");
|
||||
@@ -112,4 +114,29 @@ describe("ActiveHistoryTabs (spec 10.4)", () => {
|
||||
expect(classes).toContain("pointer-fine:h-9");
|
||||
}
|
||||
});
|
||||
|
||||
it("countOf counts only loaded data: no number while loading or after a failed first load", () => {
|
||||
expect(countOf(undefined, [])).toBeUndefined();
|
||||
expect(countOf([], [])).toBe(0);
|
||||
expect(countOf([1, 2, 3], [1, 3])).toBe(2);
|
||||
});
|
||||
|
||||
it("every list passes its counts through countOf, so a failed load never says (0)", () => {
|
||||
const src = join(__dirname, "..");
|
||||
const files = (function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return walk(path);
|
||||
return /\.tsx$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
})(src);
|
||||
const users = files.filter((file) => /<ActiveHistoryTabs\b/.test(readFileSync(file, "utf8")));
|
||||
expect(users.length).toBeGreaterThanOrEqual(10);
|
||||
const wrong = users.filter((file) => {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const counts = [...text.matchAll(/(?:active|history)Count=\{([^}]*)\}/g)].map((match) => match[1]);
|
||||
return counts.length !== 2 || counts.some((expression) => !/^countOf\(/.test(expression.trim()));
|
||||
});
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ export interface ActiveHistoryTabsProps {
|
||||
children: { active: ReactNode; history: ReactNode };
|
||||
}
|
||||
|
||||
/**
|
||||
* The count for a tab: unknown (no number) until the list has loaded, so a failed or pending
|
||||
* first load never reads „Aktiv (0)“. Cached data still counts while a refetch fails.
|
||||
*/
|
||||
export function countOf(data: readonly unknown[] | undefined, items: readonly unknown[]): number | undefined {
|
||||
return data === undefined ? undefined : items.length;
|
||||
}
|
||||
|
||||
const withCount = (label: string, count: number | undefined) =>
|
||||
count === undefined ? label : `${label} (${count})`;
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
|
||||
|
||||
const TEXT = "Neu: Sonnenmodus für draußen – im Benutzermenü.";
|
||||
const KEY = "apicore.notice.sun";
|
||||
|
||||
/** Fresh modules per test: the notice and the appearance hook keep a per-visit memory. */
|
||||
async function loadNotice() {
|
||||
vi.resetModules();
|
||||
const { AppearanceNotice, SUN_NOTICE_KEY } = await import("./appearance-notice");
|
||||
const { toast } = await import("sonner");
|
||||
return { AppearanceNotice, SUN_NOTICE_KEY, toast };
|
||||
}
|
||||
|
||||
describe("AppearanceNotice (spec 15.4)", () => {
|
||||
let media: ReturnType<typeof mockMedia>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetDocument();
|
||||
media = mockMedia();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
media.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("the notice shows once, Ausprobieren enables sun and Schließen remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { AppearanceNotice, SUN_NOTICE_KEY } = await loadNotice();
|
||||
expect(SUN_NOTICE_KEY).toBe(KEY);
|
||||
|
||||
const first = render(<AppearanceNotice />);
|
||||
expect(await screen.findByText(TEXT)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Schließen" }));
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
expect(window.localStorage.getItem(KEY)).toBe("seen");
|
||||
expect(document.documentElement.getAttribute("data-contrast")).not.toBe("sun");
|
||||
first.unmount();
|
||||
|
||||
// Another visit on this device: not again.
|
||||
const { AppearanceNotice: Again } = await loadNotice();
|
||||
render(<Again />);
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
});
|
||||
|
||||
it("Ausprobieren switches sun on at once, says so and does not come back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { AppearanceNotice, toast } = await loadNotice();
|
||||
render(<AppearanceNotice />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Ausprobieren" }));
|
||||
expect(document.documentElement.getAttribute("data-contrast")).toBe("sun");
|
||||
expect(window.localStorage.getItem("apicore.contrast")).toBe("sun");
|
||||
expect(window.localStorage.getItem(KEY)).toBe("seen");
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith("Sonnenmodus an");
|
||||
});
|
||||
|
||||
it("is a labelled, quiet hint: no honey button, both actions 44 px", async () => {
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
render(<AppearanceNotice />);
|
||||
const region = await screen.findByRole("complementary", { name: TEXT });
|
||||
for (const name of ["Ausprobieren", "Schließen"]) {
|
||||
const button = screen.getByRole("button", { name });
|
||||
expect(region).toContainElement(button);
|
||||
expect(button.className).not.toMatch(/(^|\s)bg-primary(\s|$)/);
|
||||
expect(button.className).toMatch(/(^|\s)h-11(\s|$)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("stays away while sun is already on", async () => {
|
||||
window.localStorage.setItem("apicore.contrast", "sun");
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
render(<AppearanceNotice />);
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
});
|
||||
|
||||
it("the notice works when storage is blocked", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
|
||||
const first = render(<AppearanceNotice />);
|
||||
await user.click(await screen.findByRole("button", { name: "Ausprobieren" }));
|
||||
expect(document.documentElement.getAttribute("data-contrast")).toBe("sun");
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
// Same visit, back on the start page: still gone, without storage.
|
||||
render(<AppearanceNotice />);
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
});
|
||||
|
||||
it("Schließen also works with blocked storage", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
render(<AppearanceNotice />);
|
||||
await user.click(await screen.findByRole("button", { name: "Schließen" }));
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
expect(document.documentElement.getAttribute("data-contrast")).not.toBe("sun");
|
||||
});
|
||||
|
||||
it.each(["Schließen", "Ausprobieren"])(
|
||||
"%s hands the focus to the page heading instead of dropping it to the body",
|
||||
async (name) => {
|
||||
const user = userEvent.setup();
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
render(
|
||||
<main>
|
||||
<h1 tabIndex={-1}>Guten Tag</h1>
|
||||
<AppearanceNotice />
|
||||
</main>,
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name }));
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
|
||||
},
|
||||
);
|
||||
|
||||
it("sun switched on elsewhere (user menu) counts as seen: the notice does not come back after sun is off", async () => {
|
||||
const { AppearanceNotice } = await loadNotice();
|
||||
const { useAppearance } = await import("@/lib/appearance/appearance");
|
||||
let api: ReturnType<typeof useAppearance> | null = null;
|
||||
function Menu() {
|
||||
api = useAppearance();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<Menu />
|
||||
<AppearanceNotice />
|
||||
</>,
|
||||
);
|
||||
await screen.findByText(TEXT);
|
||||
act(() => api?.setContrast("sun"));
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
expect(window.localStorage.getItem(KEY)).toBe("seen");
|
||||
act(() => api?.setContrast("normal"));
|
||||
expect(screen.queryByText(TEXT)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useSyncExternalStore } from "react";
|
||||
import { Sun } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAppearance } from "@/lib/appearance/appearance";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Remembered per device (spec 15.4): the notice is shown until it is closed or tried once. */
|
||||
export const SUN_NOTICE_KEY = "apicore.notice.sun";
|
||||
const SEEN = "seen";
|
||||
const CHANGE_EVENT = "apicore:notice";
|
||||
|
||||
/** Closed in this visit; covers blocked storage (private mode). */
|
||||
let seenThisVisit = false;
|
||||
|
||||
function isSeen(): boolean {
|
||||
if (seenThisVisit) return true;
|
||||
try {
|
||||
return window.localStorage.getItem(SUN_NOTICE_KEY) === SEEN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markSeen(): void {
|
||||
seenThisVisit = true;
|
||||
try {
|
||||
window.localStorage.setItem(SUN_NOTICE_KEY, SEEN);
|
||||
} catch {
|
||||
// Not persisted - gone for this visit only.
|
||||
}
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
window.addEventListener("storage", onChange);
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
window.removeEventListener("storage", onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice unmounts on close; focus inside it would drop to <body> (WCAG 2.4.3). It goes to
|
||||
* the page heading (h1 with tabIndex -1) instead.
|
||||
*/
|
||||
function keepFocus(): void {
|
||||
const heading = document.querySelector<HTMLElement>("main h1, h1");
|
||||
heading?.focus();
|
||||
}
|
||||
|
||||
const TEXT = "Neu: Sonnenmodus für draußen – im Benutzermenü.";
|
||||
|
||||
/** One-time hint on the start page: try the sun mode or close the hint. */
|
||||
export function AppearanceNotice() {
|
||||
const textId = useId();
|
||||
const { sunActive, setContrast } = useAppearance();
|
||||
// The server never shows it, so a device that has seen it gets no flash after hydration.
|
||||
const seen = useSyncExternalStore(subscribe, isSeen, () => true);
|
||||
// Sun switched on elsewhere (user menu, device setting) means the hint has done its job.
|
||||
useEffect(() => {
|
||||
if (sunActive && !seen) markSeen();
|
||||
}, [sunActive, seen]);
|
||||
if (seen || sunActive) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-labelledby={textId}
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<Sun className="size-5 shrink-0 text-primary-ink" aria-hidden />
|
||||
<p id={textId} className="min-w-0 flex-1 text-base">
|
||||
{TEXT}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
keepFocus();
|
||||
setContrast("sun");
|
||||
markSeen();
|
||||
toast.success("Sonnenmodus an");
|
||||
}}
|
||||
>
|
||||
Ausprobieren
|
||||
</Button>
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
keepFocus();
|
||||
markSeen();
|
||||
}}
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,14 @@ describe("CheckboxField (spec 10.3)", () => {
|
||||
expect(onChange.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it("a long label without spaces wraps instead of being clipped (Umsetzen/Teilen/Vereinigen/Auflösen)", () => {
|
||||
const label = "Umsetzen/Teilen/Vereinigen/Auflösen";
|
||||
render(<CheckboxField label={label} description="Lebenszyklus" checked={false} onCheckedChange={() => {}} />);
|
||||
for (const text of [label, "Lebenszyklus"]) {
|
||||
expect(classesOf(screen.getByText(text)), text).toEqual(expect.arrayContaining(["wrap-break-word", "hyphens-auto"]));
|
||||
}
|
||||
});
|
||||
|
||||
it("the row is at least 44 px high (min-h-11) and in a sheet 56 px (min-h-14)", () => {
|
||||
render(
|
||||
<>
|
||||
|
||||
@@ -44,6 +44,9 @@ function tileClass(isOn: boolean): string {
|
||||
: "items-center border border-input bg-card px-3 enabled:hover:bg-muted";
|
||||
}
|
||||
|
||||
/** Long German words and slash-joined labels (Umsetzen/Teilen/…) break instead of being clipped. */
|
||||
const WRAP = "wrap-break-word hyphens-auto";
|
||||
|
||||
const HINT_CLASS = "text-sm font-medium text-muted-foreground";
|
||||
|
||||
function joinIds(...ids: (string | false | undefined)[]): string | undefined {
|
||||
@@ -130,11 +133,11 @@ export function CheckboxField(props: CheckboxFieldProps) {
|
||||
) : null;
|
||||
const text = (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span id={labelId} className={cn(dimmed)}>
|
||||
<span id={labelId} className={cn(WRAP, dimmed)}>
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={descriptionId} className={cn("text-sm font-normal text-muted-foreground", dimmed)}>
|
||||
<span id={descriptionId} className={cn(WRAP, "text-sm font-normal text-muted-foreground", dimmed)}>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className ?? "pointer-events-none absolute inset-0 h-full w-full text-primary/[0.07]"}
|
||||
className={className ?? "pointer-events-none absolute inset-0 h-full w-full stroke-primary/[0.07]"}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
@@ -19,13 +19,12 @@ export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatte
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M28 0 56 16.2 56 48.5 28 64.7 0 48.5 0 16.2Z M28 64.7 56 80.8 56 97 28 97 0 97 0 80.8Z"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function NotificationBell() {
|
||||
>
|
||||
<Bell className="size-4.5" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground">
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs leading-none font-medium text-destructive-foreground">
|
||||
<span aria-hidden="true">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface TabPickerProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
/** Label of each tab, in this order. */
|
||||
labels: Record<T, string>;
|
||||
values: readonly NoInfer<T>[];
|
||||
/** Name of the tab bar. */
|
||||
listLabel?: string;
|
||||
/** Name of the phone select; say what is chosen. */
|
||||
selectLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab bar of a page with many areas, inside <Tabs>. On a phone the bar would wrap into
|
||||
* several rows, so below `sm` the same choice is a select; the panels stay labelled by the tabs.
|
||||
*/
|
||||
export function TabPicker<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
values,
|
||||
listLabel,
|
||||
selectLabel = "Bereich wählen",
|
||||
}: TabPickerProps<T>) {
|
||||
return (
|
||||
<>
|
||||
<EnumSelect
|
||||
ariaLabel={selectLabel}
|
||||
labels={labels}
|
||||
values={values}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="sm:hidden"
|
||||
/>
|
||||
<TabsList aria-label={listLabel} className="hidden sm:inline-flex">
|
||||
{values.map((entry) => (
|
||||
<TabsTrigger key={entry} value={entry}>
|
||||
{labels[entry]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const buttonVariants = cva(
|
||||
default:
|
||||
"h-11 pointer-fine:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
// 44 px: reliable with gloves or sticky fingers at the apiary.
|
||||
touch: "h-11 gap-2 px-4 text-base [&_svg:not([class*='size-'])]:size-5",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
||||
|
||||
@@ -17,6 +19,10 @@ function StatusSelect() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A display class with !important (`!flex`, `flex!`, variants too) would beat preflight's [hidden]. */
|
||||
const IMPORTANT_DISPLAY =
|
||||
/(^|\s)(?:[^\s:]+:)*(?:!(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)|(?:block|flex|grid|inline|inline-block|inline-flex|inline-grid|contents|table)!)(\s|$)/;
|
||||
|
||||
/** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */
|
||||
function reachableListboxes(): Element[] {
|
||||
return Array.from(document.querySelectorAll('[role="listbox"]')).filter(
|
||||
@@ -43,4 +49,35 @@ describe("SelectContent (spec 17.4)", () => {
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Aktiv" }));
|
||||
await waitFor(() => expect(reachableListboxes()).toEqual([]));
|
||||
});
|
||||
|
||||
it("a popup Base UI keeps mounted after an autofill (forceMount) is hidden, not a 0x0 listbox for AT", async () => {
|
||||
const { container } = render(<StatusSelect />);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
// Browser autofill writes the hidden input; Base UI then force-mounts the popup closed.
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "active" } });
|
||||
});
|
||||
// The closed popup is in the DOM now: the 0x0 listbox seen in the browser.
|
||||
await waitFor(() => expect(document.querySelectorAll('[role="listbox"]').length).toBeGreaterThan(0));
|
||||
const mounted = Array.from(document.querySelectorAll('[role="listbox"]'));
|
||||
for (const listbox of mounted) {
|
||||
// The closed popup's positioner carries `hidden`. Preflight hides [hidden] with
|
||||
// `display: none !important`, so only an important display class could bring it back.
|
||||
const positioner = listbox.closest("[data-base-ui-portal] > *") as HTMLElement;
|
||||
expect(positioner.hasAttribute("hidden")).toBe(true);
|
||||
expect(positioner.className).not.toMatch(IMPORTANT_DISPLAY);
|
||||
expect(listbox.closest("[hidden]")).toBe(positioner);
|
||||
}
|
||||
expect(readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8")).toMatch(/@import\s+["']tailwindcss["']/);
|
||||
expect(reachableListboxes()).toEqual([]);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("the important-display check catches !flex and flex! and leaves plain classes alone", () => {
|
||||
expect(IMPORTANT_DISPLAY.test("isolate z-50 !flex")).toBe(true);
|
||||
expect(IMPORTANT_DISPLAY.test("md:flex! z-50")).toBe(true);
|
||||
expect(IMPORTANT_DISPLAY.test("isolate z-50 flex")).toBe(false);
|
||||
expect(IMPORTANT_DISPLAY.test("isolate z-50")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,20 @@ describe("Tabs (spec 10.4)", () => {
|
||||
expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("an inactive tab answers a mouse hover with surface, text and a neutral bar, never a touch", () => {
|
||||
render(<Harness initial="b" />);
|
||||
const inactive = screen.getByRole("tab", { name: "Völker" });
|
||||
for (const token of [
|
||||
"pointer-fine:hover:not-data-active:bg-card/60",
|
||||
"pointer-fine:hover:not-data-active:border-b-input",
|
||||
"pointer-fine:hover:text-foreground",
|
||||
]) {
|
||||
expect(has(inactive, token), token).toBe(true);
|
||||
}
|
||||
// Same alpha as TAB_HOVER_ALPHA in contrast.spec.ts. A bare hover: would stick after a tap.
|
||||
expect(inactive.className).not.toMatch(/(^|\s)hover:/);
|
||||
});
|
||||
|
||||
it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => {
|
||||
render(<Harness />);
|
||||
const list = screen.getByRole("tablist");
|
||||
|
||||
@@ -43,6 +43,7 @@ function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
|
||||
* --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground,
|
||||
* semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour
|
||||
* alone. The top border keeps the label centred against the bar. Focus is the global ring.
|
||||
* A mouse hover (pointer-fine only, no sticky tap) previews it: card/60, --foreground, --input bar.
|
||||
*/
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
@@ -50,7 +51,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border-t-3 border-b-3 border-transparent px-4 text-base font-medium whitespace-nowrap text-muted-foreground pointer-fine:h-9",
|
||||
"hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
"pointer-fine:hover:text-foreground pointer-fine:hover:not-data-active:bg-card/60 pointer-fine:hover:not-data-active:border-b-input disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
"data-active:border-b-primary-ink data-active:bg-card data-active:font-semibold data-active:text-foreground",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
import { Button } from "./button";
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A Tailwind class with the dark variant, e.g. `dark:bg-input/30` (not an object key `dark: x`). */
|
||||
const DARK_CLASS = /(^|[\s"'`])dark:[^\s"'`]/m;
|
||||
|
||||
describe("ui colours after the value switch (spec 10.8 step 4)", () => {
|
||||
it("the default button uses solid primary with primary-foreground and no gradient", () => {
|
||||
render(<Button>Speichern</Button>);
|
||||
@@ -39,16 +24,5 @@ describe("ui colours after the value switch (spec 10.8 step 4)", () => {
|
||||
expect(screen.getByText("Link").className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
|
||||
expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/);
|
||||
});
|
||||
|
||||
it("ui components contain no dark: classes", () => {
|
||||
const offenders = sourceFiles(join(SRC, "components", "ui")).filter((file) =>
|
||||
DARK_CLASS.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source file uses dark: classes - a mode is only another token set", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => DARK_CLASS.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
// `dark:` classes: the baukasten guard (src/styles/baukasten-guard.spec.ts) bans them in all of src.
|
||||
});
|
||||
Reference in new issue
Block a user