diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx index 2b98cce..41954fa 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx @@ -281,4 +281,30 @@ describe("PrintLabelsCard", () => { 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"); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx index e6b3155..0df0f25 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx @@ -17,10 +17,13 @@ import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { NumberStepper } from "@/components/form/number-stepper"; 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_DONE = "Etiketten-PDF heruntergeladen."; const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht."; @@ -138,8 +141,10 @@ export function PrintLabelsCard({ const canCheck = canReadTemplates && hasPermission("bottling:read"); const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly(); const reasonId = useId(); + const startHintId = useId(); 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(null); const [printError, setPrintError] = useState(null); const [printDone, setPrintDone] = useState(null); @@ -160,6 +165,7 @@ export function PrintLabelsCard({ selectedTemplate?.columns && selectedTemplate?.rows ? selectedTemplate.columns * selectedTemplate.rows : undefined; + const startPosition = Math.max(1, Number(startPositionText) || 1); // The answer holds the food business address: no persistence, dropped as // soon as nobody looks at it any more. @@ -314,16 +320,18 @@ export function PrintLabelsCard({ - setStartPosition(Math.max(1, Number(event.target.value)))} + max={slotsPerSheet ?? MAX_START_POSITION} + unitLabel={slotsPerSheet ? `von ${slotsPerSheet}` : "Position"} + decrementLabel="Eine Position zurück" + incrementLabel="Eine Position weiter" /> -

+

Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.

diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.spec.tsx index cbd2cee..7a5fc02 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.spec.tsx @@ -55,4 +55,20 @@ describe("SwarmControlEditDialog", () => { notes: null, }); }); + it("swarm control edit counts queen cells with NumberStepper", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + 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 }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx index 7e5ccc5..0fe30e0 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx @@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea"; import { EnumSelect } from "@/components/form/enum-select"; import { ColonySelectField } from "@/components/colony-select-field"; import { Field } from "@/components/form/field"; +import { NumberStepper } from "@/components/form/number-stepper"; import { FormSection } from "@/components/form/form-section"; import { ToggleChip } from "@/components/form/toggle-chip"; import { @@ -115,15 +116,14 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {({ id, describedBy }) => ( - setQueenCellsFound(event.target.value)} + onChange={setQueenCellsFound} + unitLabel="Weiselzellen" + decrementLabel="Eine Weiselzelle weniger" + incrementLabel="Eine Weiselzelle mehr" /> )} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx index a7c0df0..cf61990 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx @@ -135,4 +135,15 @@ describe("VarroaEditDialog", () => { await openDialog(); 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 }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx index 1b78920..28cde73 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx @@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea"; import { EnumSelect } from "@/components/form/enum-select"; import { ColonySelectField } from "@/components/colony-select-field"; import { Field } from "@/components/form/field"; +import { NumberStepper } from "@/components/form/number-stepper"; import { FormSection } from "@/components/form/form-section"; import { Dialog, @@ -146,17 +147,18 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme {isNaturalFall ? ( ) : null} -
+ {/* One column: the stepper needs the width of the dialog on a phone. */} +
- {({ id }) => ( - ( + setMiteCount(event.target.value)} + onChange={setMiteCount} + unitLabel="Milben" + decrementLabel="Eine Milbe weniger" + incrementLabel="Eine Milbe mehr" /> )} diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.spec.tsx index ab3f38e..1b50823 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.spec.tsx @@ -1,5 +1,5 @@ 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CreateNoticeDialog } from "./create-notice-dialog"; @@ -97,4 +97,14 @@ describe("CreateNoticeDialog", () => { 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"); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx index edef04e..6d100ce 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx @@ -1,11 +1,12 @@ "use client"; 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 { Plus } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; 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 { ToggleChip } from "@/components/form/toggle-chip"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,12 @@ const KIND_HINT: Record = { TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.", }; +const KIND_OPTIONS: readonly ChoiceOption[] = NOTICE_KINDS.map((kind) => ({ + value: kind, + label: NOTICE_KIND_LABEL[kind], + description: KIND_HINT[kind], +})); + interface CreateNoticeDialogProps { /** Switched off from outside, e.g. offline; `describedBy` names the reason. */ disabled?: boolean; @@ -75,6 +82,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) { const queryClient = useQueryClient(); const attempt = useAttemptKey(); const { isOffline, hintId, describedBy } = useOnlineOnly(); + const kindLabelId = useId(); const [kind, setKind] = useState("REGISTRATION"); const [year, setYear] = useState(String(new Date().getFullYear())); const [alreadySent, setAlreadySent] = useState(false); @@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) { return (
-
- Art der Anzeige - {NOTICE_KINDS.map((entry) => ( - - ))} -
+
+

+ Art der Anzeige +

+ +
{kind === "TSK_REPORT" ? (
diff --git a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx index dd2125a..73ba75d 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.spec.tsx @@ -1,5 +1,5 @@ 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 { mockMedia, resetDocument } from "@/lib/appearance/test-helpers"; import { AppearanceSection } from "./appearance-section"; @@ -54,4 +54,18 @@ describe("AppearanceSection", () => { expect(document.documentElement.dataset.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(); + + 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.", + ); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx index b087e41..3878ea4 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/appearance-section.tsx @@ -6,17 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance"; 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[] = [ - { value: "light", label: "Hell – am besten bei Tageslicht" }, - { value: "dark", label: "Dunkel – für abends" }, - { value: "system", label: "Wie Gerät" }, + { value: "light", label: "Hell", description: "Am besten bei Tageslicht." }, + { value: "dark", label: "Dunkel", description: "Für abends." }, + { value: "system", label: "Wie Gerät", description: "Folgt der Einstellung des Geräts." }, ]; const CONTRAST_OPTIONS: readonly ChoiceOption[] = [ - { value: "normal", label: "Normal" }, - { value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" }, + { value: "normal", label: "Normal", description: "Für drinnen und Schatten." }, + { value: "sun", label: "Sonne", description: "Maximaler Kontrast, größere Schrift, für draußen." }, ]; const MOTION_LABELS: Record = { full: "Voll", subtle: "Dezent", off: "Aus" }; @@ -28,11 +26,12 @@ const MOTION_DESCRIPTIONS: Record = { const MOTION_OPTIONS: readonly ChoiceOption[] = MOTION_LEVELS.map((value) => ({ value, label: MOTION_LABELS[value], + description: MOTION_DESCRIPTIONS[value], })); export function AppearanceSection() { 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 ( @@ -53,7 +52,7 @@ export function AppearanceSection() { value={theme} onChange={setTheme} labelledBy={ids.theme} - optionClassName={OPTION_CLASS} + appearance="cards" />
@@ -65,7 +64,7 @@ export function AppearanceSection() { value={contrast} onChange={setContrast} labelledBy={ids.contrast} - optionClassName={OPTION_CLASS} + appearance="cards" />
@@ -77,13 +76,8 @@ export function AppearanceSection() { value={motion} onChange={setMotion} labelledBy={ids.motion} - describedBy={ids.motionHint} - className="grid-cols-3" - optionClassName="px-3 py-2" + appearance="cards" /> -

- {MOTION_DESCRIPTIONS[motion]} -

Gilt nur für dieses Gerät.

diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.spec.tsx new file mode 100644 index 0000000..43d0dfa --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.spec.tsx @@ -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( + , + ); + 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 }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx index e8636c7..0d84294 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx @@ -1,9 +1,11 @@ "use client"; +import { useId } from "react"; import { Bold, Copy, Italic, Trash2 } from "lucide-react"; import { LabelElement, LabelFontFamily, + LabelLineOrientation, LabelTextAlign, PLACEHOLDER_KEYS, PLACEHOLDER_LABELS, @@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { EnumSelect } from "@/components/form/enum-select"; +import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; const FONT_FAMILY_LABELS: Record = { sans: "Serifenlos", @@ -25,6 +28,11 @@ const ALIGN_LABELS: Record = { right: "Rechts", }; +const ORIENTATION_OPTIONS = choiceOptions({ + horizontal: "Waagerecht", + vertical: "Senkrecht", +}); + interface ElementPropertiesPanelProps { element: LabelElement; onChange: (patch: Partial) => void; @@ -208,17 +216,21 @@ function TextFields({ type="button" variant={element.bold ? "default" : "outline"} size="icon-sm" + pressed={element.bold} + aria-label="Fett" onClick={() => onChange({ bold: !element.bold })} > - + ; onChange: (patch: Partial) => void; }) { + const orientationLabelId = useId(); return ( <>
@@ -274,25 +287,17 @@ function LineFields({ onChange={(event) => onChange({ color: event.target.value })} />
-
- - +
+

+ Ausrichtung der Linie +

+ onChange({ orientation })} + labelledBy={orientationLabelId} + className="grid-cols-2" + />
); diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx index dc7f593..a9ab6d6 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx @@ -90,4 +90,17 @@ describe("NewSwarmControlCheckPage", () => { expect(screen.getByLabelText(/^Datum/)).toHaveFocus(); 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 }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx index 00656bb..5076500 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx @@ -20,6 +20,7 @@ import { FormBackLink } from "@/components/form/form-back-link"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { NumberStepper } from "@/components/form/number-stepper"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { LivingColonySelectField } from "@/components/colony-select-field"; 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." > {({ id, describedBy }) => ( - - set("queenCellsFound", event.target.value) - } + onChange={(count) => set("queenCellsFound", count)} + unitLabel="Weiselzellen" + decrementLabel="Eine Weiselzelle weniger" + incrementLabel="Eine Weiselzelle mehr" /> )} diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx index a3a222a..303446b 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx @@ -131,6 +131,15 @@ describe("NewVarroaMeasurementPage", () => { 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", () => { renderPage(); for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) { diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx index db29d46..e4b0fd0 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx @@ -34,6 +34,7 @@ import { FormBackLink } from "@/components/form/form-back-link"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { NumberStepper } from "@/components/form/number-stepper"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; import { CaptureColonyField } from "@/components/form/capture-colony-field"; @@ -332,15 +333,14 @@ export default function NewVarroaMeasurementPage() { } > {({ id, describedBy }) => ( - set("miteCount", event.target.value)} + onChange={(count) => set("miteCount", count)} + unitLabel="Milben" + decrementLabel="Eine Milbe weniger" + incrementLabel="Eine Milbe mehr" /> )} diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx index daa38c3..cc8f3c9 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx @@ -107,7 +107,7 @@ describe("WinterCheckDialog", () => { it("records a lost colony with its loss cause", async () => { 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(); const cause = screen.getByRole("combobox", { name: "Verlustursache" }); expect(cause).toHaveTextContent("Ursache wählen"); @@ -130,4 +130,32 @@ describe("WinterCheckDialog", () => { const [input] = vi.mocked(createWinterCheck).mock.calls[0]; 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(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx index 000436e..c471e70 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { FormEvent, useState } from "react"; -import { Check } from "lucide-react"; +import { FormEvent, useId, useState } from "react"; +import { CircleX, Sprout, TriangleAlert } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { ApiError } from "@/lib/auth/api-client"; @@ -22,9 +22,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { emptyToNull } from "@/lib/forms/empty-to-null"; @@ -42,7 +43,30 @@ function parseDecimal(value: string): number { 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[] = [ + { + 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({ target, @@ -67,6 +91,7 @@ export function WinterCheckDialog({ const [markDeceased, setMarkDeceased] = useState(target.isLiving); const [notes, setNotes] = useState(existing?.notes ?? ""); const [idempotencyKey] = useState(newIdempotencyKey); + const outcomeLabelId = useId(); const isLost = outcome === "LOST"; const missing = isSpring && (!outcome || (isLost && !lossCause)); @@ -79,7 +104,7 @@ export function WinterCheckDialog({ winterYear: target.winterYear, date: toApiDate(date), // 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. feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal), queenPresent, @@ -123,24 +148,18 @@ export function WinterCheckDialog({ {isSpring ? ( -
- Wie ist das Volk durch den Winter gekommen? -
- {OUTCOMES.map((value) => ( - - ))} -
-
+
+

+ Wie ist das Volk durch den Winter gekommen? +

+ +
) : null} {isLost ? ( @@ -184,16 +203,18 @@ export function WinterCheckDialog({
{!isLost ? ( -
+
- setOccupiedSeams(event.target.value)} + onChange={setOccupiedSeams} + max={MAX_SEAMS} + unitLabel="Gassen" + decrementLabel="Eine besetzte Wabengasse weniger" + incrementLabel="Eine besetzte Wabengasse mehr" />

Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres diff --git a/ApiCore-Frontend/src/components/form/choice-group.spec.tsx b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx index f5a458a..de9bdf7 100644 --- a/ApiCore-Frontend/src/components/form/choice-group.spec.tsx +++ b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { useState } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { Sun } from "lucide-react"; import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group"; type Feed = "ausreichend" | "niedrig" | "kritisch"; @@ -230,3 +231,108 @@ describe("choiceOptions", () => { } }); }); + +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(); + + 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(); + + 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( + 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( + 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( + , + ); + + 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"); + }); +}); diff --git a/ApiCore-Frontend/src/components/form/choice-group.tsx b/ApiCore-Frontend/src/components/form/choice-group.tsx index acbd755..68853ec 100644 --- a/ApiCore-Frontend/src/components/form/choice-group.tsx +++ b/ApiCore-Frontend/src/components/form/choice-group.tsx @@ -1,6 +1,7 @@ "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 { cn } from "@/lib/utils"; @@ -19,8 +20,18 @@ export interface ChoiceOption { selectedClassName?: string; /** Shown but not choosable; arrow keys skip it. */ 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. */ function optionKey(value: ChoiceValue): string { return `${value === null ? "null" : typeof value}:${String(value)}`; @@ -52,6 +63,7 @@ interface ChoiceGroupProps { className?: string; /** Extra classes for every option button, e.g. a larger font. */ optionClassName?: string; + appearance?: ChoiceAppearance; } const KEY_STEPS: Record = { @@ -69,6 +81,72 @@ function targetIndex(key: string, current: number, count: number): number | null return (current + step + count) % count; } +const OPTION_CLASS: Record = { + 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({ + option, + id, + mark, + appearance, + isMuted, +}: { + option: ChoiceOption; + 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 ? ( + <> + {" "} + {option.srSuffix} + + ) : null} + + ); + return ( + <> + {MarkIcon ? ( +

- setCounted(event.target.value)} + onChange={setCounted} /> {difference !== 0 ? (

diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx index 236e636..8a52852 100644 --- a/ApiCore-Frontend/src/components/ui/button.tsx +++ b/ApiCore-Frontend/src/components/ui/button.tsx @@ -9,7 +9,7 @@ const HIT_AREA = "relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none motion-full:active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -49,15 +49,25 @@ const buttonVariants = cva( } ) +interface ButtonProps extends ButtonPrimitive.Props, VariantProps { + /** + * A toolbar toggle (bold, italic, a list view): sets aria-pressed. Not for a choice between + * options - that is a ChoiceGroup (spec 10.2). + */ + pressed?: boolean +} + function Button({ className, variant = "default", size = "default", + pressed, ...props -}: ButtonPrimitive.Props & VariantProps) { +}: ButtonProps) { return ( diff --git a/ApiCore-Frontend/src/components/ui/ui-sizes.spec.tsx b/ApiCore-Frontend/src/components/ui/ui-sizes.spec.tsx index 57eacd0..894bfe1 100644 --- a/ApiCore-Frontend/src/components/ui/ui-sizes.spec.tsx +++ b/ApiCore-Frontend/src/components/ui/ui-sizes.spec.tsx @@ -78,4 +78,26 @@ describe("ui sizes, type and focus (spec 4.5, 4.6)", () => { expect(has(classes, "aria-invalid:ring-destructive")).toBe(true); expect(classes).not.toMatch(/ring-destructive\/20/); }); + it("Button pressed sets aria-pressed", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true"); + rerender( + , + ); + expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "false"); + rerender(); + expect(screen.getByRole("button", { name: "Fett" })).not.toHaveAttribute("aria-pressed"); + }); + it("the press nudge of a button only moves with the motion level full", () => { + render(); + const classes = screen.getByRole("button", { name: "Los" }).className; + expect(has(classes, "motion-full:active:not-aria-[haspopup]:translate-y-px")).toBe(true); + expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:translate-y-px/); + }); }); diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts index ee59cd3..d52c1bd 100644 --- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts +++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts @@ -81,6 +81,38 @@ const RULES: GuardRule[] = [ scope: "src/**/*.{ts,tsx}", allow: [], }, + // Task A6: one radio group for the whole app (spec 10.2, 10.7). + { + id: "no role radio, radiogroup or type radio outside choice-group.tsx", + // JSX (role="radio", type={"radio"}) and object or spread form (role: "radio"); a CSS + // attribute selector ([role="radio"]) only finds ChoiceGroup radios and is no own build. + pattern: /(? = { @@ -161,6 +193,21 @@ describe("baukasten guard (spec 10.7)", () => { expect(ownOutline.test('"p-3 focus:outline-2 focus:outline-ring"')).toBe(true); expect(ownOutline.test('"focus-visible:outline-2"')).toBe(true); expect(ownOutline.test('"outline-hidden focus:bg-accent"')).toBe(false); + const radio = rule("no role radio, radiogroup or type radio outside choice-group.tsx").pattern; + expect(radio.test('')).toBe(true); + expect(radio.test('

')).toBe(true); + expect(radio.test("