Compare commits

...
4 Commits
Author SHA1 Message Date
Nick.RosenloecherandClaude Opus 5.5 2f95696efb test: notifications-e2e mit 60 s für den Aufbau (CI-Lauf 826)
Backend E2E / e2e (push) Failing after 14m25s
Backend Image / build-and-push (push) Failing after 10m58s
Frontend Image / build-and-push (push) Successful in 25m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:46:52 +02:00
Nick.RosenloecherandClaude Opus 5.5 208e9baaf0 feat(t5): Auswahlgruppe mit Haken, Karten-Variante, Plus/Minus (T5a A6)
- ChoiceGroup nach Spec 10.2: gewählt mit Haken und 2-px-Tintenrahmen,
  „Nicht geprüft“ gestrichelt; neue Variante appearance="cards" mit Icon
  und Beschreibung (Winter-Ergebnis, Darstellung, Behördenmeldung).
- Eigenbau-Radios in Behördenmeldung, Phänologie-Bericht und
  Zeilen-Ausrichtung durch ChoiceGroup ersetzt.
- Button pressed → aria-pressed (Fett/Kursiv, „Aufbau als Liste“);
  Druck-Versatz nur mit motion-full.
- NumberStepper (ganze Schritte) für Winter-Waben, Milbenzahl (neu und
  bearbeiten), Weiselzellen, Entnahme/Zählbestand, Startposition; Entnahme
  nur 1 bis Restmenge, Startposition höchstens 1000.
- Baukasten-Wächter: Radios nur in ChoiceGroup, aria-pressed nur in
  Button und Kalender, auch in Objektform und .ts-Dateien.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:44:31 +02:00
Nick.RosenloecherandClaude Opus 5.5 6ac9eef93b 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]>
2026-10-16 18:20:28 +02:00
Nick.RosenloecherandClaude Opus 5.5 ce68729233 feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)
- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width
  und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen;
  --text-xs 0.8125rem.
- Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und
  outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit
  focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt.
- Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer
  44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand.
- 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln.
- .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring.
- Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln
  aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:06:20 +02:00
114 changed files with 1965 additions and 344 deletions

No files matched your search

@@ -91,7 +91,9 @@ describe('Notifications (e2e)', () => {
ids[who] = user.id; ids[who] = user.id;
tokens[who] = (await login(app, emails[who], password)).accessToken; tokens[who] = (await login(app, emails[who], password)).accessToken;
} }
}); // App start plus four bcrypt-12 logins exceeded the 20 s default on a
// busy CI runner (run 826).
}, 60_000);
afterAll(async () => { afterAll(async () => {
const userIds = Object.values(ids); const userIds = Object.values(ids);
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* T5 focus and size codemod (spec 4.5, 4.6 and 10.8 step 5). Pure and idempotent:
*
* - One focus style: the base rule `:where(:focus-visible)` in globals.css draws it. Component
* classes that remove the outline or draw an own ring go: `outline-none` (also behind
* `focus:`/`focus-visible:`), `focus-visible:ring-3`, `focus-visible:ring-ring/50`,
* `focus-visible:border-ring`, own outline rings (`focus-visible:outline-2 …-ring`, the hive
* glass edge ring, which `.holo-stage` now provides) and variant focus colours.
* - A row whose focus target sits inside (label with its input) keeps one ring on the row:
* `has-[:focus-visible]:outline-*`/`ring-*` become the utility `focus-ring-within`.
* - Compact sizes follow the pointer, not the viewport: `sm:h-*`/`sm:min-h-*`/`sm:size-*` (also
* behind a further variant) become `pointer-fine:…`.
* - `focus:outline-2 focus:outline-ring` stays: it marks elements focused by script after a
* click, where :focus-visible does not match (guarded with an allow-list).
* - A class string that held nothing but focus classes is dropped together with its line.
*
* Selection outlines (`outline-2 outline-foreground`) and menu items' `outline-hidden` stay.
*
* CLI: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>
* --check lists the files that would change and exits 1 if there are any.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Characters that belong to a class token (so a match never starts or ends inside one). */
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
/** Classes removed outright. */
const REMOVED = [
"outline-none",
"focus:outline-none",
"focus-visible:outline-none",
"focus-visible:ring-3",
"focus-visible:ring-[3px]",
"focus-visible:ring-ring/50",
"focus-visible:border-ring",
"focus:ring-3",
"focus:ring-ring/50",
"focus-visible:outline-1",
"focus-visible:outline-2",
"focus-visible:outline-ring",
"focus-visible:outline-offset-2",
"focus-visible:outline-holo-edge-strong",
"focus-visible:ring-destructive/20",
"focus-visible:border-destructive/40",
"focus-visible:ring-warning/30",
];
/** Own rings on a row with an inner focus target; replaced by one `focus-ring-within`. */
const WITHIN = [
"has-[:focus-visible]:outline-2",
"has-[:focus-visible]:outline-ring",
"has-[:focus-visible]:ring-3",
"has-[:focus-visible]:ring-ring/50",
"has-focus-visible:ring-3",
"has-focus-visible:ring-ring/50",
];
const WITHIN_UTILITY = "focus-ring-within";
function alternativesOf(classes) {
return `(?:${classes.map(escape).join("|")})(?!${TOKEN_CHAR})`;
}
/**
* Removes the classes so that exactly one separator stays: a class after a space goes with
* that space, a class at the start of a string goes with the space after it.
*/
function removeClasses(line, classes) {
const alternatives = alternativesOf(classes);
return line
.replace(new RegExp(` ${alternatives}`, "g"), "")
.replace(new RegExp(`(?<!${TOKEN_CHAR})${alternatives} ?`, "g"), "");
}
const WITHIN_FIRST = new RegExp(`(?<!${TOKEN_CHAR})${alternativesOf(WITHIN)}`);
/** `sm:` plus any further variants (`sm:data-[size=default]:`) in front of `h-`/`min-h-`/`size-`. */
const SM_HEIGHT = new RegExp(String.raw`(?<!${TOKEN_CHAR})sm:((?:[^\s:"'\x60]+:)*)((?:min-)?h-|size-)`, "g");
const EMPTY_CLASS_LINE = /^\s*(["'`])\1,?\s*\r?$/;
function transformLine(line) {
let out = removeClasses(line, REMOVED);
if (WITHIN_FIRST.test(out)) {
out = removeClasses(out.replace(WITHIN_FIRST, WITHIN_UTILITY), WITHIN);
}
return out.replace(SM_HEIGHT, "pointer-fine:$1$2");
}
/** Applies the focus and size rules to one source text; line endings are kept. */
export function transform(source) {
const lines = source.split("\n");
const result = [];
for (const line of lines) {
const out = transformLine(line);
if (out !== line && EMPTY_CLASS_LINE.test(out)) continue;
result.push(out);
}
return result.join("\n");
}
function sourceFiles(path) {
if (statSync(path).isDirectory()) {
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
}
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
}
function main(args) {
const check = args.includes("--check");
const targets = args.filter((arg) => arg !== "--check");
if (targets.length === 0) {
console.error("usage: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>");
return 2;
}
const changed = targets.flatMap(sourceFiles).filter((file) => {
const before = readFileSync(file, "utf8");
const after = transform(before);
if (after === before) return false;
if (!check) writeFileSync(file, after);
return true;
});
for (const file of changed) console.log(file);
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
return check && changed.length > 0 ? 1 : 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
@@ -0,0 +1,121 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { transform } from "./codemod-t5-focus.mjs";
describe("codemod t5 focus and sizes (spec 4.5, 4.6, 10.8 step 5)", () => {
it("drops outline-none and the component focus ring classes", () => {
expect(
transform(
'"min-h-11 rounded-lg px-2 outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"',
),
).toBe('"min-h-11 rounded-lg px-2 hover:bg-muted disabled:opacity-60"');
expect(transform('className="px-2 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"')).toBe(
'className="px-2"',
);
});
it("drops the own outline rings, including the stage edge ring of the hive glass", () => {
expect(transform('"rounded-full focus-visible:outline-2 focus-visible:outline-ring"')).toBe('"rounded-full"');
expect(
transform("`h-11 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${X}`"),
).toBe("`h-11 ${X}`");
});
it("keeps the focus: outline of elements focused by script after a click", () => {
// :focus-visible does not match a script focus that follows a pointer click.
const scripted = '"rounded-xl border p-3 focus:outline-2 focus:outline-ring"';
expect(transform(scripted)).toBe(scripted);
});
it("drops the skip link's own focus ring", () => {
expect(transform('"sr-only focus:not-sr-only focus:ring-3 focus:ring-ring/50 focus:outline-none"')).toBe(
'"sr-only focus:not-sr-only"',
);
});
it("drops variant focus colours on the destructive button and badges", () => {
expect(transform('"bg-destructive/10 text-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20"')).toBe(
'"bg-destructive/10 text-destructive"',
);
expect(transform('"bg-warning/15 focus-visible:ring-warning/30 [a]:hover:bg-warning/25"')).toBe(
'"bg-warning/15 [a]:hover:bg-warning/25"',
);
});
it("a row with an inner focus target keeps one ring on the outer element", () => {
expect(transform('"flex gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"')).toBe(
'"flex gap-3 rounded-xl border p-3 focus-ring-within"',
);
expect(transform('"flex min-h-14 px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"')).toBe(
'"flex min-h-14 px-3 focus-ring-within"',
);
expect(transform('"flex min-h-11 px-1 has-focus-visible:ring-3 has-focus-visible:ring-ring/50"')).toBe(
'"flex min-h-11 px-1 focus-ring-within"',
);
});
it("selection outlines that are not focus styles stay", () => {
const calendar = 'isSelected && "outline-2 -outline-offset-2 outline-foreground",';
expect(transform(calendar)).toBe(calendar);
const canvas = 'selected && "outline outline-2 outline-foreground",';
expect(transform(canvas)).toBe(canvas);
const menuItem = '"relative flex rounded-md text-sm outline-hidden select-none focus:bg-accent"';
expect(transform(menuItem)).toBe(menuItem);
});
it("a class string that only held focus classes disappears with its line", () => {
expect(transform('cn(\n "min-h-11 px-2",\n "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",\n x,\n)')).toBe(
'cn(\n "min-h-11 px-2",\n x,\n)',
);
});
it("sm:h-* and sm:min-h-* become pointer-fine, also behind a further variant", () => {
expect(transform('"h-10 sm:h-8 gap-1.5"')).toBe('"h-10 pointer-fine:h-8 gap-1.5"');
expect(transform('"min-h-11 sm:min-h-9 rounded-lg"')).toBe('"min-h-11 pointer-fine:min-h-9 rounded-lg"');
expect(transform('"data-[size=default]:h-11 sm:data-[size=default]:h-9"')).toBe(
'"data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9"',
);
});
it("sm:size-* becomes pointer-fine:size-*", () => {
expect(transform('className="size-11 sm:size-8"')).toBe('className="size-11 pointer-fine:size-8"');
});
it("other sm: classes stay", () => {
const layout = '"hidden sm:flex sm:hidden sm:gap-2 sm:px-4 sm:max-w-xs sm:max-h-96"';
expect(transform(layout)).toBe(layout);
});
it("keeps CRLF line endings", () => {
expect(transform('a\r\nclassName="px-2 outline-none"\r\n')).toBe('a\r\nclassName="px-2"\r\n');
});
it("the focus codemod is idempotent and keeps pointer-fine classes", () => {
const source = [
'"h-11 sm:h-8 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"',
'"pointer-fine:h-9 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"',
].join("\n");
const once = transform(source);
expect(once).toBe('"h-11 pointer-fine:h-8"\n"pointer-fine:h-9 focus-ring-within"');
expect(transform(once)).toBe(once);
});
it("leaves nothing to migrate in src", () => {
const files = sourceFiles(join(process.cwd(), "src"));
expect(files.length).toBeGreaterThan(100);
const pending = files.filter((file) => {
const text = readFileSync(file, "utf8");
return transform(text) !== text;
});
expect(pending).toEqual([]);
});
});
function sourceFiles(dir) {
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] : [];
});
}
@@ -78,7 +78,7 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
<Link <Link
key={row.id} key={row.id}
href={hrefFor(row.id)} href={hrefFor(row.id)}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none" className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
> >
<span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
@@ -281,4 +281,30 @@ describe("PrintLabelsCard", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/); expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
}); });
it("label print start position uses NumberStepper", async () => {
renderCard();
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
const input = screen.getByLabelText(/Startposition/);
expect(input).toHaveValue("1");
expect(input).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
expect(input).toHaveValue("3");
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
await userEvent.click(main);
await waitFor(() =>
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 3, proof: false }),
);
});
it("caps the start position at the server limit of 1000 when the template has no sheet size", async () => {
vi.mocked(fetchLabelTemplates).mockResolvedValue([{ ...TEMPLATE, columns: null, rows: null }]);
renderCard();
await screen.findByRole("button", { name: "Etiketten als PDF" });
const input = screen.getByLabelText(/Startposition/);
await userEvent.clear(input);
await userEvent.type(input, "5000");
expect(input).toHaveValue("1000");
});
}); });
@@ -17,16 +17,19 @@ import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { NumberStepper } from "@/components/form/number-stepper";
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
/** Highest start position the server takes (label-templates.controller.ts). */
const MAX_START_POSITION = 1000;
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden."; const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
const PRINT_DONE = "Etiketten-PDF heruntergeladen."; const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht."; const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden."; const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
const LINK_CLASS = const LINK_CLASS =
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring"; "inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4";
type FixTarget = "master" | "product" | "designer"; type FixTarget = "master" | "product" | "designer";
@@ -138,8 +141,10 @@ export function PrintLabelsCard({
const canCheck = canReadTemplates && hasPermission("bottling:read"); const canCheck = canReadTemplates && hasPermission("bottling:read");
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly(); const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
const reasonId = useId(); const reasonId = useId();
const startHintId = useId();
const [templateId, setTemplateId] = useState(""); const [templateId, setTemplateId] = useState("");
const [startPosition, setStartPosition] = useState(1); // "" while the field is emptied: printing then starts at the first label.
const [startPositionText, setStartPositionText] = useState("1");
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null); const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
const [printError, setPrintError] = useState<string | null>(null); const [printError, setPrintError] = useState<string | null>(null);
const [printDone, setPrintDone] = useState<string | null>(null); const [printDone, setPrintDone] = useState<string | null>(null);
@@ -160,6 +165,7 @@ export function PrintLabelsCard({
selectedTemplate?.columns && selectedTemplate?.rows selectedTemplate?.columns && selectedTemplate?.rows
? selectedTemplate.columns * selectedTemplate.rows ? selectedTemplate.columns * selectedTemplate.rows
: undefined; : undefined;
const startPosition = Math.max(1, Number(startPositionText) || 1);
// The answer holds the food business address: no persistence, dropped as // The answer holds the food business address: no persistence, dropped as
// soon as nobody looks at it any more. // soon as nobody looks at it any more.
@@ -314,16 +320,18 @@ export function PrintLabelsCard({
<Label htmlFor="print-start-position"> <Label htmlFor="print-start-position">
Startposition auf dem Bogen (1 = oben links) Startposition auf dem Bogen (1 = oben links)
</Label> </Label>
<Input <NumberStepper
id="print-start-position" id="print-start-position"
type="number" describedBy={startHintId}
inputMode="numeric" value={startPositionText}
onChange={setStartPositionText}
min={1} min={1}
max={slotsPerSheet} max={slotsPerSheet ?? MAX_START_POSITION}
value={startPosition} unitLabel={slotsPerSheet ? `von ${slotsPerSheet}` : "Position"}
onChange={(event) => setStartPosition(Math.max(1, Number(event.target.value)))} decrementLabel="Eine Position zurück"
incrementLabel="Eine Position weiter"
/> />
<p className="text-xs text-muted-foreground"> <p id={startHintId} className="text-xs text-muted-foreground">
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette. Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
</p> </p>
</div> </div>
@@ -192,6 +192,20 @@ describe("CalendarPage entries", () => {
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig"); expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
}); });
it("marks the chosen day with an inset ring and keeps the focus ring inside the clipped grid", async () => {
renderPage();
await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
const day = screen.getByRole("button", { name: /Dienstag, 11\. August/ });
await userEvent.click(day);
const classes = day.className.split(/\s+/);
expect(classes).toEqual(expect.arrayContaining(["ring-2", "ring-inset", "ring-foreground"]));
// A selection outline would replace the focus ring; the grid clips (overflow-hidden), so
// the focus ring of every day is drawn inside the cell, next to the selection ring.
expect(day.className).not.toMatch(/(^|\s)outline-(2|foreground)|-outline-offset-2/);
expect(classes).toContain("focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]");
});
it("counts a day with several entries in the plural", async () => { it("counts a day with several entries in the plural", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([ vi.mocked(fetchCalendarEvents).mockResolvedValue([
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" }, { id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
@@ -146,7 +146,7 @@ function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuat
</span> </span>
); );
return href ? ( return href ? (
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50"> <Link href={href} className="block rounded-lg">
{content} {content}
</Link> </Link>
) : ( ) : (
@@ -353,9 +353,11 @@ export default function CalendarPage() {
aria-pressed={isSelected} aria-pressed={isSelected}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`} aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
className={cn( className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0", // The grid clips (overflow-hidden): the focus ring sits inside the cell,
// next to the inset selection ring, so edge days keep it whole.
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0 focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]",
isCurrentMonth ? "bg-card" : "bg-muted/40", isCurrentMonth ? "bg-card" : "bg-muted/40",
isSelected && "outline-2 -outline-offset-2 outline-foreground", isSelected && "ring-2 ring-inset ring-foreground",
)} )}
> >
<span <span
@@ -83,7 +83,7 @@ function ActionFooter({
ref={errorRef} ref={errorRef}
role="alert" role="alert"
tabIndex={-1} tabIndex={-1}
className="text-sm font-medium text-destructive outline-none" className="text-sm font-medium text-destructive"
> >
{error} {error}
</p> </p>
@@ -283,7 +283,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
ref={conflictRef} ref={conflictRef}
role="alert" role="alert"
tabIndex={-1} tabIndex={-1}
className="text-sm font-medium text-destructive outline-none" className="text-sm font-medium text-destructive"
> >
{conflictText} {conflictText}
</p> </p>
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
); );
} }
if (!colony.data || !hive.data || !catalog) { if (!colony.data || !hive.data || !catalog) {
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />; return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
} }
return ( return (
<HiveEditor <HiveEditor
@@ -55,4 +55,20 @@ describe("SwarmControlEditDialog", () => {
notes: null, notes: null,
}); });
}); });
it("swarm control edit counts queen cells with NumberStepper", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<SwarmControlEditDialog check={check} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await screen.findByRole("dialog");
expect(screen.getByLabelText(/^Weiselzellen gefunden/)).toHaveValue("3");
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle weniger/ }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSwarmControlCheck).mock.calls[0][1]).toMatchObject({ queenCellsFound: 2 });
});
}); });
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip"; import { ToggleChip } from "@/components/form/toggle-chip";
import { import {
@@ -115,15 +116,14 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
<FormSection step={2} title="Befund"> <FormSection step={2} title="Befund">
<Field label="Weiselzellen gefunden" hint="Anzahl"> <Field label="Weiselzellen gefunden" hint="Anzahl">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <NumberStepper
id={id} id={id}
type="number" describedBy={describedBy}
inputMode="numeric"
min={0}
className="h-11"
aria-describedby={describedBy}
value={queenCellsFound} value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)} onChange={setQueenCellsFound}
unitLabel="Weiselzellen"
decrementLabel="Eine Weiselzelle weniger"
incrementLabel="Eine Weiselzelle mehr"
/> />
)} )}
</Field> </Field>
@@ -135,4 +135,15 @@ describe("VarroaEditDialog", () => {
await openDialog(); await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument(); expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
}); });
it("the varroa edit dialog counts mites with NumberStepper", async () => {
await openDialog();
const miteCount = screen.getByLabelText(/^Milbenzahl/);
expect(miteCount).toHaveValue("12");
expect(miteCount).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ miteCount: 13 });
});
}); });
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
import { import {
Dialog, Dialog,
@@ -146,17 +147,18 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
{isNaturalFall ? ( {isNaturalFall ? (
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} /> <VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
) : null} ) : null}
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}> {/* One column: the stepper needs the width of the dialog on a phone. */}
<div className="flex flex-col gap-3">
<Field label="Milbenzahl"> <Field label="Milbenzahl">
{({ id }) => ( {({ id, describedBy }) => (
<Input <NumberStepper
id={id} id={id}
type="number" describedBy={describedBy}
inputMode="numeric"
min={0}
className="h-11"
value={miteCount} value={miteCount}
onChange={(event) => setMiteCount(event.target.value)} onChange={setMiteCount}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/> />
)} )}
</Field> </Field>
@@ -238,7 +238,7 @@ export default function NewColonyPage() {
ref={errorRef} ref={errorRef}
role="alert" role="alert"
tabIndex={-1} tabIndex={-1}
className="text-sm font-medium text-destructive outline-none" className="text-sm font-medium text-destructive"
> >
{submitError} {submitError}
</p> </p>
@@ -39,7 +39,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
<span <span
id={titleId} id={titleId}
tabIndex={titleId ? -1 : undefined} tabIndex={titleId ? -1 : undefined}
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="font-heading text-base font-semibold"
>{`${number}. ${title}`}</span> >{`${number}. ${title}`}</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium"> <span className="inline-flex items-center gap-1.5 text-sm font-medium">
<Icon className="size-4" aria-hidden /> <Icon className="size-4" aria-hidden />
@@ -164,7 +164,7 @@ export default function NoticeDetailPage() {
id="notice-flow-heading" id="notice-flow-heading"
ref={flowHeading} ref={flowHeading}
tabIndex={-1} tabIndex={-1}
className="font-heading text-lg font-semibold tracking-tight outline-none" className="font-heading text-lg font-semibold tracking-tight"
> >
{flowTitle} {flowTitle}
</h2> </h2>
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateNoticeDialog } from "./create-notice-dialog"; import { CreateNoticeDialog } from "./create-notice-dialog";
@@ -97,4 +97,14 @@ describe("CreateNoticeDialog", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen."); expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
}); });
it("offers the kind of notice as cards with their hint", async () => {
renderDialog();
await open();
const group = screen.getByRole("radiogroup", { name: "Art der Anzeige" });
const first = within(group).getByRole("radio", { name: "Erstanzeige" });
expect(first).toHaveAttribute("aria-checked", "true");
expect(first).toHaveAccessibleDescription("Deine erste Meldung an das Veterinäramt.");
expect(first).toHaveClass("min-h-14");
});
}); });
@@ -1,11 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react"; import { FormEvent, useEffect, useId, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip"; import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -28,6 +29,12 @@ const KIND_HINT: Record<AuthorityNoticeKind, string> = {
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.", TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
}; };
const KIND_OPTIONS: readonly ChoiceOption<AuthorityNoticeKind>[] = NOTICE_KINDS.map((kind) => ({
value: kind,
label: NOTICE_KIND_LABEL[kind],
description: KIND_HINT[kind],
}));
interface CreateNoticeDialogProps { interface CreateNoticeDialogProps {
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */ /** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean; disabled?: boolean;
@@ -75,6 +82,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const attempt = useAttemptKey(); const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly(); const { isOffline, hintId, describedBy } = useOnlineOnly();
const kindLabelId = useId();
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION"); const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
const [year, setYear] = useState(String(new Date().getFullYear())); const [year, setYear] = useState(String(new Date().getFullYear()));
const [alreadySent, setAlreadySent] = useState(false); const [alreadySent, setAlreadySent] = useState(false);
@@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
return ( return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate> <form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<fieldset className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend> <p id={kindLabelId} className="text-base font-medium">
{NOTICE_KINDS.map((entry) => ( Art der Anzeige
<label </p>
key={entry} <ChoiceGroup
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-foreground has-[:checked]:bg-primary/10" options={KIND_OPTIONS}
> value={kind}
<input onChange={setKind}
type="radio" labelledBy={kindLabelId}
name="notice-kind" appearance="cards"
value={entry} />
checked={kind === entry} </div>
onChange={() => setKind(entry)}
aria-labelledby={`notice-kind-${entry}`}
aria-describedby={`notice-kind-hint-${entry}`}
className="mt-1 size-4"
/>
<span className="flex flex-col">
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
{NOTICE_KIND_LABEL[entry]}
</span>
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
{KIND_HINT[entry]}
</span>
</span>
</label>
))}
</fieldset>
{kind === "TSK_REPORT" ? ( {kind === "TSK_REPORT" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -197,7 +189,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
<p aria-live="polite" className="min-h-5 text-sm text-destructive"> <p aria-live="polite" className="min-h-5 text-sm text-destructive">
{showProblem ? problem : null} {showProblem ? problem : null}
</p> </p>
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none"> <div ref={errorRef} tabIndex={-1} role="alert">
{error ? ( {error ? (
<div className="flex flex-col gap-2 text-sm text-destructive"> <div className="flex flex-col gap-2 text-sm text-destructive">
<p>{error.text}</p> <p>{error.text}</p>
@@ -62,7 +62,7 @@ export default function NoticesPage() {
id="notice-kind-filter" id="notice-kind-filter"
value={kind} value={kind}
onChange={(event) => setKind(event.target.value as KindFilter)} onChange={(event) => setKind(event.target.value as KindFilter)}
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm" className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs"
> >
<option value="ALL">Alle</option> <option value="ALL">Alle</option>
{NOTICE_KINDS.map((entry) => ( {NOTICE_KINDS.map((entry) => (
@@ -85,7 +85,7 @@ export default function NoticesPage() {
aria-invalid={yearInvalid ? true : undefined} aria-invalid={yearInvalid ? true : undefined}
aria-describedby={yearInvalid ? "notice-year-problem" : undefined} aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
onChange={(event) => setYearText(event.target.value)} onChange={(event) => setYearText(event.target.value)}
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm" className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base"
/> />
{yearInvalid ? ( {yearInvalid ? (
<p id="notice-year-problem" className="text-sm text-destructive"> <p id="notice-year-problem" className="text-sm text-destructive">
@@ -131,7 +131,7 @@ function NoticeLink({ notice }: { notice: AuthorityNotice }) {
return ( return (
<Link <Link
href={`/compliance/notices/${encodeURIComponent(notice.id)}`} href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50" className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 hover:bg-muted"
> >
<span className="flex flex-wrap items-center justify-between gap-2"> <span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span> <span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
}; };
const FIX_LINK_CLASS = const FIX_LINK_CLASS =
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"; "inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline";
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) { function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
const { href, label, available } = fixLinkFor(itemKey, subject); const { href, label, available } = fixLinkFor(itemKey, subject);
@@ -117,7 +117,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
<h3 <h3
ref={headingRef} ref={headingRef}
tabIndex={-1} tabIndex={-1}
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="min-w-0 font-heading text-base font-semibold break-words"
> >
{item.title} {item.title}
</h3> </h3>
@@ -46,7 +46,7 @@ function Section({
const headingId = `restrictions-${id}`; const headingId = `restrictions-${id}`;
return ( return (
<section aria-labelledby={headingId} className="flex flex-col gap-2"> <section aria-labelledby={headingId} className="flex flex-col gap-2">
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none"> <h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
{`${title} (${rows.length})`} {`${title} (${rows.length})`}
</h2> </h2>
{rows.length === 0 ? ( {rows.length === 0 ? (
@@ -244,7 +244,7 @@ export default function DashboardPage() {
<li key={tile.label}> <li key={tile.label}>
<Link <Link
href={tile.href} href={tile.href}
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted"
> >
<Icon <Icon
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`} className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
@@ -202,7 +202,7 @@ export default function NewExtractionPage() {
data-field="harvestIds" data-field="harvestIds"
aria-labelledby={labelId} aria-labelledby={labelId}
aria-describedby={describedBy} aria-describedby={describedBy}
className="flex flex-col gap-2 outline-none" className="flex flex-col gap-2"
> >
{harvests?.map((harvest) => ( {harvests?.map((harvest) => (
<ToggleChip <ToggleChip
@@ -253,7 +253,7 @@ export default function NewHarvestPage() {
data-field="colonyIds" data-field="colonyIds"
aria-labelledby={labelId} aria-labelledby={labelId}
aria-describedby={describedBy} aria-describedby={describedBy}
className="grid grid-cols-2 gap-2 outline-none" className="grid grid-cols-2 gap-2"
> >
{apiaryColonies.map((colony) => { {apiaryColonies.map((colony) => {
const withdrawal = withdrawalByColony.get(colony.id); const withdrawal = withdrawalByColony.get(colony.id);
@@ -147,7 +147,7 @@ export function InspectionHiveSection({
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form. // A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
<div <div
data-testid="hive-section" data-testid="hive-section"
className="flex flex-col gap-4 outline-none" className="flex flex-col gap-4"
onSubmit={(event) => event.stopPropagation()} onSubmit={(event) => event.stopPropagation()}
> >
<h3 ref={headingRef} tabIndex={-1} className="sr-only"> <h3 ref={headingRef} tabIndex={-1} className="sr-only">
+53 -1
View File
@@ -63,12 +63,14 @@ vi.mock("@/components/offline/offline-band", () => ({
userId, userId,
pageContentRef, pageContentRef,
syncEnabled, syncEnabled,
inert,
}: { }: {
userId: string; userId: string;
pageContentRef: { current: HTMLElement | null }; pageContentRef: { current: HTMLElement | null };
syncEnabled?: boolean; 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} {userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}> <button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt 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. // 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 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) { function expectNoScrollContainerAbove(element: HTMLElement) {
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS); expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
@@ -127,6 +138,47 @@ describe("AppLayout", () => {
expect(content).toHaveFocus(); 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", () => { it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
pathname = "/suggestions"; pathname = "/suggestions";
permissions = ["inspections:create"]; permissions = ["inspections:create"];
+22 -6
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav"; import { BottomNav } from "./bottom-nav";
import { isFullScreenPage } from "./nav-config";
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet"; import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition"; 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-[calc(var(--bottom-nav-height)+--spacing(18))]"
: "pb-(--bottom-nav-height)"; : "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 // The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture // scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick. // `position: sticky` and the StickySubmit save bar would never stick.
@@ -79,19 +87,24 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)"> <div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a <a
href={`#${MAIN_CONTENT_ID}`} href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
onClick={(event) => { onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably. // The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault(); event.preventDefault();
pageContentRef.current?.focus(); pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" }); pageContentRef.current?.scrollIntoView?.({ block: "start" });
}} }}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
> >
Zum Inhalt springen Zum Inhalt springen
</a> </a>
<Sidebar user={user} /> <div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col"> <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 <Link
href="/dashboard" href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden" className="flex items-center font-heading font-semibold sm:hidden"
@@ -143,18 +156,21 @@ export default function AppLayout({ children }: { children: ReactNode }) {
userId={user.id} userId={user.id}
pageContentRef={pageContentRef} pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"} syncEnabled={sessionMode === "online"}
inert={isShellInert}
/> />
<div <div
id={MAIN_CONTENT_ID} id={MAIN_CONTENT_ID}
ref={pageContentRef} ref={pageContentRef}
tabIndex={-1} tabIndex={-1}
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`} className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
> >
<PageTransition>{children}</PageTransition> <PageTransition>{children}</PageTransition>
</div> </div>
</div> </div>
<BottomNav user={user} /> <div className="contents" inert={isShellInert}>
<QuickAddSheet user={user} variant="fab" /> <BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog <LogoutConfirmDialog
open={isLogoutOpen} open={isLogoutOpen}
onOpenChange={setIsLogoutOpen} onOpenChange={setIsLogoutOpen}
@@ -50,7 +50,7 @@ interface LotFormValues {
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>; type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
const INPUT_CLASS = "h-11 sm:h-11"; const INPUT_CLASS = "h-11 pointer-fine:h-11";
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues { function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
if (lot) { if (lot) {
@@ -129,7 +129,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
<Input <Input
id="disposal-quantity" id="disposal-quantity"
inputMode="decimal" inputMode="decimal"
className="h-11 sm:h-11" className="h-11 pointer-fine:h-11"
value={quantityText} value={quantityText}
onChange={(event) => setQuantityText(event.target.value)} onChange={(event) => setQuantityText(event.target.value)}
/> />
@@ -141,7 +141,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
</Label> </Label>
<Input <Input
id="disposal-reason" id="disposal-reason"
className="h-11 sm:h-11" className="h-11 pointer-fine:h-11"
value={reason} value={reason}
maxLength={500} maxLength={500}
placeholder="z. B. abgelaufen, verschüttet" placeholder="z. B. abgelaufen, verschüttet"
@@ -128,7 +128,7 @@ function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
<Input <Input
id="inventory-counted" id="inventory-counted"
inputMode="decimal" inputMode="decimal"
className="h-11 sm:h-11" className="h-11 pointer-fine:h-11"
value={counted} value={counted}
aria-describedby="inventory-status" aria-describedby="inventory-status"
onChange={(event) => setCounted(event.target.value)} onChange={(event) => setCounted(event.target.value)}
@@ -89,7 +89,7 @@ interface MedicineFormProps {
onDone: () => void; onDone: () => void;
} }
const INPUT_CLASS = "h-11 sm:h-11"; const INPUT_CLASS = "h-11 pointer-fine:h-11";
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) { function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -3,6 +3,7 @@ import {
ADMIN_GROUP, ADMIN_GROUP,
BOTTOM_NAV_HREFS, BOTTOM_NAV_HREFS,
NAV_GROUPS, NAV_GROUPS,
isFullScreenPage,
visibleNavGroups, visibleNavGroups,
} from "./nav-config"; } from "./nav-config";
@@ -93,4 +94,13 @@ describe("nav-config", () => {
expect(group?.label).toBe("Betrieb"); expect(group?.label).toBe("Betrieb");
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft"); 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 { export function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`); 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);
}
@@ -72,7 +72,7 @@ export default function OfflinePage() {
<Link <Link
key={stored.apiaryId} key={stored.apiaryId}
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`} href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none" className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
> >
<span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{stored.pack.apiary.name}</span> <span className="font-medium">{stored.pack.apiary.name}</span>
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers"; import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
import { AppearanceSection } from "./appearance-section"; import { AppearanceSection } from "./appearance-section";
@@ -54,4 +54,18 @@ describe("AppearanceSection", () => {
expect(document.documentElement.dataset.motion).toBe("subtle"); expect(document.documentElement.dataset.motion).toBe("subtle");
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle"); expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
}); });
it("offers colour scheme, contrast and motion as cards with a description each", () => {
render(<AppearanceSection />);
for (const group of ["Farbschema", "Kontrast", "Bewegung & Effekte"]) {
for (const radio of within(screen.getByRole("radiogroup", { name: group })).getAllByRole("radio")) {
expect(radio).toHaveClass("min-h-14");
expect(radio).toHaveAccessibleDescription(/\S/);
}
}
expect(screen.getByRole("radio", { name: "Hell" })).toHaveAccessibleDescription("Am besten bei Tageslicht.");
expect(screen.getByRole("radio", { name: "Dezent" })).toHaveAccessibleDescription(
"Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
);
});
}); });
@@ -6,17 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance"; import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference"; import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [ const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [
{ value: "light", label: "Hell – am besten bei Tageslicht" }, { value: "light", label: "Hell", description: "Am besten bei Tageslicht." },
{ value: "dark", label: "Dunkel – für abends" }, { value: "dark", label: "Dunkel", description: "Für abends." },
{ value: "system", label: "Wie Gerät" }, { value: "system", label: "Wie Gerät", description: "Folgt der Einstellung des Geräts." },
]; ];
const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [ const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [
{ value: "normal", label: "Normal" }, { value: "normal", label: "Normal", description: "Für drinnen und Schatten." },
{ value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" }, { value: "sun", label: "Sonne", description: "Maximaler Kontrast, größere Schrift, für draußen." },
]; ];
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" }; const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
@@ -28,11 +26,12 @@ const MOTION_DESCRIPTIONS: Record<MotionLevel, string> = {
const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({ const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({
value, value,
label: MOTION_LABELS[value], label: MOTION_LABELS[value],
description: MOTION_DESCRIPTIONS[value],
})); }));
export function AppearanceSection() { export function AppearanceSection() {
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance(); const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() }; const ids = { theme: useId(), contrast: useId(), motion: useId() };
return ( return (
<Card> <Card>
@@ -53,7 +52,7 @@ export function AppearanceSection() {
value={theme} value={theme}
onChange={setTheme} onChange={setTheme}
labelledBy={ids.theme} labelledBy={ids.theme}
optionClassName={OPTION_CLASS} appearance="cards"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -65,7 +64,7 @@ export function AppearanceSection() {
value={contrast} value={contrast}
onChange={setContrast} onChange={setContrast}
labelledBy={ids.contrast} labelledBy={ids.contrast}
optionClassName={OPTION_CLASS} appearance="cards"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -77,13 +76,8 @@ export function AppearanceSection() {
value={motion} value={motion}
onChange={setMotion} onChange={setMotion}
labelledBy={ids.motion} labelledBy={ids.motion}
describedBy={ids.motionHint} appearance="cards"
className="grid-cols-3"
optionClassName="px-3 py-2"
/> />
<p id={ids.motionHint} className="text-sm text-muted-foreground">
{MOTION_DESCRIPTIONS[motion]}
</p>
</div> </div>
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p> <p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
</CardContent> </CardContent>
@@ -10,6 +10,7 @@ import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet"; import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch"; import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
import { isFullScreenPage } from "./nav-config";
import { QUICK_ACTIONS } from "./quick-actions"; import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the // 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 { export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean {
return ( return (
!FORM_ROUTE.test(pathname) && !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)) QUICK_ACTIONS.some((action) => permissions.includes(action.permission))
); );
} }
@@ -29,7 +29,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
aria-expanded={open} aria-expanded={open}
aria-controls={contentId} aria-controls={contentId}
onClick={onToggle} onClick={onToggle}
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left focus-visible:ring-3 focus-visible:ring-ring/50" className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left"
> >
<span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { LabelLineElement, LabelTextElement } from "@/lib/label-templates/types";
import { ElementPropertiesPanel } from "./element-properties-panel";
const LINE: LabelLineElement = {
id: "l-1",
type: "line",
x: 2,
y: 3,
length: 40,
thickness: 1,
color: "#000000",
orientation: "horizontal",
};
const TEXT: LabelTextElement = {
id: "t-1",
type: "text",
x: 2,
y: 3,
width: 40,
height: 8,
text: "Honig",
fontSize: 10,
fontFamily: "sans",
bold: true,
italic: false,
color: "#000000",
align: "left",
};
function renderPanel(element: LabelLineElement | LabelTextElement, onChange = vi.fn()) {
render(
<ElementPropertiesPanel element={element} onChange={onChange} onDelete={vi.fn()} onDuplicate={vi.fn()} />,
);
return onChange;
}
describe("ElementPropertiesPanel", () => {
it("the label designer line alignment is a ChoiceGroup", async () => {
const onChange = renderPanel(LINE);
const group = screen.getByRole("radiogroup", { name: "Ausrichtung der Linie" });
expect(group).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Waagerecht" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "Senkrecht" })).toHaveAttribute("aria-checked", "false");
await userEvent.click(screen.getByRole("radio", { name: "Senkrecht" }));
expect(onChange).toHaveBeenCalledWith({ orientation: "vertical" });
});
it("bold and italic are named toolbar toggles with aria-pressed", async () => {
const onChange = renderPanel(TEXT);
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Kursiv" })).toHaveAttribute("aria-pressed", "false");
await userEvent.click(screen.getByRole("button", { name: "Kursiv" }));
expect(onChange).toHaveBeenCalledWith({ italic: true });
});
});
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useId } from "react";
import { Bold, Copy, Italic, Trash2 } from "lucide-react"; import { Bold, Copy, Italic, Trash2 } from "lucide-react";
import { import {
LabelElement, LabelElement,
LabelFontFamily, LabelFontFamily,
LabelLineOrientation,
LabelTextAlign, LabelTextAlign,
PLACEHOLDER_KEYS, PLACEHOLDER_KEYS,
PLACEHOLDER_LABELS, PLACEHOLDER_LABELS,
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = { const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = {
sans: "Serifenlos", sans: "Serifenlos",
@@ -25,6 +28,11 @@ const ALIGN_LABELS: Record<LabelTextAlign, string> = {
right: "Rechts", right: "Rechts",
}; };
const ORIENTATION_OPTIONS = choiceOptions<LabelLineOrientation>({
horizontal: "Waagerecht",
vertical: "Senkrecht",
});
interface ElementPropertiesPanelProps { interface ElementPropertiesPanelProps {
element: LabelElement; element: LabelElement;
onChange: (patch: Partial<LabelElement>) => void; onChange: (patch: Partial<LabelElement>) => void;
@@ -44,7 +52,8 @@ export function ElementPropertiesPanel({
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">
{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"} {element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}
</p> </p>
<div className="flex gap-1"> {/* pointer-coarse:gap-4: the 44 px hit areas of the two 28 px buttons must not overlap. */}
<div className="flex gap-1 pointer-coarse:gap-4">
<Button type="button" variant="ghost" size="icon-sm" onClick={onDuplicate} title="Duplizieren"> <Button type="button" variant="ghost" size="icon-sm" onClick={onDuplicate} title="Duplizieren">
<Copy className="size-3.5" /> <Copy className="size-3.5" />
</Button> </Button>
@@ -202,22 +211,26 @@ function TextFields({
/> />
</div> </div>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5 pointer-coarse:gap-4">
<Button <Button
type="button" type="button"
variant={element.bold ? "default" : "outline"} variant={element.bold ? "default" : "outline"}
size="icon-sm" size="icon-sm"
pressed={element.bold}
aria-label="Fett"
onClick={() => onChange({ bold: !element.bold })} onClick={() => onChange({ bold: !element.bold })}
> >
<Bold className="size-3.5" /> <Bold aria-hidden className="size-3.5" />
</Button> </Button>
<Button <Button
type="button" type="button"
variant={element.italic ? "default" : "outline"} variant={element.italic ? "default" : "outline"}
size="icon-sm" size="icon-sm"
pressed={element.italic}
aria-label="Kursiv"
onClick={() => onChange({ italic: !element.italic })} onClick={() => onChange({ italic: !element.italic })}
> >
<Italic className="size-3.5" /> <Italic aria-hidden className="size-3.5" />
</Button> </Button>
<EnumSelect <EnumSelect
id="element-text-align" id="element-text-align"
@@ -239,6 +252,7 @@ function LineFields({
element: Extract<LabelElement, { type: "line" }>; element: Extract<LabelElement, { type: "line" }>;
onChange: (patch: Partial<LabelElement>) => void; onChange: (patch: Partial<LabelElement>) => void;
}) { }) {
const orientationLabelId = useId();
return ( return (
<> <>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -273,25 +287,17 @@ function LineFields({
onChange={(event) => onChange({ color: event.target.value })} onChange={(event) => onChange({ color: event.target.value })}
/> />
</div> </div>
<div className="flex gap-1.5"> <div className="flex flex-col gap-2">
<Button <p id={orientationLabelId} className="text-sm leading-none font-medium">
type="button" Ausrichtung der Linie
variant={element.orientation === "horizontal" ? "default" : "outline"} </p>
size="sm" <ChoiceGroup
className="flex-1" options={ORIENTATION_OPTIONS}
onClick={() => onChange({ orientation: "horizontal" })} value={element.orientation}
> onChange={(orientation) => onChange({ orientation })}
Waagerecht labelledBy={orientationLabelId}
</Button> className="grid-cols-2"
<Button />
type="button"
variant={element.orientation === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1"
onClick={() => onChange({ orientation: "vertical" })}
>
Senkrecht
</Button>
</div> </div>
</> </>
); );
@@ -659,7 +659,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
id="designer-min-x-height" id="designer-min-x-height"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="min-h-11 sm:h-11" className="min-h-11 pointer-fine:h-11"
value={minXHeightText} value={minXHeightText}
aria-invalid={minXHeightInvalid ? true : undefined} aria-invalid={minXHeightInvalid ? true : undefined}
aria-describedby="designer-min-x-height-hint" aria-describedby="designer-min-x-height-hint"
@@ -786,7 +786,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
/> />
</div> </div>
</div> </div>
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"> <label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 focus-ring-within">
<input <input
type="checkbox" type="checkbox"
className="mt-1 size-4 accent-[var(--primary)]" className="mt-1 size-4 accent-[var(--primary)]"
@@ -282,7 +282,7 @@ export default function SuggestionsPage() {
role="status" role="status"
aria-live="polite" aria-live="polite"
data-testid="suggestion-count" data-testid="suggestion-count"
className="min-h-5 text-sm font-medium outline-none" className="min-h-5 text-sm font-medium"
> >
{countText(loaded, total)} {countText(loaded, total)}
</p> </p>
@@ -90,4 +90,17 @@ describe("NewSwarmControlCheckPage", () => {
expect(screen.getByLabelText(/^Datum/)).toHaveFocus(); expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createSwarmControlCheck).not.toHaveBeenCalled(); expect(createSwarmControlCheck).not.toHaveBeenCalled();
}); });
it("swarm control new counts queen cells with NumberStepper", async () => {
vi.mocked(createSwarmControlCheck).mockResolvedValue({ id: "s-1", colonyId: "colony-1" } as never);
renderPage();
const cells = screen.getByLabelText(/Weiselzellen gefunden/);
expect(cells).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
expect(cells).toHaveValue("2");
await userEvent.click(screen.getByRole("button", { name: "Kontrolle speichern" }));
expect(vi.mocked(createSwarmControlCheck).mock.calls[0][0]).toMatchObject({ queenCellsFound: 2 });
});
}); });
@@ -20,6 +20,7 @@ import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field"; import { LivingColonySelectField } from "@/components/colony-select-field";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
@@ -174,17 +175,14 @@ export default function NewSwarmControlCheckPage() {
hint="Zellen, in denen eine neue Königin heranwächst – groß, zapfenförmig, meist am Wabenrand." hint="Zellen, in denen eine neue Königin heranwächst – groß, zapfenförmig, meist am Wabenrand."
> >
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <NumberStepper
id={id} id={id}
aria-describedby={describedBy} describedBy={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.queenCellsFound} value={values.queenCellsFound}
onChange={(event) => onChange={(count) => set("queenCellsFound", count)}
set("queenCellsFound", event.target.value) unitLabel="Weiselzellen"
} decrementLabel="Eine Weiselzelle weniger"
incrementLabel="Eine Weiselzelle mehr"
/> />
)} )}
</Field> </Field>
@@ -131,6 +131,15 @@ describe("NewVarroaMeasurementPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Messung speichern" })); await userEvent.click(screen.getByRole("button", { name: "Messung speichern" }));
} }
it("counts the mites of one colony with NumberStepper", async () => {
renderPage();
const miteCount = screen.getByLabelText(/^Milbenzahl/);
expect(miteCount).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(miteCount).toHaveValue("1");
});
it("preselects no period chip", () => { it("preselects no period chip", () => {
renderPage(); renderPage();
for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) { for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) {
@@ -34,6 +34,7 @@ import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
import { CaptureColonyField } from "@/components/form/capture-colony-field"; import { CaptureColonyField } from "@/components/form/capture-colony-field";
@@ -332,15 +333,14 @@ export default function NewVarroaMeasurementPage() {
} }
> >
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <NumberStepper
id={id} id={id}
aria-describedby={describedBy} describedBy={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.miteCount} value={values.miteCount}
onChange={(event) => set("miteCount", event.target.value)} onChange={(count) => set("miteCount", count)}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/> />
)} )}
</Field> </Field>
@@ -257,7 +257,7 @@ function WinterContent() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="size-11 sm:size-8" className="size-11 pointer-fine:size-8"
aria-label="Vorheriger Winter" aria-label="Vorheriger Winter"
onClick={() => setWinterYear((year) => year - 1)} onClick={() => setWinterYear((year) => year - 1)}
> >
@@ -267,7 +267,7 @@ function WinterContent() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="size-11 sm:size-8" className="size-11 pointer-fine:size-8"
aria-label="Nächster Winter" aria-label="Nächster Winter"
onClick={() => setWinterYear((year) => year + 1)} onClick={() => setWinterYear((year) => year + 1)}
> >
@@ -107,7 +107,7 @@ describe("WinterCheckDialog", () => {
it("records a lost colony with its loss cause", async () => { it("records a lost colony with its loss cause", async () => {
renderDialog({ season: "SPRING" }); renderDialog({ season: "SPRING" });
await userEvent.click(screen.getByRole("button", { name: "Verloren" })); await userEvent.click(screen.getByRole("radio", { name: "Verloren" }));
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
const cause = screen.getByRole("combobox", { name: "Verlustursache" }); const cause = screen.getByRole("combobox", { name: "Verlustursache" });
expect(cause).toHaveTextContent("Ursache wählen"); expect(cause).toHaveTextContent("Ursache wählen");
@@ -130,4 +130,32 @@ describe("WinterCheckDialog", () => {
const [input] = vi.mocked(createWinterCheck).mock.calls[0]; const [input] = vi.mocked(createWinterCheck).mock.calls[0];
expect(input).toHaveProperty("queenPresent", null); expect(input).toHaveProperty("queenPresent", null);
}); });
it("winter seams use NumberStepper", async () => {
renderDialog({ existing });
const seams = screen.getByLabelText("Besetzte Wabengassen");
expect(seams).toHaveValue("7");
expect(seams).toHaveAttribute("inputmode", "numeric");
await userEvent.click(screen.getByRole("button", { name: /Eine besetzte Wabengasse mehr/ }));
expect(seams).toHaveValue("8");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateWinterCheck).mock.calls[0];
expect(input).toHaveProperty("occupiedSeams", 8);
});
it("asks for the outcome with one card per answer, each with its icon", async () => {
renderDialog({ season: "SPRING" });
const group = screen.getByRole("radiogroup", { name: "Wie ist das Volk durch den Winter gekommen?" });
const radios = within(group).getAllByRole("radio");
expect(radios.map((radio) => radio.getAttribute("aria-checked"))).toEqual(["false", "false", "false"]);
for (const radio of radios) {
expect(radio).toHaveClass("min-h-14");
expect(radio.querySelector("svg")).not.toBeNull();
}
await userEvent.click(within(group).getByRole("radio", { name: "Schwach" }));
expect(within(group).getByRole("radio", { name: "Schwach" })).toHaveAttribute("aria-checked", "true");
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
});
}); });
@@ -1,7 +1,7 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useId, useState } from "react";
import { Check } from "lucide-react"; import { CircleX, Sprout, TriangleAlert } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
@@ -22,9 +22,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { TriStateField } from "@/components/form/tri-state-field"; import { TriStateField } from "@/components/form/tri-state-field";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
@@ -42,7 +43,30 @@ function parseDecimal(value: string): number {
return Number(value.replace(",", ".")); return Number(value.replace(",", "."));
} }
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"]; /** Highest count the server takes (create-winter-check.dto.ts). */
const MAX_SEAMS = 60;
// Icon plus word, so the answer never hangs on colour (spec 10.2).
const OUTCOME_OPTIONS: readonly ChoiceOption<WinterOutcome | null>[] = [
{
value: "SURVIVED",
label: WINTER_OUTCOME_LABELS.SURVIVED,
description: "Das Volk lebt und ist gut durchgekommen.",
icon: Sprout,
},
{
value: "WEAK",
label: WINTER_OUTCOME_LABELS.WEAK,
description: "Das Volk lebt, ist aber klein geworden.",
icon: TriangleAlert,
},
{
value: "LOST",
label: WINTER_OUTCOME_LABELS.LOST,
description: "Das Volk hat den Winter nicht überlebt.",
icon: CircleX,
},
];
export function WinterCheckDialog({ export function WinterCheckDialog({
target, target,
@@ -67,6 +91,7 @@ export function WinterCheckDialog({
const [markDeceased, setMarkDeceased] = useState(target.isLiving); const [markDeceased, setMarkDeceased] = useState(target.isLiving);
const [notes, setNotes] = useState(existing?.notes ?? ""); const [notes, setNotes] = useState(existing?.notes ?? "");
const [idempotencyKey] = useState(newIdempotencyKey); const [idempotencyKey] = useState(newIdempotencyKey);
const outcomeLabelId = useId();
const isLost = outcome === "LOST"; const isLost = outcome === "LOST";
const missing = isSpring && (!outcome || (isLost && !lossCause)); const missing = isSpring && (!outcome || (isLost && !lossCause));
@@ -79,7 +104,7 @@ export function WinterCheckDialog({
winterYear: target.winterYear, winterYear: target.winterYear,
date: toApiDate(date), date: toApiDate(date),
// Empty means null, not omitted: on an edit it clears an earlier entry. // Empty means null, not omitted: on an edit it clears an earlier entry.
occupiedSeams: emptyToNull(occupiedSeams, parseDecimal), occupiedSeams: emptyToNull(occupiedSeams, Number),
// Spring checks have no feed stock field - leave whatever is stored. // Spring checks have no feed stock field - leave whatever is stored.
feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal), feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal),
queenPresent, queenPresent,
@@ -123,24 +148,18 @@ export function WinterCheckDialog({
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{isSpring ? ( {isSpring ? (
<fieldset className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend> <p id={outcomeLabelId} className="text-base font-medium">
<div className="grid grid-cols-3 gap-2"> Wie ist das Volk durch den Winter gekommen?
{OUTCOMES.map((value) => ( </p>
<Button <ChoiceGroup
key={value} options={OUTCOME_OPTIONS}
type="button" value={outcome}
size="touch" onChange={setOutcome}
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"} labelledBy={outcomeLabelId}
aria-pressed={outcome === value} appearance="cards"
onClick={() => setOutcome(value)} />
> </div>
{outcome === value ? <Check aria-hidden /> : null}
{WINTER_OUTCOME_LABELS[value]}
</Button>
))}
</div>
</fieldset>
) : null} ) : null}
{isLost ? ( {isLost ? (
@@ -184,16 +203,18 @@ export function WinterCheckDialog({
</div> </div>
{!isLost ? ( {!isLost ? (
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label> <Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
<Input <NumberStepper
id="winter-seams" id="winter-seams"
inputMode="numeric" describedBy="winter-seams-hint"
className="h-11"
aria-describedby="winter-seams-hint"
value={occupiedSeams} value={occupiedSeams}
onChange={(event) => setOccupiedSeams(event.target.value)} onChange={setOccupiedSeams}
max={MAX_SEAMS}
unitLabel="Gassen"
decrementLabel="Eine besetzte Wabengasse weniger"
incrementLabel="Eine besetzte Wabengasse mehr"
/> />
<p id="winter-seams-hint" className="text-xs text-muted-foreground"> <p id="winter-seams-hint" className="text-xs text-muted-foreground">
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres
+39 -6
View File
@@ -7,6 +7,12 @@
@custom-variant pointer-fine (@media (pointer: fine)); @custom-variant pointer-fine (@media (pointer: fine));
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));
/* Type scale (spec 4.5): text-xs is 13 px (sun: 14 px), only for axes, legends and footers. */
@theme {
--text-xs: 0.8125rem;
--text-xs--line-height: calc(1.125 / 0.8125);
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@@ -332,6 +338,14 @@
.holo-stage { .holo-stage {
--ring: var(--holo-edge-strong); --ring: var(--holo-edge-strong);
} }
/* A page-coloured card on the stage (the outline panel) takes the page ring back. The root
keeps a copy of its ring for that, resolved for the current set (light, dark, sun). */
html {
--page-ring: var(--ring);
}
.holo-stage .stage-card {
--ring: var(--page-ring);
}
/* Tinted surfaces: set --tint (and --tint-ink) on the element. In sun the tint becomes the /* Tinted surfaces: set --tint (and --tint-ink) on the element. In sun the tint becomes the
plain card with a 2 px border in the ink instead of a 15 % wash (spec 4.3). */ plain card with a 2 px border in the ink instead of a 15 % wash (spec 4.3). */
@@ -430,7 +444,7 @@
--queen-green: oklch(0.58 0.15 145); --queen-green: oklch(0.58 0.15 145);
--queen-blue: oklch(0.52 0.15 255); --queen-blue: oklch(0.52 0.15 255);
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-xs: 0.75rem; --text-xs: 0.8125rem;
--focus-width: 2px; --focus-width: 2px;
--control-border-width: 1px; --control-border-width: 1px;
--comb-stroke-1: 2px; --comb-stroke-1: 2px;
@@ -480,7 +494,21 @@
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
}
/* One focus style for everything (spec 4.6): a solid ring in --ring with a gap, 3 px in sun.
Components add no focus classes; a row whose focus target sits inside uses
focus-ring-within. */
:where(:focus-visible) {
outline: var(--focus-width) solid var(--ring);
outline-offset: var(--focus-offset);
}
/* Firefox: the preflight sets `:-moz-focusring:where(:not(iframe)) { outline: auto }` (0,1,0),
which beats the rule above. Firefox matches :-moz-focusring exactly where it matches
:focus-visible (keyboard, not a mouse click), so restating the ring here adds nothing else. */
:-moz-focusring:where(:not(iframe)) {
outline: var(--focus-width) solid var(--ring);
outline-offset: var(--focus-offset);
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
@@ -513,6 +541,15 @@
} }
} }
/* A row whose focus target sits inside (a label with its checkbox, radio or a visually hidden
input) also rings the whole row, so the focus is easy to spot from arm's length (spec 4.6). */
@utility focus-ring-within {
&:has(:focus-visible) {
outline: var(--focus-width) solid var(--ring);
outline-offset: var(--focus-offset);
}
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
Motion levels (see lib/motion/motion-preference.ts) Motion levels (see lib/motion/motion-preference.ts)
html[data-motion] = full | subtle | off, chosen per device in the profile. html[data-motion] = full | subtle | off, chosen per device in the profile.
@@ -574,7 +611,6 @@
height: 2.75rem; height: 2.75rem;
border-radius: 9999px; border-radius: 9999px;
color: var(--primary-foreground); color: var(--primary-foreground);
outline: none;
} }
.honey-check::before { .honey-check::before {
content: ""; content: "";
@@ -600,9 +636,6 @@
border-color: var(--primary); border-color: var(--primary);
background: color-mix(in oklch, var(--primary) 8%, var(--card)); background: color-mix(in oklch, var(--primary) 8%, var(--card));
} }
.honey-check:focus-visible::before {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 45%, transparent);
}
.honey-check svg { .honey-check svg {
position: relative; position: relative;
z-index: 1; z-index: 1;
+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 = { export const metadata: Metadata = {
title: "ApiCore – Imkereiverwaltung", title: "ApiCore – Imkereiverwaltung",
description: "Digitale Imkereiverwaltung für Wabenhain", 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 = { 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)", () => { it("has no build flag for dark any more (task A4)", () => {
expect(appearanceScript()).not.toMatch(/DARK=/); expect(appearanceScript()).not.toMatch(/DARK=/);
}); });
@@ -19,18 +19,7 @@ function read(relative: string): string {
} }
describe("area migration", () => { describe("area migration", () => {
it("no file passes a gradient prop any more", () => { // The gradient and white-text rules live in src/styles/baukasten-guard.spec.ts (task A5).
const offenders = sourceFiles(SRC).filter((file) => {
const text = readFileSync(file, "utf8");
return /gradient=|var\(--gradient-|\[background-image:var\(--gradient/.test(text);
});
expect(offenders).toEqual([]);
});
it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("harvest detail uses Scissors, extraction create FlaskConical and storage only Warehouse", () => { it("harvest detail uses Scissors, extraction create FlaskConical and storage only Warehouse", () => {
expect(read("app/(app)/harvests/[id]/page.tsx")).toContain('area="harvests"'); expect(read("app/(app)/harvests/[id]/page.tsx")).toContain('area="harvests"');
@@ -54,15 +43,4 @@ describe("area migration", () => {
}); });
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("no white text outside the hive stage", () => {
const offenders = sourceFiles(SRC)
.filter((file) => !file.replaceAll("\\", "/").includes("/components/hive/"))
.filter((file) => /\btext-white\b/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("the stage overlay on the hive stage uses the holo label colour instead of white", () => {
expect(read("components/hive/editor/editor-stage-overlay.tsx")).not.toMatch(/\btext-white\b/);
});
}); });
@@ -138,7 +138,7 @@ export function NextStepsView({
<h2 <h2
id={NEXT_STEPS_HEADING_ID} id={NEXT_STEPS_HEADING_ID}
tabIndex={-1} tabIndex={-1}
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="font-heading text-base font-semibold"
> >
Als Nächstes Als Nächstes
</h2> </h2>
@@ -74,7 +74,7 @@ export function DetailHeader({
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words"> <h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
{title} {title}
</h1> </h1>
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null} {subtitle ? <p className="text-base text-muted-foreground">{subtitle}</p> : null}
{status ? <div className="flex flex-wrap items-center gap-2">{status}</div> : null} {status ? <div className="flex flex-wrap items-center gap-2">{status}</div> : null}
</div> </div>
</div> </div>
@@ -155,7 +155,7 @@ function BackLink({ href, label }: { href: string; label: string }) {
return ( return (
<Link <Link
href={href} href={href}
className="-ml-2 inline-flex min-h-11 items-center gap-1 self-start rounded-lg px-2 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50" className="-ml-2 inline-flex min-h-11 items-center gap-1 self-start rounded-lg px-2 text-sm text-muted-foreground hover:text-foreground"
> >
<ChevronLeft className="size-5" aria-hidden /> <ChevronLeft className="size-5" aria-hidden />
{label} {label}
@@ -17,7 +17,7 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
</span> </span>
<div> <div>
<p className="font-medium">{title}</p> <p className="font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? <p className="text-base text-muted-foreground">{description}</p> : null}
</div> </div>
{action ? <div className="mt-1">{action}</div> : null} {action ? <div className="mt-1">{action}</div> : null}
</div> </div>
@@ -37,7 +37,7 @@ export function EntityCard({
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<AreaIcon area={area} /> <AreaIcon area={area} />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{title}</p> <p className="truncate text-base font-medium">{title}</p>
{subtitle ? ( {subtitle ? (
<p className="truncate text-sm text-muted-foreground">{subtitle}</p> <p className="truncate text-sm text-muted-foreground">{subtitle}</p>
) : null} ) : null}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { useState } from "react"; import { useState } from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Sun } from "lucide-react";
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group"; import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group";
type Feed = "ausreichend" | "niedrig" | "kritisch"; type Feed = "ausreichend" | "niedrig" | "kritisch";
@@ -221,4 +222,117 @@ describe("choiceOptions", () => {
{ value: "NONE", label: "Keine" }, { value: "NONE", label: "Keine" },
]); ]);
}); });
it("options are body text (16 px) and take the global focus ring", () => {
render(<Harness />);
for (const radio of screen.getAllByRole("radio")) {
expect(radio.className.split(/\s+/)).toContain("text-base");
expect(radio.className).not.toMatch(/outline-none|ring-ring\/50|focus-visible:ring-|focus-visible:border-ring/);
}
});
});
describe("ChoiceGroup – selected look (spec 10.2)", () => {
const classesOf = (element: HTMLElement) => element.className.split(/\s+/);
it("the chosen option has aria-checked true, the tick icon and the ink border", () => {
render(<Harness initial="niedrig" />);
const chosen = screen.getByRole("radio", { name: "Niedrig" });
expect(chosen).toHaveAttribute("aria-checked", "true");
expect(chosen.querySelector("[data-choice-mark='tick']")).not.toBeNull();
expect(chosen.querySelector("[data-choice-mark='tick']")?.getAttribute("aria-hidden")).toBe("true");
expect(classesOf(chosen)).toEqual(
expect.arrayContaining(["border-2", "border-foreground", "bg-primary", "text-primary-foreground", "font-semibold"]),
);
// The tick stands before the label, and the spoken name stays the label.
expect(chosen.firstElementChild?.getAttribute("data-choice-mark")).toBe("tick");
expect(chosen).toHaveAccessibleName("Niedrig");
const other = screen.getByRole("radio", { name: "Kritisch" });
expect(other.querySelector("[data-choice-mark]")).toBeNull();
expect(classesOf(other)).toEqual(expect.arrayContaining(["border", "border-input", "bg-card"]));
});
it("„Nicht geprüft“ chosen shows the dash icon and a dashed border", () => {
render(<Harness />);
const unanswered = screen.getByRole("radio", { name: "Nicht geprüft" });
expect(unanswered.querySelector("[data-choice-mark='dash']")).not.toBeNull();
expect(unanswered.querySelector("[data-choice-mark='tick']")).toBeNull();
expect(classesOf(unanswered)).toEqual(
expect.arrayContaining(["border-2", "border-dashed", "border-foreground", "bg-muted"]),
);
expect(classesOf(unanswered)).not.toContain("bg-primary");
});
it("appearance cards shows the description and is at least 56 px high", () => {
render(
<ChoiceGroup
label="Farbschema"
appearance="cards"
options={[
{ value: "light", label: "Hell", description: "Am besten bei Tageslicht" },
{ value: "dark", label: "Dunkel", description: "Für abends" },
]}
value="light"
onChange={() => undefined}
/>,
);
const light = screen.getByRole("radio", { name: "Hell" });
expect(light).toHaveAccessibleDescription("Am besten bei Tageslicht");
expect(screen.getByText("Für abends")).toHaveClass("text-sm");
expect(screen.getByText("Hell")).toHaveClass("text-base");
for (const radio of screen.getAllByRole("radio")) {
expect(classesOf(radio)).toEqual(expect.arrayContaining(["min-h-14", "w-full", "text-left"]));
}
});
it("an option icon renders before the label", () => {
render(
<ChoiceGroup
label="Ergebnis"
options={[
{ value: "a", label: "Überlebt", icon: Sun },
{ value: "b", label: "Verloren" },
]}
value="b"
onChange={() => undefined}
/>,
);
const radio = screen.getByRole("radio", { name: "Überlebt" });
const icon = radio.querySelector("svg.lucide-sun");
expect(icon).not.toBeNull();
expect(icon).toHaveAttribute("aria-hidden", "true");
const label = screen.getByText("Überlebt");
expect(icon!.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("arrow keys, Home and End move the selection as before", async () => {
const onChange = vi.fn();
render(
<ChoiceGroup
label="Bewegung"
appearance="cards"
options={[
{ value: "full", label: "Voll", description: "a" },
{ value: "subtle", label: "Dezent", description: "b" },
{ value: "off", label: "Aus", description: "c" },
]}
value="full"
onChange={onChange}
/>,
);
await userEvent.tab();
expect(screen.getByRole("radio", { name: "Voll" })).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(onChange).toHaveBeenLastCalledWith("subtle");
await userEvent.keyboard("{End}");
expect(onChange).toHaveBeenLastCalledWith("off");
await userEvent.keyboard("{Home}");
expect(onChange).toHaveBeenLastCalledWith("full");
});
}); });
@@ -1,6 +1,7 @@
"use client"; "use client";
import { KeyboardEvent, useRef } from "react"; import { KeyboardEvent, useId, useRef } from "react";
import { Check, Minus, type LucideIcon } from "lucide-react";
import { keysOf } from "@/lib/keys-of"; import { keysOf } from "@/lib/keys-of";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -19,8 +20,18 @@ export interface ChoiceOption<T extends ChoiceValue> {
selectedClassName?: string; selectedClassName?: string;
/** Shown but not choosable; arrow keys skip it. */ /** Shown but not choosable; arrow keys skip it. */
disabled?: boolean; disabled?: boolean;
/** Icon before the label, e.g. for the outcome of the winter. */
icon?: LucideIcon;
/** Second line under the label (14 px); only shown with `appearance="cards"`. */
description?: string;
} }
/**
* "buttons": a grid of equal buttons (default). "cards": one full-width option per row with
* label and description, at least 56 px high - for choices that need a sentence each.
*/
export type ChoiceAppearance = "buttons" | "cards";
/** Distinct per value and type: "null" (text) and null must not collide. */ /** Distinct per value and type: "null" (text) and null must not collide. */
function optionKey(value: ChoiceValue): string { function optionKey(value: ChoiceValue): string {
return `${value === null ? "null" : typeof value}:${String(value)}`; return `${value === null ? "null" : typeof value}:${String(value)}`;
@@ -52,6 +63,7 @@ interface ChoiceGroupProps<T extends ChoiceValue> {
className?: string; className?: string;
/** Extra classes for every option button, e.g. a larger font. */ /** Extra classes for every option button, e.g. a larger font. */
optionClassName?: string; optionClassName?: string;
appearance?: ChoiceAppearance;
} }
const KEY_STEPS: Record<string, number> = { const KEY_STEPS: Record<string, number> = {
@@ -69,6 +81,72 @@ function targetIndex(key: string, current: number, count: number): number | null
return (current + step + count) % count; return (current + step + count) % count;
} }
const OPTION_CLASS: Record<ChoiceAppearance, string> = {
buttons:
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg px-2 text-base motion-safe:transition-colors",
cards:
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base motion-safe:transition-colors",
};
const MARK_ICON = { tick: Check, dash: Minus } as const;
type Mark = keyof typeof MARK_ICON;
/** Tick (an answer) or dash („Nicht geprüft“) before the label, the option icon, then the text. */
function OptionContent<T extends ChoiceValue>({
option,
id,
mark,
appearance,
isMuted,
}: {
option: ChoiceOption<T>;
id: string;
mark: Mark | null;
appearance: ChoiceAppearance;
isMuted: boolean;
}) {
const MarkIcon = mark ? MARK_ICON[mark] : null;
const Icon = option.icon;
const iconClass = cn("size-4 shrink-0", appearance === "cards" && "mt-1");
const label = (
<>
{option.label}
{option.srSuffix ? (
<>
{" "}
<span className="sr-only">{option.srSuffix}</span>
</>
) : null}
</>
);
return (
<>
{MarkIcon ? (
<MarkIcon aria-hidden="true" data-choice-mark={mark} strokeWidth={3} className={iconClass} />
) : null}
{Icon ? <Icon aria-hidden="true" className={cn(iconClass, appearance === "cards" && "size-5 mt-0.5")} /> : null}
{appearance === "cards" ? (
<span className="flex min-w-0 flex-col">
<span id={`${id}-label`} className="text-base">
{label}
</span>
{option.description !== undefined ? (
<span
id={`${id}-description`}
className={cn("text-sm font-normal", isMuted && "text-muted-foreground")}
>
{option.description}
</span>
) : null}
</span>
) : (
<span>{label}</span>
)}
</>
);
}
/** /**
* One large button per option instead of a dropdown: a single tap with * One large button per option instead of a dropdown: a single tap with
* gloves, and every choice stays visible. A `null` option stands for "not * gloves, and every choice stays visible. A `null` option stands for "not
@@ -88,7 +166,9 @@ export function ChoiceGroup<T extends ChoiceValue>({
clearValue, clearValue,
className, className,
optionClassName, optionClassName,
appearance = "buttons",
}: ChoiceGroupProps<T>) { }: ChoiceGroupProps<T>) {
const baseId = useId();
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]); const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
const checkedIndex = options.findIndex((option) => option.value === value); const checkedIndex = options.findIndex((option) => option.value === value);
const tabStop = checkedIndex === -1 ? 0 : checkedIndex; const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
@@ -119,6 +199,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
{options.map((option, index) => { {options.map((option, index) => {
const selected = index === checkedIndex; const selected = index === checkedIndex;
const isUnanswered = option.value === null; const isUnanswered = option.value === null;
const optionId = `${baseId}-${index}`;
const hasDescription = appearance === "cards" && option.description !== undefined;
return ( return (
<button <button
key={optionKey(option.value)} key={optionKey(option.value)}
@@ -128,6 +210,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
aria-labelledby={hasDescription ? `${optionId}-label` : undefined}
aria-describedby={hasDescription ? `${optionId}-description` : undefined}
disabled={option.disabled} disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1} tabIndex={index === tabStop ? 0 : -1}
onClick={() => onClick={() =>
@@ -135,25 +219,25 @@ export function ChoiceGroup<T extends ChoiceValue>({
} }
onKeyDown={(event) => handleKeyDown(event, index)} onKeyDown={(event) => handleKeyDown(event, index)}
className={cn( className={cn(
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors", OPTION_CLASS[appearance],
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", !selected && "border border-input bg-card hover:bg-muted",
!selected && "border-border bg-background hover:bg-muted", option.disabled && "cursor-not-allowed opacity-50 hover:bg-card",
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background", selected && "border-2 font-semibold",
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground", selected && isUnanswered && "border-dashed bg-muted text-foreground",
selected && selected && !isUnanswered && "bg-primary text-primary-foreground",
!isUnanswered &&
"border-foreground bg-primary font-medium text-primary-foreground",
selected && option.selectedClassName, selected && option.selectedClassName,
// After the tone of a scale step: the frame of a chosen option is always the ink.
selected && "border-foreground",
optionClassName, optionClassName,
)} )}
> >
{option.label} <OptionContent
{option.srSuffix ? ( option={option}
<> id={optionId}
{" "} mark={selected ? (isUnanswered ? "dash" : "tick") : null}
<span className="sr-only">{option.srSuffix}</span> appearance={appearance}
</> isMuted={!selected}
) : null} />
</button> </button>
); );
})} })}
@@ -147,7 +147,7 @@ export function ColonyMultiSelect({
<li key={colony.id}> <li key={colony.id}>
<label <label
htmlFor={`${id}-${colony.id}`} htmlFor={`${id}-${colony.id}`}
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50" className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 focus-ring-within"
> >
<Checkbox <Checkbox
id={`${id}-${colony.id}`} id={`${id}-${colony.id}`}
@@ -36,4 +36,12 @@ describe("Field", () => {
const group = screen.getByRole("radiogroup", { name: /^Futterart/ }); const group = screen.getByRole("radiogroup", { name: /^Futterart/ });
expect(group).toHaveAccessibleDescription("Eine Art antippen."); expect(group).toHaveAccessibleDescription("Eine Art antippen.");
}); });
it("the label is body text (16 px), the hint stays secondary", () => {
render(<Field label="Datum" hint="Tag der Durchsicht">{({ id }) => <input id={id} type="date" />}</Field>);
const label = screen.getByText("Datum");
expect(label.className.split(/\s+/)).toContain("text-base");
expect(label.className.split(/\s+/)).not.toContain("text-sm");
});
}); });
@@ -52,7 +52,7 @@ export function Field({
<Caption <Caption
id={`${id}-label`} id={`${id}-label`}
htmlFor={group ? undefined : id} htmlFor={group ? undefined : id}
className="flex items-baseline gap-2 text-sm font-medium" className="flex items-baseline gap-2 text-base font-medium"
> >
{label} {label}
<span <span
@@ -57,7 +57,7 @@ export function FormBackLink({ href, label, dirty, draft, className }: FormBackL
href={href} href={href}
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none", "-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground",
className, className,
)} )}
> >
@@ -316,4 +316,18 @@ describe("NumberStepper", () => {
expect(onEmit).toHaveBeenLastCalledWith(""); expect(onEmit).toHaveBeenLastCalledWith("");
}); });
}); });
it("NumberStepper buttons keep min-h-11 and drop their own ring classes", () => {
render(<Harness initial="3" />);
const buttons = [
screen.getByRole("button", { name: /Eine Milbe weniger/ }),
screen.getByRole("button", { name: /Eine Milbe mehr/ }),
screen.getByRole("button", { name: /leeren/ }),
];
for (const button of buttons) {
expect(button.className.split(/\s+/)).toContain("min-h-11");
expect(button.className).not.toMatch(/outline-none|ring-ring\/50|focus-visible:ring-|focus-visible:border-ring/);
}
expect(screen.getByRole("textbox").className).not.toMatch(/(^|\s)sm:h-/);
});
}); });
@@ -27,7 +27,7 @@ export interface NumberStepperProps {
} }
const STEP_BUTTON_CLASS = const STEP_BUTTON_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold outline-none motion-safe:transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"; "inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold motion-safe:transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
const MAX_DIGITS = 4; const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1; const MAX_VALUE = 10 ** MAX_DIGITS - 1;
@@ -196,7 +196,7 @@ export function NumberStepper({
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
aria-invalid={invalid || offGrid || undefined} aria-invalid={invalid || offGrid || undefined}
className="w-20 text-center text-lg font-semibold sm:h-11" className="w-20 text-center text-lg font-semibold pointer-fine:h-11"
/> />
<button <button
type="button" type="button"
@@ -228,7 +228,7 @@ export function NumberStepper({
<button <button
type="button" type="button"
onClick={clear} onClick={clear}
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4"
> >
leeren{" "} leeren{" "}
<span className="sr-only">– {unitLabel}</span> <span className="sr-only">– {unitLabel}</span>
@@ -36,7 +36,7 @@ export function PreviousValueHint({
<button <button
type="button" type="button"
onClick={onTakeOver} onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4"
> >
übernehmen{" "} übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span> <span className="sr-only">– {fieldLabel}</span>
@@ -91,4 +91,17 @@ describe("RatingScale", () => {
expect(screen.getByText("1 = ruhig")).toBeInTheDocument(); expect(screen.getByText("1 = ruhig")).toBeInTheDocument();
expect(screen.getByText("5 = stark")).toBeInTheDocument(); expect(screen.getByText("5 = stark")).toBeInTheDocument();
}); });
it("RatingScale shows the tick before the chosen number and keeps its tone", () => {
render(<Harness initial={4} />);
const chosen = screen.getAllByRole("radio")[3];
expect(chosen).toHaveTextContent("4");
expect(chosen.firstElementChild?.getAttribute("data-choice-mark")).toBe("tick");
expect(chosen).toHaveClass("bg-condition-good", "text-condition-good-foreground", "border-2", "border-foreground");
expect(chosen).not.toHaveClass("border-condition-good");
expect(chosen).toHaveAccessibleName("4 von 5");
for (const other of screen.getAllByRole("radio").filter((radio) => radio !== chosen)) {
expect(other.querySelector("[data-choice-mark]")).toBeNull();
}
});
}); });
@@ -8,9 +8,9 @@ import { ReactNode } from "react";
* `position: sticky` only works while no ancestor between this bar and the * `position: sticky` only works while no ancestor between this bar and the
* window is a scroll container (overflow auto/scroll/hidden). * window is a scroll container (overflow auto/scroll/hidden).
* *
* Buttons in the bar keep a 48px touch target on every breakpoint - the * Buttons in the bar keep a 48px touch target for every pointer - the
* default button size shrinks to 32px from `sm` up (`sm:h-8`), which would * default button size shrinks to 32px for a mouse (`pointer-fine:h-8`), which
* otherwise win over the forms' `h-12`. * would otherwise win over the forms' `h-12`.
*/ */
export function StickySubmit({ export function StickySubmit({
children, children,
@@ -14,7 +14,7 @@ interface SwitchRowProps {
export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) { export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) {
const id = useId(); const id = useId();
return ( return (
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"> <label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 focus-ring-within">
<input <input
type="checkbox" type="checkbox"
role="switch" role="switch"
@@ -40,7 +40,7 @@ const INSERT_HOST_KIND: Partial<Record<HivePartKind, HivePartKind>> = {
const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"]; const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"];
const SELECT_CLASS = const SELECT_CLASS =
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm"; "h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs";
export interface AddPartDialogProps { export interface AddPartDialogProps {
open: boolean; open: boolean;
@@ -13,7 +13,7 @@ export interface EditorNoticeProps {
/** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */ /** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */
export function EditorNotice({ title, backHref, children }: EditorNoticeProps) { export function EditorNotice({ title, backHref, children }: EditorNoticeProps) {
return ( return (
<main className="fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg"> <main className="holo-stage fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
<div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}> <div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}>
<h1 className="text-lg font-semibold">{title}</h1> <h1 className="text-lg font-semibold">{title}</h1>
{children} {children}
@@ -33,7 +33,7 @@ function Row({ part, name, isSelected, onSelect, moveUp, moveDown, remove }: Row
type="button" type="button"
onClick={onSelect} onClick={onSelect}
aria-current={isSelected ? "true" : undefined} aria-current={isSelected ? "true" : undefined}
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-holo-edge-strong aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold" className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
data-part-id={part.id} data-part-id={part.id}
> >
{name} {name}
@@ -313,7 +313,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
} }
return ( return (
<main className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg"> <main className="holo-stage fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<EditorToolbar <EditorToolbar
back={ back={
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}> <Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
@@ -377,7 +377,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
/> />
</section> </section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3"> <section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none"> <h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold">
Aufbau Aufbau
</h2> </h2>
<EditorStackList <EditorStackList
@@ -174,6 +174,24 @@ describe("HiveCard", () => {
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument(); expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
}); });
it("the stage is a .holo-stage and the outline panel on it takes the page focus ring back", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(screen.getByRole("img", { name: /Beute mit/ }).closest(".holo-stage")).not.toBeNull();
expect(toggle.closest(".holo-stage")).not.toBeNull();
await userEvent.click(toggle);
const panel = document.getElementById(toggle.getAttribute("aria-controls")!)!;
expect(panel).toHaveClass("stage-card");
});
it("the loading stage is a .holo-stage too", () => {
vi.mocked(fetchHive).mockReturnValue(new Promise(() => {}));
renderCard();
expect(screen.getByRole("status", { name: "Beute wird geladen" })).toHaveClass("holo-stage");
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => { it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard(); renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" }); const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
@@ -189,6 +207,15 @@ describe("HiveCard", () => {
expect(toggle).toHaveAttribute("aria-pressed", "true"); expect(toggle).toHaveAttribute("aria-pressed", "true");
}); });
it("„Aufbau als Liste“ is the toolbar toggle of the Button kit (prop pressed), still glass on the stage", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(toggle).toHaveAttribute("data-slot", "button");
expect(toggle).toHaveClass("backdrop-blur-md", "text-holo-label-fg", "h-11");
expect(toggle.className).not.toMatch(/(^|\s)(bg-primary|pointer-fine:h-8|hover:text-foreground)(\s|$)/);
});
it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => { it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => {
renderCard(); renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" })); await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
@@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
<div <div
role="status" role="status"
aria-label="Beute wird geladen" aria-label="Beute wird geladen"
className="min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse" className="holo-stage min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
/> />
); );
} }
@@ -318,7 +318,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
return ( return (
<> <>
<div className="relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg"> <div className="holo-stage relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg">
<div className="absolute inset-0"> <div className="absolute inset-0">
<HiveVisualSlot <HiveVisualSlot
layout={layout} layout={layout}
@@ -384,20 +384,23 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
Umbauen Umbauen
</button> </button>
) : null} ) : null}
<button {/* A toolbar toggle of the kit; the glass classes win over the ghost look (spec 10.2). */}
<Button
type="button" type="button"
aria-pressed={showOutline} variant="ghost"
size="touch"
pressed={showOutline}
aria-controls={outlineId} aria-controls={outlineId}
aria-label="Aufbau als Liste" aria-label="Aufbau als Liste"
onClick={() => { onClick={() => {
setShowOutline(!showOutline); setShowOutline(!showOutline);
onShowOutlineChange?.(!showOutline); onShowOutlineChange?.(!showOutline);
}} }}
className={GLASS_BUTTON} className={`${GLASS_BUTTON} hover:text-holo-label-fg`}
> >
<ListTree aria-hidden /> <ListTree aria-hidden />
Liste Liste
</button> </Button>
<button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}> <button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}>
<History aria-hidden /> <History aria-hidden />
Verlauf Verlauf
@@ -414,7 +417,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
id={outlineId} id={outlineId}
className={ className={
showOutline showOutline
? "pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4" ? "stage-card pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4"
: "sr-only" : "sr-only"
} }
> >
@@ -1,11 +1,12 @@
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is // Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well // --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
// above 4.5:1; focus rings use the strong edge colour. // above 4.5:1. The focus ring comes from the global rule; .holo-stage on the
// stage root draws it in the strong edge colour (spec 17.8).
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md"; const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`; export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`; export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${GLASS_SURFACE}`; export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
@@ -94,7 +94,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
type="button" type="button"
disabled={loadingId !== null} disabled={loadingId !== null}
onClick={() => void show(item)} onClick={() => void show(item)}
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60" className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm hover:bg-muted disabled:opacity-60"
> >
<span className="font-medium"> <span className="font-medium">
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)} {shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
@@ -153,7 +153,7 @@ export function HiveInit({ colonyId, colony, canEdit }: HiveInitProps) {
{copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."} {copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."}
</p> </p>
</div> </div>
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg"> <div className="holo-stage relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
<div className="absolute inset-0"> <div className="absolute inset-0">
{proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null} {proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null}
</div> </div>
@@ -437,7 +437,7 @@ export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEdit
activateNode(node); activateNode(node);
}} }}
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }} style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium" className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm hover:bg-muted aria-[current=true]:bg-muted aria-[current=true]:font-medium"
> >
<span className={isFrame ? "text-muted-foreground" : undefined}> <span className={isFrame ? "text-muted-foreground" : undefined}>
{node.hint ? `${node.name} · ${node.hint}` : node.name} {node.hint ? `${node.name} · ${node.hint}` : node.name}
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(__dirname, "..", "..");
const read = (relative: string) => readFileSync(join(SRC, relative), "utf8");
/**
* Spec 17.8: the page focus ring (--primary-ink) has less than 3:1 on --holo-bg, so every root
* that paints the stage carries .holo-stage, which sets --ring to --holo-edge-strong.
*/
describe("hive stage roots carry .holo-stage", () => {
it.each([
["the editor", "components/hive/editor/hive-editor.tsx"],
["the editor notice", "components/hive/editor/editor-notice.tsx"],
["the loading fallback of the editor route", "app/(app)/colonies/[id]/hive/page.tsx"],
["the setup preview", "components/hive/hive-init.tsx"],
])("%s", (_name, file) => {
const roots = read(file).match(/className="[^"]*\bbg-holo-bg\b[^"]*"/g) ?? [];
expect(roots.length).toBeGreaterThan(0);
for (const root of roots) expect(root).toMatch(/\bholo-stage\b/);
});
it("no hive component draws its own stage ring any more", () => {
expect(read("components/hive/hive-glass.ts")).not.toMatch(/outline-holo-edge-strong/);
expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/);
});
});
@@ -259,7 +259,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameF
aria-labelledby={`part-${part.id}-confirm`} aria-labelledby={`part-${part.id}-confirm`}
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3" className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
> >
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none"> <p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm">
{removalNoun} wirklich entfernen? {removalNoun} wirklich entfernen?
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -12,7 +12,7 @@ import {
/** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */ /** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */
export const NAV_SHEET_ROW_CLASS = export const NAV_SHEET_ROW_CLASS =
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"; "flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium";
interface NavSheetProps { interface NavSheetProps {
open: boolean; open: boolean;
@@ -34,11 +34,11 @@ function setOnLine(value: boolean) {
} }
/** The band as the layout places it: above the page content wrapper. */ /** 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); const pageContentRef = useRef<HTMLDivElement>(null);
return ( 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"> <div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt Seiteninhalt
</div> </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 } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
@@ -85,6 +85,19 @@ describe("OfflineBand", () => {
vi.restoreAllMocks(); 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 () => { it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
setOnLine(false); setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
@@ -38,6 +38,8 @@ interface OfflineBandProps {
* nor sends - the server would only answer 401. * nor sends - the server would only answer 401.
*/ */
syncEnabled?: boolean; 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. * device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back. * 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 queryClient = useQueryClient();
const connectivity = useConnectivity(); const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null); const statusRef = useRef<HTMLDivElement>(null);
@@ -136,8 +138,8 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
role="status" role="status"
aria-live="polite" aria-live="polite"
tabIndex={-1} tabIndex={-1}
inert={inert}
className={cn( className={cn(
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
hasContent && 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", "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",
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"), hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
@@ -455,7 +455,7 @@ export function PendingQueueSheet({
id="queue-voice-heading" id="queue-voice-heading"
ref={voiceHeadingRef} ref={voiceHeadingRef}
tabIndex={-1} tabIndex={-1}
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="font-medium"
> >
Sprachnotizen Sprachnotizen
</h3> </h3>
@@ -109,6 +109,14 @@ describe("PhenologyReportSheet", () => {
} }
}); });
it("the scope is one radio group from the kit, named „Gilt für“", () => {
renderSheet();
const group = screen.getByRole("radiogroup", { name: "Gilt für" });
expect(within(group).getAllByRole("radio")).toHaveLength(2);
expect(document.querySelector('input[type="radio"]')).toBeNull();
});
it("reports for this apiary only after switching the scope", async () => { it("reports for this apiary only after switching the scope", async () => {
renderSheet(); renderSheet();
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useRef, useState, type RefObject } from "react"; import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flower2 } from "lucide-react"; import { Flower2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -147,8 +148,8 @@ function ReportForm({
} }
className={cn( className={cn(
"flex min-h-14 w-full items-center gap-3 rounded-xl border border-border bg-background px-4", "flex min-h-14 w-full items-center gap-3 rounded-xl border border-border bg-background px-4",
"text-left text-base font-medium outline-none transition-colors", "text-left text-base font-medium transition-colors",
"hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", "hover:bg-muted",
"disabled:pointer-events-none disabled:opacity-50", "disabled:pointer-events-none disabled:opacity-50",
)} )}
> >
@@ -185,29 +186,18 @@ function ScopeChoice({
scope: Scope; scope: Scope;
onChange: (scope: Scope) => void; onChange: (scope: Scope) => void;
}) { }) {
const options: { value: Scope; label: string }[] = [ const labelId = useId();
const options: ChoiceOption<Scope>[] = [
{ value: "all", label: "Für alle Stände" }, { value: "all", label: "Für alle Stände" },
{ value: "apiary", label: `Nur ${apiaryName}` }, { value: "apiary", label: `Nur ${apiaryName}` },
]; ];
return ( return (
<fieldset className="flex flex-col gap-1"> <div className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Gilt für</legend> <p id={labelId} className="text-base font-medium">
{options.map((option) => ( Gilt für
<label </p>
key={option.value} <ChoiceGroup options={options} value={scope} onChange={onChange} labelledBy={labelId} />
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base has-focus-visible:ring-3 has-focus-visible:ring-ring/50" </div>
>
<input
type="radio"
name="phenology-scope"
className="size-5 accent-primary"
checked={scope === option.value}
onChange={() => onChange(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
); );
} }
@@ -264,7 +254,7 @@ function ReportsOfTheYear({
id="phenology-reports-heading" id="phenology-reports-heading"
ref={heading} ref={heading}
tabIndex={-1} tabIndex={-1}
className="text-sm font-medium outline-none" className="text-sm font-medium"
> >
Meldungen dieses Jahres Meldungen dieses Jahres
</h3> </h3>
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StockCard } from "./stock-card"; import { StockCard } from "./stock-card";
import { fetchStock } from "@/lib/stock/api"; import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
import type { BatchStock } from "@/lib/stock/types"; import type { BatchStock, StockBatchPath } from "@/lib/stock/types";
vi.mock("@/lib/stock/api", () => ({ vi.mock("@/lib/stock/api", () => ({
fetchStock: vi.fn(), fetchStock: vi.fn(),
@@ -11,17 +12,17 @@ vi.mock("@/lib/stock/api", () => ({
correctStock: vi.fn(), correctStock: vi.fn(),
})); }));
function renderCard(canBook = true) { function renderCard(canBook = true, unit = "Gläser", path: StockBatchPath = "bottling-batches") {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
}); });
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<StockCard <StockCard
path="bottling-batches" path={path}
id="b1" id="b1"
initialLabel="Abgefüllt" initialLabel="Abgefüllt"
unit="Gläser" unit={unit}
canBook={canBook} canBook={canBook}
/> />
</QueryClientProvider>, </QueryClientProvider>,
@@ -107,4 +108,61 @@ describe("StockCard", () => {
screen.queryByRole("button", { name: /Inventur/ }), screen.queryByRole("button", { name: /Inventur/ }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("stock card withdraw and counted stock use NumberStepper", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
vi.mocked(withdrawStock).mockResolvedValue(undefined as never);
vi.mocked(correctStock).mockResolvedValue(undefined as never);
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
let dialog = await screen.findByRole("dialog");
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
expect(quantity).toHaveValue("1");
expect(quantity).toHaveAttribute("inputmode", "numeric");
await userEvent.click(within(dialog).getByRole("button", { name: /Eins mehr/ }));
await userEvent.click(within(dialog).getByRole("button", { name: "Entnahme buchen" }));
expect(vi.mocked(withdrawStock).mock.calls[0][2]).toMatchObject({ quantity: 2 });
await userEvent.click(await screen.findByRole("button", { name: /Inventur korrigieren/ }));
dialog = await screen.findByRole("dialog");
const counted = within(dialog).getByLabelText("Gezählt (Gläser)");
expect(counted).toHaveValue("80");
await userEvent.click(within(dialog).getByRole("button", { name: /Eins weniger/ }));
expect(within(dialog).getByText(/Differenz: -1/)).toBeInTheDocument();
});
it("keeps a typed field for amounts in grams", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(500));
renderCard(true, "g", "raw-material-lots");
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByLabelText("Anzahl (g)")).toHaveAttribute("type", "number");
expect(within(dialog).queryByRole("button", { name: /mehr$/ })).toBeNull();
});
it("does not book an empty withdrawal", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
await userEvent.clear(within(dialog).getByLabelText("Anzahl (Gläser)"));
expect(within(dialog).getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("does not book a withdrawal of 0 or more than is left", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
const book = within(dialog).getByRole("button", { name: "Entnahme buchen" });
await userEvent.clear(quantity);
await userEvent.type(quantity, "0");
expect(book).toBeDisabled();
await userEvent.clear(quantity);
await userEvent.type(quantity, "80");
expect(book).toBeEnabled();
});
}); });
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { NumberStepper } from "@/components/form/number-stepper";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { import {
@@ -210,6 +211,56 @@ function useStockMutationDone(path: StockBatchPath, id: string) {
}; };
} }
/**
* Jars and pieces are counted at the shelf: plus/minus for gloved fingers (spec 10.5). Raw
* materials are weighed in grams and keep a typed field.
*/
function QuantityField({
id,
path,
unit,
min,
max,
value,
onChange,
}: {
id: string;
path: StockBatchPath;
unit: string;
min: number;
max?: number;
value: string;
onChange: (value: string) => void;
}) {
if (path === "raw-material-lots") {
return (
<Input
id={id}
type="number"
inputMode="numeric"
min={min}
max={max}
step={1}
required
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
return (
<NumberStepper
id={id}
value={value}
onChange={onChange}
min={min}
max={max !== undefined && max >= min ? max : undefined}
unitLabel={unit}
decrementLabel="Eins weniger"
incrementLabel="Eins mehr"
/>
);
}
function WithdrawDialog({ function WithdrawDialog({
path, path,
id, id,
@@ -227,6 +278,9 @@ function WithdrawDialog({
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const done = useStockMutationDone(path, id); const done = useStockMutationDone(path, id);
// The server takes only a positive amount up to what is left; 0 or more would be a 400.
const amount = Number(quantity);
const canWithdraw = quantity !== "" && amount >= 1 && amount <= remaining;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -277,16 +331,14 @@ function WithdrawDialog({
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label> <Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
<Input <QuantityField
id="withdraw-quantity" id="withdraw-quantity"
type="number" path={path}
inputMode="numeric" unit={unit}
min={1} min={1}
max={remaining} max={remaining}
step={1}
required
value={quantity} value={quantity}
onChange={(event) => setQuantity(event.target.value)} onChange={setQuantity}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -311,7 +363,7 @@ function WithdrawDialog({
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending || !canWithdraw}>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"} {mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button> </Button>
</form> </form>
@@ -391,15 +443,13 @@ function CorrectDialog({
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="correct-counted">Gezählt ({unit})</Label> <Label htmlFor="correct-counted">Gezählt ({unit})</Label>
<Input <QuantityField
id="correct-counted" id="correct-counted"
type="number" path={path}
inputMode="numeric" unit={unit}
min={0} min={0}
step={1}
required
value={counted} value={counted}
onChange={(event) => setCounted(event.target.value)} onChange={setCounted}
/> />
{difference !== 0 ? ( {difference !== 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -63,7 +63,7 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm
<Input <Input
id="suggestion-due-date" id="suggestion-due-date"
type="date" type="date"
className="h-11 sm:h-11" className="h-11 pointer-fine:h-11"
value={date} value={date}
required required
onChange={(event) => setDate(event.target.value)} onChange={(event) => setDate(event.target.value)}
@@ -117,7 +117,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
<h3 <h3
tabIndex={-1} tabIndex={-1}
data-suggestion-heading={suggestion.id} data-suggestion-heading={suggestion.id}
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="font-medium"
> >
{suggestion.title} {suggestion.title}
</h3> </h3>
@@ -15,7 +15,7 @@ describe("TabSwitcher", () => {
/>, />,
); );
for (const tab of screen.getAllByRole("tab")) { for (const tab of screen.getAllByRole("tab")) {
expect(tab).toHaveClass("min-h-11", "sm:min-h-9"); expect(tab).toHaveClass("min-h-11", "pointer-fine:min-h-9");
} }
}); });
}); });
@@ -20,7 +20,7 @@ export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwit
aria-selected={value === tab.value} aria-selected={value === tab.value}
onClick={() => onChange(tab.value)} onClick={() => onChange(tab.value)}
className={cn( className={cn(
"min-h-11 sm:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none", "min-h-11 pointer-fine:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
value === tab.value value === tab.value
? "bg-card text-foreground shadow-[var(--shadow-soft)]" ? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
@@ -35,7 +35,7 @@ export function ComplianceLine() {
return ( return (
<Link <Link
href="/compliance" href="/compliance"
className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm outline-none hover:bg-destructive/10 focus-visible:ring-3 focus-visible:ring-ring/50" className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm hover:bg-destructive/10"
> >
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden /> <XCircle className="size-5 shrink-0 text-destructive" aria-hidden />
<span className="flex-1"> <span className="flex-1">
@@ -297,7 +297,7 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
<h2 <h2
id="next-step-heading" id="next-step-heading"
tabIndex={-1} tabIndex={-1}
className="font-heading text-base font-semibold text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="font-heading text-base font-semibold text-muted-foreground"
> >
Als Nächstes Als Nächstes
</h2> </h2>
@@ -359,7 +359,7 @@ function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
<li> <li>
<Link <Link
href="/suggestions" href="/suggestions"
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring" className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium"
> >
<Lightbulb className="size-4" aria-hidden /> <Lightbulb className="size-4" aria-hidden />
Vorschläge {autopilot.suggestionsOpen} Vorschläge {autopilot.suggestionsOpen}
@@ -431,7 +431,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
<Link <Link
href={href} href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]} aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring" className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<ArrowRight className="size-5" aria-hidden /> <ArrowRight className="size-5" aria-hidden />
</Link> </Link>
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import { Sprout } from "lucide-react";
import { describe, expect, it } from "vitest";
import { DetailHeader } from "./detail-header";
import { EmptyState } from "./empty-state";
import { EntityCard } from "./entity-card";
const classesOf = (text: string) => screen.getByText(text).className.split(/\s+/);
describe("content text is 16 px (spec 4.5)", () => {
it("EmptyState description", () => {
render(<EmptyState icon={Sprout} title="Noch keine Völker" description="Lege das erste Volk an." />);
expect(classesOf("Lege das erste Volk an.")).toContain("text-base");
});
it("DetailHeader subtitle", () => {
render(<DetailHeader area="colonies" title="V-01" subtitle="Stand Waldrand" backHref="/colonies" backLabel="Alle Völker" />);
expect(classesOf("Stand Waldrand")).toContain("text-base");
});
it("list title in EntityCard", () => {
render(<EntityCard area="colonies" title="V-01" subtitle="Waldrand" />);
expect(classesOf("V-01")).toContain("text-base");
});
});
+3 -3
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
{ {
variants: { variants: {
variant: { variant: {
@@ -13,9 +13,9 @@ const badgeVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
warning: warning:
"bg-warning/15 text-warning-ink focus-visible:ring-warning/30 [a]:hover:bg-warning/25", "bg-warning/15 text-warning-ink [a]:hover:bg-warning/25",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more