feat(t5): ein Checkbox-Baustein für alle 35 Stellen (T5a A7)

- ui/checkbox.tsx und form/checkbox-field.tsx: ganze Zeile, Kachel,
  Aufgabe oder Sheet-Zeile ist der Knopf (Base UI), Haken bzw. Strich für
  teilweise; role="switch" mit Daumen und Haken. ToggleChip und SwitchRow
  entfernt.
- 14 Kacheln, 11 native Checkboxen, 3 Schalter-Zeilen, 6 Base-UI-Stellen
  und die Heute-Aufgaben umgestellt; Werte ans Backend unverändert.
- Enter in einer Checkbox schickt kein Formular ab und schaltet nicht
  (Leertaste schaltet); Nachbarn mit mindestens 4 px Abstand; gesperrte
  Felder mit sichtbarem Grund; gesperrte Kacheln ganz gedimmt.
- Baukasten-Wächter: keine eigenen Checkboxen/Schalter, auch bedingte
  role-Werte und setAttribute; Zeilenlisten mit Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 19:30:21 +02:00
1 parent 11bccbeb50
commit f307e7da66
40 files changed
+1179 -409

No files matched your search

@@ -14,7 +14,7 @@ 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 { CheckboxField } from "@/components/form/checkbox-field";
import {
Dialog,
DialogContent,
@@ -127,10 +127,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
/>
)}
</Field>
<ToggleChip
<CheckboxField
appearance="tile"
label="Weiselzellen bereits verdeckelt (akut)"
checked={queenCellsCapped}
onChange={setQueenCellsCapped}
onCheckedChange={setQueenCellsCapped}
/>
<Field label="Schwarmstimmung" required>
{({ id }) => (
@@ -8,7 +8,7 @@ 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 { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -165,7 +165,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
</div>
) : null}
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
<CheckboxField appearance="tile" label="Bereits angezeigt" checked={alreadySent} onCheckedChange={setAlreadySent} />
{alreadySent ? (
<div className="flex flex-col gap-2">
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
@@ -20,7 +20,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -207,11 +207,12 @@ export function CreateRestrictionDialog() {
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
{apiaries.map((apiary) => (
<ToggleChip
<CheckboxField
appearance="tile"
key={apiary.id}
label={apiary.name}
checked={values.apiaryIds.includes(apiary.id)}
onChange={(checked) => toggleApiary(apiary.id, checked)}
onCheckedChange={(checked) => toggleApiary(apiary.id, checked)}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { settingsFixture } from "@/lib/compliance/notice-fixtures";
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
const ASSUMPTION = "Annahme – bitte prüfen";
const READ_ONLY_TEXT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
@@ -250,6 +251,26 @@ describe("ComplianceSettingsPage", () => {
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
});
it("read-only tiles say why they are off (hint text, not grey only)", async () => {
permissions = ["compliance:read"];
renderPage();
const trigger = await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
const counted = screen.getByRole("checkbox", { name: "Ableger" });
// Both trigger tiles and the counted colony types show the reason next to them.
expect(screen.getAllByText(READ_ONLY_TEXT)).toHaveLength(3);
for (const hint of screen.getAllByText(READ_ONLY_TEXT)) expect(hint).toBeVisible();
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(READ_ONLY_TEXT));
expect(counted).toHaveAccessibleDescription(READ_ONLY_TEXT);
});
it("editable tiles show no read-only hint", async () => {
renderPage();
await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
expect(screen.queryByText(READ_ONLY_TEXT)).not.toBeInTheDocument();
});
it("says so without the read permission and loads nothing", () => {
permissions = [];
renderPage();
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -31,6 +31,8 @@ import {
} from "./digit-rules-field";
const ASSUMPTION = "Annahme – bitte prüfen";
/** Why a read-only tile is off (spec 10.3: hint text, not grey only). */
const READ_ONLY_HINT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
/** Input id per form field that can carry a validation error, in page order. */
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
@@ -277,22 +279,26 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
<div className="flex flex-col gap-1">
<ToggleChip
<CheckboxField
appearance="tile"
label="Bei geänderter Völkerzahl"
checked={values.noticeOnColonyCountChange}
disabled={readOnly}
disabledHint={READ_ONLY_HINT}
describedBy="note-trigger-count"
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
onCheckedChange={(checked) => set("noticeOnColonyCountChange", checked)}
/>
<AssumptionNote id="note-trigger-count" />
</div>
<div className="flex flex-col gap-1">
<ToggleChip
<CheckboxField
appearance="tile"
label="Bei geändertem Stand"
checked={values.noticeOnApiaryChange}
disabled={readOnly}
disabledHint={READ_ONLY_HINT}
describedBy="note-trigger-apiary"
onChange={(checked) => set("noticeOnApiaryChange", checked)}
onCheckedChange={(checked) => set("noticeOnApiaryChange", checked)}
/>
<AssumptionNote id="note-trigger-apiary" />
</div>
@@ -361,15 +367,22 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
<div className="flex flex-wrap gap-2">
{COLONY_TYPES.map((type) => (
<ToggleChip
<CheckboxField
appearance="tile"
key={type}
label={COLONY_TYPE_LABELS[type]}
checked={values.counted.includes(type)}
disabled={readOnly}
onChange={(checked) => toggleCounted(type, checked)}
describedBy={readOnly ? "note-counted-read-only" : undefined}
onCheckedChange={(checked) => toggleCounted(type, checked)}
/>
))}
</div>
{readOnly ? (
<p id="note-counted-read-only" className="text-sm font-medium text-muted-foreground">
{READ_ONLY_HINT}
</p>
) : null}
</fieldset>
<DigitRulesField
rows={values.rules}
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import DashboardPage from "./page";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { fetchTasks } from "@/lib/tasks/api";
import { completeTask, fetchTasks } from "@/lib/tasks/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
@@ -229,6 +230,27 @@ describe("DashboardPage – Heute", () => {
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
});
it("ticking a task on the Base UI honey-check cell completes it (Space works too)", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
// The first task is the next step; the second one is a row with the tick cell.
vi.mocked(fetchTasks).mockResolvedValue([
overdueTask(),
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
]);
vi.mocked(completeTask).mockResolvedValue({ ...overdueTask(), status: "DONE" } as never);
renderPage();
const cell = await screen.findByRole("checkbox", { name: "„Zweite Aufgabe“ als erledigt markieren" });
expect(cell).toHaveAttribute("data-slot", "checkbox");
expect(cell).toHaveClass("honey-check");
expect(cell).not.toBeChecked();
cell.focus();
await userEvent.keyboard(" ");
expect(cell).toBeChecked();
await waitFor(() => expect(completeTask).toHaveBeenCalledWith("t2"), { timeout: 3000 });
});
it("gives the dashboard text links a 44 px target", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
@@ -24,7 +24,7 @@ import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
@@ -205,13 +205,14 @@ export default function NewExtractionPage() {
className="flex flex-col gap-2"
>
{harvests?.map((harvest) => (
<ToggleChip
<CheckboxField
appearance="tile"
key={harvest.id}
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
.map((entry) => entry.colony.code)
.join(", ")}`}
checked={values.harvestIds.includes(harvest.id)}
onChange={(checked) =>
onCheckedChange={(checked) =>
toggleHarvest(harvest.id, checked)
}
/>
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RecordSelect } from "@/components/form/record-select";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
@@ -177,11 +177,12 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
className="grid grid-cols-2 gap-2"
>
{selectableColonies.map((colony) => (
<ToggleChip
<CheckboxField
appearance="tile"
key={colony.id}
label={colony.code}
checked={colonyIds.includes(colony.id)}
onChange={(checked) => toggleColony(colony.id, checked)}
onCheckedChange={(checked) => toggleColony(colony.id, checked)}
/>
))}
</div>
@@ -25,7 +25,7 @@ import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
@@ -259,10 +259,11 @@ export default function NewHarvestPage() {
const withdrawal = withdrawalByColony.get(colony.id);
return (
<div key={colony.id} className="flex flex-col gap-1">
<ToggleChip
<CheckboxField
appearance="tile"
label={colonyOptionLabel(colony)}
checked={values.colonyIds.includes(colony.id)}
onChange={(checked) =>
onCheckedChange={(checked) =>
toggleColony(colony.id, checked)
}
/>
@@ -13,7 +13,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -219,10 +219,11 @@ function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
/>
)}
</Field>
<ToggleChip
<CheckboxField
appearance="tile"
label="Verschreibungspflichtig"
checked={values.prescriptionOnly}
onChange={(checked) => set("prescriptionOnly", checked)}
onCheckedChange={(checked) => set("prescriptionOnly", checked)}
/>
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
{({ id, describedBy: fieldDescribedBy }) => (
@@ -18,7 +18,7 @@ import {
validateQuietHours,
} from "@/lib/notification-settings/validation";
import { errorMessage } from "@/lib/forms/error-message";
import { SwitchRow } from "@/components/form/switch-row";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -104,17 +104,19 @@ function NotificationSettingsForm({
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<SwitchRow
<CheckboxField
role="switch"
label="Erinnerung je Aufgabe"
description="Eine Meldung, wenn eine Aufgabe fällig wird."
checked={draft.taskDueEnabled}
onChange={(checked) => change("taskDueEnabled", checked)}
onCheckedChange={(checked) => change("taskDueEnabled", checked)}
/>
<SwitchRow
<CheckboxField
role="switch"
label="Morgenübersicht"
description="Eine Meldung am Morgen mit allem, was heute ansteht."
checked={draft.digestEnabled}
onChange={(checked) => change("digestEnabled", checked)}
onCheckedChange={(checked) => change("digestEnabled", checked)}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
@@ -26,8 +26,7 @@ import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
import { StepUpDialog } from "@/components/auth/step-up-dialog";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { SwitchRow } from "@/components/form/switch-row";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { ErrorState } from "@/components/query-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -100,23 +99,26 @@ function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
<div className="flex flex-wrap gap-2">
{NOTIFICATION_CATEGORIES.map((category) => (
<ToggleChip
<CheckboxField
appearance="tile"
key={category}
label={NOTIFICATION_CATEGORY_LABELS[category]}
checked={settings.telegramCategories.includes(category)}
onChange={(checked) => toggleCategory(category, checked)}
onCheckedChange={(checked) => toggleCategory(category, checked)}
describedBy={isOffline ? hintId : undefined}
disabled={mutation.isPending || isOffline}
/>
))}
</div>
</fieldset>
<SwitchRow
<CheckboxField
role="switch"
label="Mit Details"
description={PRIVACY_NOTE}
checked={settings.telegramDetail}
disabled={mutation.isPending || isOffline}
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
describedBy={isOffline ? hintId : undefined}
onCheckedChange={(checked) => mutation.mutate({ telegramDetail: checked })}
/>
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
@@ -21,7 +21,7 @@ import {
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input";
@@ -280,11 +280,12 @@ function RuleCard({
))}
{!numberFormatRule ? (
<div className="flex flex-col gap-1">
<ToggleChip
<CheckboxField
appearance="tile"
label="Zusätzlich melden"
checked={notify}
describedBy={`notify-hint-${rule.id}`}
onChange={(checked) => {
onCheckedChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
@@ -13,7 +13,7 @@ import { CalendarSyncSettings } from "@/lib/calendar-sync/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -81,14 +81,12 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die
Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.
</p>
<div className="flex items-center gap-2">
<Checkbox
id="sync-enabled"
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
/>
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
</div>
<CheckboxField
id="sync-enabled"
label="Synchronisation aktiv"
checked={enabled}
onCheckedChange={setEnabled}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="sync-group-id">Kennung des Microsoft-365-Gruppenkalenders</Label>
<Input
@@ -103,19 +101,20 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
„Objekt-ID“. Bei Unklarheit die IT fragen.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Welche Einträge übertragen werden</p>
<div role="group" aria-labelledby="sync-types-label" className="flex flex-col gap-1">
<p id="sync-types-label" className="mb-1 text-sm font-medium">
Welche Einträge übertragen werden
</p>
{SYNC_TYPE_FIELDS.map((field) => (
<div key={field.key} className="flex items-center gap-2">
<Checkbox
id={`sync-${field.key}`}
checked={typeFlags[field.key]}
onCheckedChange={(checked) =>
setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true }))
}
/>
<Label htmlFor={`sync-${field.key}`}>{field.label}</Label>
</div>
<CheckboxField
key={field.key}
id={`sync-${field.key}`}
label={field.label}
checked={typeFlags[field.key]}
onCheckedChange={(checked) =>
setTypeFlags((prev) => ({ ...prev, [field.key]: checked }))
}
/>
))}
</div>
<Button type="submit" disabled={mutation.isPending}>
@@ -10,6 +10,7 @@ import {
WEBHOOK_EVENT_LABELS,
toggleWebhookEvent,
} from "@/lib/webhooks/labels";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -133,28 +134,21 @@ export function CreateWebhookDialog() {
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Ereignisse</Label>
<div className="flex flex-col gap-2">
<div role="group" aria-labelledby="webhook-events-label" className="flex flex-col gap-2">
<span id="webhook-events-label" className="text-sm font-medium">
Ereignisse
</span>
<div className="flex flex-col gap-1">
{WEBHOOK_EVENTS.map((webhookEvent) => (
<label
<CheckboxField
key={webhookEvent}
className="flex h-9 items-center gap-3 text-sm"
htmlFor={`webhook-event-${webhookEvent}`}
>
<input
id={`webhook-event-${webhookEvent}`}
type="checkbox"
className="size-4.5"
checked={events.includes(webhookEvent)}
onChange={(inputEvent) =>
setEvents((current) =>
toggleWebhookEvent(current, webhookEvent, inputEvent.target.checked),
)
}
/>
{WEBHOOK_EVENT_LABELS[webhookEvent]}
</label>
id={`webhook-event-${webhookEvent}`}
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
checked={events.includes(webhookEvent)}
onCheckedChange={(checked) =>
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
}
/>
))}
</div>
</div>
@@ -9,6 +9,7 @@ import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/t
import { TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -193,19 +194,13 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
/>
</div>
) : null}
<label
className="flex h-9 items-center gap-3 text-sm"
htmlFor="template-edit-active"
>
<input
id="template-edit-active"
type="checkbox"
className="size-4.5"
checked={active}
onChange={(event) => setActive(event.target.checked)}
/>
Aktiv – eine deaktivierte Vorlage erzeugt keine Aufgaben mehr
</label>
<CheckboxField
id="template-edit-active"
label="Aktiv"
description="Eine deaktivierte Vorlage erzeugt keine Aufgaben mehr."
checked={active}
onCheckedChange={setActive}
/>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -13,6 +13,7 @@ import {
WEBHOOK_EVENT_LABELS,
toggleWebhookEvent,
} from "@/lib/webhooks/labels";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -81,48 +82,31 @@ function EditWebhookForm({
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Ereignisse</Label>
<div className="flex flex-col gap-2">
<div role="group" aria-labelledby="webhook-edit-events-label" className="flex flex-col gap-2">
<span id="webhook-edit-events-label" className="text-sm font-medium">
Ereignisse
</span>
<div className="flex flex-col gap-1">
{WEBHOOK_EVENTS.map((webhookEvent) => (
<label
<CheckboxField
key={webhookEvent}
className="flex h-9 items-center gap-3 text-sm"
htmlFor={`webhook-edit-event-${webhookEvent}`}
>
<input
id={`webhook-edit-event-${webhookEvent}`}
type="checkbox"
className="size-4.5"
checked={events.includes(webhookEvent)}
onChange={(inputEvent) =>
setEvents((current) =>
toggleWebhookEvent(
current,
webhookEvent,
inputEvent.target.checked,
),
)
}
/>
{WEBHOOK_EVENT_LABELS[webhookEvent]}
</label>
id={`webhook-edit-event-${webhookEvent}`}
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
checked={events.includes(webhookEvent)}
onCheckedChange={(checked) =>
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
}
/>
))}
</div>
</div>
<label
className="flex h-9 items-center gap-3 text-sm"
htmlFor="webhook-edit-active"
>
<input
id="webhook-edit-active"
type="checkbox"
className="size-4.5"
checked={active}
onChange={(inputEvent) => setActive(inputEvent.target.checked)}
/>
Aktiv – bei deaktiviertem Webhook werden keine Ereignisse zugestellt
</label>
<CheckboxField
id="webhook-edit-active"
label="Aktiv"
description="Bei deaktiviertem Webhook werden keine Ereignisse zugestellt."
checked={active}
onCheckedChange={setActive}
/>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -2,7 +2,7 @@
import { FormEvent, ReactNode, useId, useMemo } from "react";
import { Field } from "@/components/form/field";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -118,10 +118,11 @@ export function RuleToggle({ trigger, draft, path, label, hint }: RuleToggleProp
const errorId = error ? `${baseId}-error` : undefined;
return (
<div className="flex flex-col gap-1">
<ToggleChip
<CheckboxField
appearance="tile"
label={label}
checked={draft.values[path] === true}
onChange={(checked) => draft.set(path, checked)}
onCheckedChange={(checked) => draft.set(path, checked)}
describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined}
/>
{hintText ? (
@@ -2,6 +2,7 @@
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -146,16 +147,13 @@ function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; on
</Field>
))}
{system ? (
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
<input
id="hive-system-active"
type="checkbox"
className="size-4.5"
checked={values.active}
onChange={(event) => setValues({ ...values, active: event.target.checked })}
/>
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
</label>
<CheckboxField
id="hive-system-active"
label="Aktiv"
description="Ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl."
checked={values.active}
onCheckedChange={(active) => setValues({ ...values, active })}
/>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
@@ -27,6 +27,7 @@ import {
} from "@/lib/label-templates/check";
import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -680,15 +681,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</p>
) : null}
</div>
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
<input
id="designer-default"
type="checkbox"
checked={isDefault}
onChange={(event) => setIsDefault(event.target.checked)}
/>
Als Standardvorlage verwenden
</label>
<CheckboxField
id="designer-default"
label="Als Standardvorlage verwenden"
checked={isDefault}
onCheckedChange={setIsDefault}
/>
</CardContent>
</Card>
@@ -786,22 +784,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
/>
</div>
</div>
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 focus-ring-within">
<input
type="checkbox"
className="mt-1 size-4 accent-[var(--primary)]"
checked={cropMarks}
onChange={(event) => setCropMarks(event.target.checked)}
/>
<span className="flex flex-col">
<span className="font-medium">Schnittmarken am Blattrand</span>
<span className="text-sm text-muted-foreground">
Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider.
Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die
Striche gedruckt werden.
</span>
</span>
</label>
<CheckboxField
label="Schnittmarken am Blattrand"
description="Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider. Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die Striche gedruckt werden."
checked={cropMarks}
onCheckedChange={setCropMarks}
/>
</CardContent>
</Card>
@@ -0,0 +1,48 @@
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { PermissionCatalogEntry } from "@/lib/roles/types";
import { PermissionMatrix } from "./permission-matrix";
function entry(key: string, labelDe: string, sortOrder: number): PermissionCatalogEntry {
return { key, labelDe, groupDe: "Völker", sortOrder } as PermissionCatalogEntry;
}
const CATALOG = [entry("colonies:read", "Ansehen", 1), entry("colonies:update", "Bearbeiten", 2)];
function Harness({ disabled }: { disabled?: boolean }) {
const [selected, setSelected] = useState(new Set(["colonies:read"]));
return <PermissionMatrix catalog={CATALOG} selected={selected} onChange={setSelected} disabled={disabled} />;
}
describe("PermissionMatrix", () => {
it("the group box is partly checked and checks the whole group", async () => {
render(<Harness />);
const group = screen.getByRole("checkbox", { name: "Völker" });
expect(group).toHaveAttribute("aria-checked", "mixed");
expect(group).toHaveAccessibleDescription("1 von 2 gewählt");
await userEvent.click(group);
expect(group).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Bearbeiten" })).toBeChecked();
});
it("neighbouring rows keep at least 4 px between them", () => {
render(<Harness />);
const group = screen.getByRole("checkbox", { name: "Völker" });
const entries = screen.getByRole("checkbox", { name: "Ansehen" }).parentElement as HTMLElement;
expect(group.parentElement?.className).toMatch(/(^|\s)gap-1(\s|$)/);
expect(entries.className).toMatch(/(^|\s)gap-y-1(\s|$)/);
});
it("disabled says why, visibly and on every box", () => {
render(<Harness disabled />);
const hint = screen.getByText(/Rechte lassen sich hier nicht ändern/);
expect(hint).toBeVisible();
for (const box of screen.getAllByRole("checkbox")) {
expect(box).toBeDisabled();
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
}
});
});
@@ -1,8 +1,7 @@
"use client";
import { useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useId, useMemo } from "react";
import { CheckboxField } from "@/components/form/checkbox-field";
import { PermissionCatalogEntry } from "@/lib/roles/types";
interface PermissionMatrixProps {
@@ -10,8 +9,12 @@ interface PermissionMatrixProps {
selected: Set<string>;
onChange: (next: Set<string>) => void;
disabled?: boolean;
/** Why the rights are off; shown above the matrix while disabled (not grey only). */
disabledHint?: string;
}
const DEFAULT_DISABLED_HINT = "Die Rechte lassen sich hier nicht ändern.";
function slugify(value: string): string {
return value
.toLowerCase()
@@ -41,8 +44,12 @@ export function PermissionMatrix({
selected,
onChange,
disabled,
disabledHint = DEFAULT_DISABLED_HINT,
}: PermissionMatrixProps) {
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
const hintId = useId();
// One visible reason for the whole matrix instead of a line under every box.
const describedBy = disabled ? hintId : undefined;
function toggleOne(key: string, checked: boolean) {
const next = new Set(selected);
@@ -68,43 +75,44 @@ export function PermissionMatrix({
return (
<div className="flex flex-col gap-4 rounded-xl border border-border/60 p-3">
{disabled ? (
<p id={hintId} className="text-sm font-medium text-muted-foreground">
{disabledHint}
</p>
) : null}
{groups.map((group) => {
const groupSelectedCount = group.entries.filter((entry) =>
selected.has(entry.key),
).length;
const allSelected = groupSelectedCount === group.entries.length;
const groupState =
groupSelectedCount === group.entries.length
? true
: groupSelectedCount > 0
? "indeterminate"
: false;
return (
<div key={group.groupDe} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id={`group-${slugify(group.groupDe)}`}
checked={allSelected}
disabled={disabled}
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
/>
<Label
htmlFor={`group-${slugify(group.groupDe)}`}
className="text-sm font-semibold text-foreground"
>
{group.groupDe}
</Label>
<span className="text-xs text-muted-foreground">
{groupSelectedCount}/{group.entries.length}
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 pl-6 sm:grid-cols-3">
<div key={group.groupDe} className="flex flex-col gap-1">
<CheckboxField
id={`group-${slugify(group.groupDe)}`}
className="font-semibold"
label={group.groupDe}
description={`${groupSelectedCount} von ${group.entries.length} gewählt`}
checked={groupState}
disabled={disabled}
describedBy={describedBy}
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-1 pl-8 sm:grid-cols-2 lg:grid-cols-3">
{group.entries.map((entry) => (
<div key={entry.key} className="flex items-center gap-2">
<Checkbox
id={`perm-${entry.key}`}
checked={selected.has(entry.key)}
disabled={disabled}
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
/>
<Label htmlFor={`perm-${entry.key}`} className="text-sm font-normal">
{entry.labelDe}
</Label>
</div>
<CheckboxField
key={entry.key}
id={`perm-${entry.key}`}
label={entry.labelDe}
checked={selected.has(entry.key)}
disabled={disabled}
describedBy={describedBy}
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
/>
))}
</div>
</div>
@@ -20,6 +20,7 @@ import {
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
import { isListedVoiceStatus } from "@/lib/voice/status-texts";
import { CheckboxField } from "@/components/form/checkbox-field";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -45,20 +46,14 @@ function CloudConsentCard() {
<Card>
<CardContent className="flex flex-col gap-3">
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
<label
htmlFor="voice-cloud-consent"
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
>
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
<input
id="voice-cloud-consent"
type="checkbox"
role="switch"
className="size-6 shrink-0 accent-[var(--primary)]"
checked={allowed}
onChange={(event) => toggle(event.target.checked)}
/>
</label>
<CheckboxField
id="voice-cloud-consent"
role="switch"
size="sheet"
label="Browser-Cloud-Erkennung erlauben"
checked={allowed}
onCheckedChange={toggle}
/>
<p role="status" className="text-sm font-medium">
{allowed ? "Erlaubt" : "Aus"}
</p>
@@ -24,7 +24,7 @@ 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";
import { ToggleChip } from "@/components/form/toggle-chip";
import { CheckboxField } from "@/components/form/checkbox-field";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
@@ -186,10 +186,11 @@ export default function NewSwarmControlCheckPage() {
/>
)}
</Field>
<ToggleChip
<CheckboxField
appearance="tile"
label="Weiselzellen bereits verdeckelt (akut)"
checked={values.queenCellsCapped}
onChange={(checked) => set("queenCellsCapped", checked)}
onCheckedChange={(checked) => set("queenCellsCapped", checked)}
/>
<Field
label="Schwarmstimmung"
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { completeTask, fetchTasks } from "@/lib/tasks/api";
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { PageHeader } from "@/components/page-header";
@@ -79,14 +80,12 @@ export default function TasksPage() {
noneLabel="Alle Prioritäten"
/>
{tab === "active" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
<CheckboxField
className="w-auto"
label="Nur überfällige"
checked={onlyOverdue}
onCheckedChange={setOnlyOverdue}
/>
) : null}
</div>
@@ -17,7 +17,7 @@ import {
} from "@/lib/winter-checks/types";
import { winterLabel } from "@/lib/winter-checks/winter";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { CheckboxField } from "@/components/form/checkbox-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -178,14 +178,12 @@ export function WinterCheckDialog({
</p>
</div>
{target.isLiving ? (
<div className="flex items-center gap-2">
<Checkbox
id="winter-mark-deceased"
checked={markDeceased}
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
/>
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
</div>
<CheckboxField
id="winter-mark-deceased"
label="Volk als abgestorben kennzeichnen"
checked={markDeceased}
onCheckedChange={setMarkDeceased}
/>
) : null}
</div>
) : null}
+4 -1
View File
@@ -696,8 +696,11 @@ html {
:root[data-motion="subtle"] .next-step-enter {
animation: next-step-fade 200ms ease-out;
}
/* The honey-check moment (cell fills, tick draws itself) belongs to full motion; subtle keeps
the tick but drops the slow fill and the stroke delay. */
:root[data-motion="subtle"] .today-row,
:root[data-motion="subtle"] .honey-check::after {
:root[data-motion="subtle"] .honey-check::after,
:root[data-motion="subtle"] .honey-check svg {
transition-duration: 120ms;
transition-delay: 0ms;
}