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]>
This commit is contained in:
1 parent
6ac9eef93b
commit
208e9baaf0
30 files changed
+807
-196
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ServerGaps | null>(null);
|
||||
const [printError, setPrintError] = 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
|
||||
: 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({
|
||||
<Label htmlFor="print-start-position">
|
||||
Startposition auf dem Bogen (1 = oben links)
|
||||
</Label>
|
||||
<Input
|
||||
<NumberStepper
|
||||
id="print-start-position"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
describedBy={startHintId}
|
||||
value={startPositionText}
|
||||
onChange={setStartPositionText}
|
||||
min={1}
|
||||
max={slotsPerSheet}
|
||||
value={startPosition}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<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 { 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 })
|
||||
<FormSection step={2} title="Befund">
|
||||
<Field label="Weiselzellen gefunden" hint="Anzahl">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
describedBy={describedBy}
|
||||
value={queenCellsFound}
|
||||
onChange={(event) => setQueenCellsFound(event.target.value)}
|
||||
onChange={setQueenCellsFound}
|
||||
unitLabel="Weiselzellen"
|
||||
decrementLabel="Eine Weiselzelle weniger"
|
||||
incrementLabel="Eine Weiselzelle mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
|
||||
) : 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">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
value={miteCount}
|
||||
onChange={(event) => setMiteCount(event.target.value)}
|
||||
onChange={setMiteCount}
|
||||
unitLabel="Milben"
|
||||
decrementLabel="Eine Milbe weniger"
|
||||
incrementLabel="Eine Milbe mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AuthorityNoticeKind, string> = {
|
||||
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 {
|
||||
/** 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<AuthorityNoticeKind>("REGISTRATION");
|
||||
const [year, setYear] = useState(String(new Date().getFullYear()));
|
||||
const [alreadySent, setAlreadySent] = useState(false);
|
||||
@@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
<label
|
||||
key={entry}
|
||||
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"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="notice-kind"
|
||||
value={entry}
|
||||
checked={kind === entry}
|
||||
onChange={() => setKind(entry)}
|
||||
aria-labelledby={`notice-kind-${entry}`}
|
||||
aria-describedby={`notice-kind-hint-${entry}`}
|
||||
className="mt-1 size-4"
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={kindLabelId} className="text-base font-medium">
|
||||
Art der Anzeige
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={KIND_OPTIONS}
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
labelledBy={kindLabelId}
|
||||
appearance="cards"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{kind === "TSK_REPORT" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -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(<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 { 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>[] = [
|
||||
{ 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<ContrastChoice>[] = [
|
||||
{ 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<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) => ({
|
||||
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 (
|
||||
<Card>
|
||||
@@ -53,7 +52,7 @@ export function AppearanceSection() {
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
labelledBy={ids.theme}
|
||||
optionClassName={OPTION_CLASS}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -65,7 +64,7 @@ export function AppearanceSection() {
|
||||
value={contrast}
|
||||
onChange={setContrast}
|
||||
labelledBy={ids.contrast}
|
||||
optionClassName={OPTION_CLASS}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -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"
|
||||
/>
|
||||
<p id={ids.motionHint} className="text-sm text-muted-foreground">
|
||||
{MOTION_DESCRIPTIONS[motion]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
|
||||
</CardContent>
|
||||
|
||||
+63
@@ -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 });
|
||||
});
|
||||
});
|
||||
+26
-21
@@ -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<LabelFontFamily, string> = {
|
||||
sans: "Serifenlos",
|
||||
@@ -25,6 +28,11 @@ const ALIGN_LABELS: Record<LabelTextAlign, string> = {
|
||||
right: "Rechts",
|
||||
};
|
||||
|
||||
const ORIENTATION_OPTIONS = choiceOptions<LabelLineOrientation>({
|
||||
horizontal: "Waagerecht",
|
||||
vertical: "Senkrecht",
|
||||
});
|
||||
|
||||
interface ElementPropertiesPanelProps {
|
||||
element: LabelElement;
|
||||
onChange: (patch: Partial<LabelElement>) => 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 })}
|
||||
>
|
||||
<Bold className="size-3.5" />
|
||||
<Bold aria-hidden className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.italic ? "default" : "outline"}
|
||||
size="icon-sm"
|
||||
pressed={element.italic}
|
||||
aria-label="Kursiv"
|
||||
onClick={() => onChange({ italic: !element.italic })}
|
||||
>
|
||||
<Italic className="size-3.5" />
|
||||
<Italic aria-hidden className="size-3.5" />
|
||||
</Button>
|
||||
<EnumSelect
|
||||
id="element-text-align"
|
||||
@@ -240,6 +252,7 @@ function LineFields({
|
||||
element: Extract<LabelElement, { type: "line" }>;
|
||||
onChange: (patch: Partial<LabelElement>) => void;
|
||||
}) {
|
||||
const orientationLabelId = useId();
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
@@ -274,25 +287,17 @@ function LineFields({
|
||||
onChange={(event) => onChange({ color: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.orientation === "horizontal" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onChange({ orientation: "horizontal" })}
|
||||
>
|
||||
Waagerecht
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.orientation === "vertical" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onChange({ orientation: "vertical" })}
|
||||
>
|
||||
Senkrecht
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={orientationLabelId} className="text-sm leading-none font-medium">
|
||||
Ausrichtung der Linie
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={ORIENTATION_OPTIONS}
|
||||
value={element.orientation}
|
||||
onChange={(orientation) => onChange({ orientation })}
|
||||
labelledBy={orientationLabelId}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
value={values.queenCellsFound}
|
||||
onChange={(event) =>
|
||||
set("queenCellsFound", event.target.value)
|
||||
}
|
||||
onChange={(count) => set("queenCellsFound", count)}
|
||||
unitLabel="Weiselzellen"
|
||||
decrementLabel="Eine Weiselzelle weniger"
|
||||
incrementLabel="Eine Weiselzelle mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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 }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<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({
|
||||
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({
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
{isSpring ? (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{OUTCOMES.map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
|
||||
aria-pressed={outcome === value}
|
||||
onClick={() => setOutcome(value)}
|
||||
>
|
||||
{outcome === value ? <Check aria-hidden /> : null}
|
||||
{WINTER_OUTCOME_LABELS[value]}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={outcomeLabelId} className="text-base font-medium">
|
||||
Wie ist das Volk durch den Winter gekommen?
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={OUTCOME_OPTIONS}
|
||||
value={outcome}
|
||||
onChange={setOutcome}
|
||||
labelledBy={outcomeLabelId}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{isLost ? (
|
||||
@@ -184,16 +203,18 @@ export function WinterCheckDialog({
|
||||
</div>
|
||||
|
||||
{!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">
|
||||
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
|
||||
<Input
|
||||
<NumberStepper
|
||||
id="winter-seams"
|
||||
inputMode="numeric"
|
||||
className="h-11"
|
||||
aria-describedby="winter-seams-hint"
|
||||
describedBy="winter-seams-hint"
|
||||
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">
|
||||
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres
|
||||
|
||||
@@ -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(<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";
|
||||
|
||||
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<T extends ChoiceValue> {
|
||||
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<T extends ChoiceValue> {
|
||||
className?: string;
|
||||
/** Extra classes for every option button, e.g. a larger font. */
|
||||
optionClassName?: string;
|
||||
appearance?: ChoiceAppearance;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
* gloves, and every choice stays visible. A `null` option stands for "not
|
||||
@@ -88,7 +166,9 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
clearValue,
|
||||
className,
|
||||
optionClassName,
|
||||
appearance = "buttons",
|
||||
}: ChoiceGroupProps<T>) {
|
||||
const baseId = useId();
|
||||
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const checkedIndex = options.findIndex((option) => option.value === value);
|
||||
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
|
||||
@@ -119,6 +199,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
{options.map((option, index) => {
|
||||
const selected = index === checkedIndex;
|
||||
const isUnanswered = option.value === null;
|
||||
const optionId = `${baseId}-${index}`;
|
||||
const hasDescription = appearance === "cards" && option.description !== undefined;
|
||||
return (
|
||||
<button
|
||||
key={optionKey(option.value)}
|
||||
@@ -128,6 +210,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-labelledby={hasDescription ? `${optionId}-label` : undefined}
|
||||
aria-describedby={hasDescription ? `${optionId}-description` : undefined}
|
||||
disabled={option.disabled}
|
||||
tabIndex={index === tabStop ? 0 : -1}
|
||||
onClick={() =>
|
||||
@@ -135,24 +219,25 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-2 text-base motion-safe:transition-colors",
|
||||
!selected && "border-border bg-background hover:bg-muted",
|
||||
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background",
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
"border-foreground bg-primary font-medium text-primary-foreground",
|
||||
OPTION_CLASS[appearance],
|
||||
!selected && "border border-input bg-card hover:bg-muted",
|
||||
option.disabled && "cursor-not-allowed opacity-50 hover:bg-card",
|
||||
selected && "border-2 font-semibold",
|
||||
selected && isUnanswered && "border-dashed bg-muted text-foreground",
|
||||
selected && !isUnanswered && "bg-primary text-primary-foreground",
|
||||
selected && option.selectedClassName,
|
||||
// After the tone of a scale step: the frame of a chosen option is always the ink.
|
||||
selected && "border-foreground",
|
||||
optionClassName,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.srSuffix ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="sr-only">{option.srSuffix}</span>
|
||||
</>
|
||||
) : null}
|
||||
<OptionContent
|
||||
option={option}
|
||||
id={optionId}
|
||||
mark={selected ? (isUnanswered ? "dash" : "tick") : null}
|
||||
appearance={appearance}
|
||||
isMuted={!selected}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -91,4 +91,17 @@ describe("RatingScale", () => {
|
||||
expect(screen.getByText("1 = ruhig")).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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,15 @@ describe("HiveCard", () => {
|
||||
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 () => {
|
||||
renderCard();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
|
||||
|
||||
@@ -384,20 +384,23 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
||||
Umbauen
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
{/* A toolbar toggle of the kit; the glass classes win over the ghost look (spec 10.2). */}
|
||||
<Button
|
||||
type="button"
|
||||
aria-pressed={showOutline}
|
||||
variant="ghost"
|
||||
size="touch"
|
||||
pressed={showOutline}
|
||||
aria-controls={outlineId}
|
||||
aria-label="Aufbau als Liste"
|
||||
onClick={() => {
|
||||
setShowOutline(!showOutline);
|
||||
onShowOutlineChange?.(!showOutline);
|
||||
}}
|
||||
className={GLASS_BUTTON}
|
||||
className={`${GLASS_BUTTON} hover:text-holo-label-fg`}
|
||||
>
|
||||
<ListTree aria-hidden />
|
||||
Liste
|
||||
</button>
|
||||
</Button>
|
||||
<button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}>
|
||||
<History aria-hidden />
|
||||
Verlauf
|
||||
|
||||
@@ -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 () => {
|
||||
renderSheet();
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"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 { Flower2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -185,29 +186,18 @@ function ScopeChoice({
|
||||
scope: Scope;
|
||||
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: "apiary", label: `Nur ${apiaryName}` },
|
||||
];
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-sm font-medium">Gilt für</legend>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base focus-ring-within"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="phenology-scope"
|
||||
className="size-5 accent-primary"
|
||||
checked={scope === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={labelId} className="text-base font-medium">
|
||||
Gilt für
|
||||
</p>
|
||||
<ChoiceGroup options={options} value={scope} onChange={onChange} labelledBy={labelId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { StockCard } from "./stock-card";
|
||||
import { fetchStock } from "@/lib/stock/api";
|
||||
import type { BatchStock } from "@/lib/stock/types";
|
||||
import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
|
||||
import type { BatchStock, StockBatchPath } from "@/lib/stock/types";
|
||||
|
||||
vi.mock("@/lib/stock/api", () => ({
|
||||
fetchStock: vi.fn(),
|
||||
@@ -11,17 +12,17 @@ vi.mock("@/lib/stock/api", () => ({
|
||||
correctStock: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderCard(canBook = true) {
|
||||
function renderCard(canBook = true, unit = "Gläser", path: StockBatchPath = "bottling-batches") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<StockCard
|
||||
path="bottling-batches"
|
||||
path={path}
|
||||
id="b1"
|
||||
initialLabel="Abgefüllt"
|
||||
unit="Gläser"
|
||||
unit={unit}
|
||||
canBook={canBook}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
@@ -107,4 +108,61 @@ describe("StockCard", () => {
|
||||
screen.queryByRole("button", { name: /Inventur/ }),
|
||||
).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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
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({
|
||||
path,
|
||||
id,
|
||||
@@ -227,6 +278,9 @@ function WithdrawDialog({
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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({
|
||||
mutationFn: () =>
|
||||
@@ -277,16 +331,14 @@ function WithdrawDialog({
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
|
||||
<Input
|
||||
<QuantityField
|
||||
id="withdraw-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
path={path}
|
||||
unit={unit}
|
||||
min={1}
|
||||
max={remaining}
|
||||
step={1}
|
||||
required
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
onChange={setQuantity}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -311,7 +363,7 @@ function WithdrawDialog({
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
<Button type="submit" disabled={mutation.isPending || !canWithdraw}>
|
||||
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -391,15 +443,13 @@ function CorrectDialog({
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="correct-counted">Gezählt ({unit})</Label>
|
||||
<Input
|
||||
<QuantityField
|
||||
id="correct-counted"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
path={path}
|
||||
unit={unit}
|
||||
min={0}
|
||||
step={1}
|
||||
required
|
||||
value={counted}
|
||||
onChange={(event) => setCounted(event.target.value)}
|
||||
onChange={setCounted}
|
||||
/>
|
||||
{difference !== 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -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<typeof buttonVariants> {
|
||||
/**
|
||||
* 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<typeof buttonVariants>) {
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
aria-pressed={pressed}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
<Button pressed aria-label="Fett">
|
||||
B
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true");
|
||||
rerender(
|
||||
<Button pressed={false} aria-label="Fett">
|
||||
B
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "false");
|
||||
rerender(<Button aria-label="Fett">B</Button>);
|
||||
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(<Button>Los</Button>);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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: /(?<![\w[-])(?:role|type)\s*[=:]\s*\{?\s*["'`]radio(?:group)?["'`]/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
allow: [
|
||||
{
|
||||
file: "src/components/form/choice-group.tsx",
|
||||
reason: "ChoiceGroup is the radio group of the kit: role radiogroup with one role radio per option.",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Task A6: a toggle button sets aria-pressed through Button `pressed` (spec 10.2, 10.7).
|
||||
{
|
||||
id: "aria-pressed only through Button pressed",
|
||||
// JSX attribute or object/spread key ("aria-pressed": x); not the Tailwind variant aria-pressed:.
|
||||
pattern: /aria-pressed\s*=|["'`]aria-pressed["'`]\s*:/,
|
||||
scope: "src/**/*.{ts,tsx}",
|
||||
allow: [
|
||||
{
|
||||
file: "src/components/ui/button.tsx",
|
||||
reason: "Button turns its `pressed` prop into aria-pressed (toolbar toggles, not a radio group).",
|
||||
},
|
||||
{
|
||||
file: "src/app/(app)/calendar/page.tsx",
|
||||
reason:
|
||||
"The month grid is a grid of day buttons; the chosen day is pressed. It keeps the global focus and the selection ring (spec 10.2).",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_FILE: Record<GuardScope, RegExp> = {
|
||||
@@ -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('<input type="radio" name="x" />')).toBe(true);
|
||||
expect(radio.test('<div role="radiogroup">')).toBe(true);
|
||||
expect(radio.test("<button role={'radio'}>")).toBe(true);
|
||||
expect(radio.test('<DropdownMenuRadioItem value="a">')).toBe(false);
|
||||
const pressed = rule("aria-pressed only through Button pressed").pattern;
|
||||
expect(pressed.test("<button aria-pressed={on}>")).toBe(true);
|
||||
expect(pressed.test('"aria-pressed:bg-holo-edge/25"')).toBe(false);
|
||||
expect(pressed.test('{ "aria-pressed": on }')).toBe(true);
|
||||
expect(pressed.test("{ 'aria-pressed': on }")).toBe(true);
|
||||
expect(radio.test('{ role: "radio" }')).toBe(true);
|
||||
expect(radio.test("{ role: 'radiogroup' }")).toBe(true);
|
||||
expect(radio.test('{ type: "radio" }')).toBe(true);
|
||||
expect(radio.test('{ role: "radioactive" }')).toBe(false);
|
||||
expect(radio.test(`querySelector('[role="radio"][tabindex="0"]')`)).toBe(false);
|
||||
expect(rule("no text-white").pattern.test('"text-white/80"')).toBe(true);
|
||||
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user