From f307e7da6657dd57a681ea8c81ff58b2b6aa7fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 19:30:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(t5):=20ein=20Checkbox-Baustein=20f=C3=BCr?= =?UTF-8?q?=20alle=2035=20Stellen=20(T5a=20A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../[id]/swarm-control-edit-dialog.tsx | 7 +- .../notices/create-notice-dialog.tsx | 4 +- .../create-restriction-dialog.tsx | 7 +- .../(app)/compliance/settings/page.spec.tsx | 21 ++ .../app/(app)/compliance/settings/page.tsx | 27 +- .../src/app/(app)/dashboard/page.spec.tsx | 26 +- .../src/app/(app)/extractions/new/page.tsx | 7 +- .../harvests/[id]/harvest-edit-dialog.tsx | 7 +- .../src/app/(app)/harvests/new/page.tsx | 7 +- .../(app)/medicines/medicine-form-dialog.tsx | 7 +- .../profile/notification-settings-card.tsx | 12 +- .../app/(app)/profile/telegram-link-card.tsx | 14 +- .../automation/automation-rules-tab.tsx | 7 +- .../settings/automation/calendar-sync-tab.tsx | 41 ++- .../automation/create-webhook-dialog.tsx | 34 +- .../automation/edit-task-template-dialog.tsx | 21 +- .../automation/edit-webhook-dialog.tsx | 58 ++-- .../automation/rule-forms/rule-form-parts.tsx | 7 +- .../settings/hive-systems/system-dialog.tsx | 18 +- .../designer/label-designer.tsx | 38 +-- .../users-roles/permission-matrix.spec.tsx | 48 +++ .../users-roles/permission-matrix.tsx | 76 +++-- .../src/app/(app)/settings/voice/page.tsx | 23 +- .../src/app/(app)/swarm-control/new/page.tsx | 7 +- ApiCore-Frontend/src/app/(app)/tasks/page.tsx | 15 +- .../app/(app)/winter/winter-check-dialog.tsx | 16 +- ApiCore-Frontend/src/app/globals.css | 5 +- .../colonies/colony-takeover-section.tsx | 16 +- .../components/form/checkbox-field.spec.tsx | 314 ++++++++++++++++++ .../src/components/form/checkbox-field.tsx | 167 ++++++++++ .../form/colony-multi-select.spec.tsx | 7 +- .../components/form/colony-multi-select.tsx | 25 +- .../src/components/form/switch-row.tsx | 40 --- .../src/components/form/toggle-chip.tsx | 46 --- .../src/components/harvest-select-field.tsx | 44 ++- .../src/components/today/today-panel.tsx | 31 +- .../src/components/ui/checkbox.spec.tsx | 121 +++++++ .../src/components/ui/checkbox.tsx | 75 ++++- ApiCore-Frontend/src/components/ui/switch.tsx | 63 ++++ .../src/styles/baukasten-guard.spec.ts | 79 +++++ 40 files changed, 1179 insertions(+), 409 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/checkbox-field.tsx delete mode 100644 ApiCore-Frontend/src/components/form/switch-row.tsx delete mode 100644 ApiCore-Frontend/src/components/form/toggle-chip.tsx create mode 100644 ApiCore-Frontend/src/components/ui/checkbox.spec.tsx create mode 100644 ApiCore-Frontend/src/components/ui/switch.tsx diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx index 0fe30e0..83ee9dc 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx @@ -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 }) /> )} - {({ id }) => ( diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx index 6d100ce..121190d 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/create-notice-dialog.tsx @@ -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 }) { ) : null} - + {alreadySent ? (
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx index 800425d..51c7560 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx @@ -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) => ( - toggleApiary(apiary.id, checked)} + onCheckedChange={(checked) => toggleApiary(apiary.id, checked)} /> ))}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx index 56e0335..0c599f3 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.spec.tsx @@ -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(); diff --git a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx index bd0c7b0..dd4debd 100644 --- a/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/compliance/settings/page.tsx @@ -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> = { @@ -277,22 +279,26 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
- set("noticeOnColonyCountChange", checked)} + onCheckedChange={(checked) => set("noticeOnColonyCountChange", checked)} />
- set("noticeOnApiaryChange", checked)} + onCheckedChange={(checked) => set("noticeOnApiaryChange", checked)} />
@@ -361,15 +367,22 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c

Nur diese Arten zählen in der TSK-Zählung mit.

{COLONY_TYPES.map((type) => ( - toggleCounted(type, checked)} + describedBy={readOnly ? "note-counted-read-only" : undefined} + onCheckedChange={(checked) => toggleCounted(type, checked)} /> ))}
+ {readOnly ? ( +

+ {READ_ONLY_HINT} +

+ ) : null} { 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(); diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx index 36ced69..450d24d 100644 --- a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx @@ -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) => ( - entry.colony.code) .join(", ")}`} checked={values.harvestIds.includes(harvest.id)} - onChange={(checked) => + onCheckedChange={(checked) => toggleHarvest(harvest.id, checked) } /> diff --git a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx index dd75e59..7986742 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx @@ -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) => ( - toggleColony(colony.id, checked)} + onCheckedChange={(checked) => toggleColony(colony.id, checked)} /> ))} diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index 65bcf5b..e139106 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -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 (
- + onCheckedChange={(checked) => toggleColony(colony.id, checked) } /> diff --git a/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx index ef673b3..8b1b656 100644 --- a/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx @@ -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) { /> )} - set("prescriptionOnly", checked)} + onCheckedChange={(checked) => set("prescriptionOnly", checked)} /> {({ id, describedBy: fieldDescribedBy }) => ( diff --git a/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx index d9d75f0..d8feab0 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx @@ -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 (
- change("taskDueEnabled", checked)} + onCheckedChange={(checked) => change("taskDueEnabled", checked)} /> - change("digestEnabled", checked)} + onCheckedChange={(checked) => change("digestEnabled", checked)} />
diff --git a/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx index 15affd9..17114cf 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx @@ -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 Das kommt per Telegram
{NOTIFICATION_CATEGORIES.map((category) => ( - toggleCategory(category, checked)} + onCheckedChange={(checked) => toggleCategory(category, checked)} describedBy={isOffline ? hintId : undefined} disabled={mutation.isPending || isOffline} /> ))}
- mutation.mutate({ telegramDetail: checked })} + describedBy={isOffline ? hintId : undefined} + onCheckedChange={(checked) => mutation.mutate({ telegramDetail: checked })} /> {error ? (

diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 6f09f52..d255418 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -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 ? (

- { + onCheckedChange={(checked) => { clearIssues("notify"); setNotify(checked); }} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx index e620526..e08b7c2 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx @@ -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.

-
- setEnabled(checked === true)} - /> - -
+
-
-

Welche Einträge übertragen werden

+
+

+ Welche Einträge übertragen werden +

{SYNC_TYPE_FIELDS.map((field) => ( -
- - setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true })) - } - /> - -
+ + setTypeFlags((prev) => ({ ...prev, [field.key]: checked })) + } + /> ))}
-
- -
+
+ + Ereignisse + +
{WEBHOOK_EVENTS.map((webhookEvent) => ( - + id={`webhook-event-${webhookEvent}`} + label={WEBHOOK_EVENT_LABELS[webhookEvent]} + checked={events.includes(webhookEvent)} + onCheckedChange={(checked) => + setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked)) + } + /> ))}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx index 28be066..1af565c 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx @@ -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 }) />
) : null} - + {error ? (

{error} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx index 65bfced..8f0fe8f 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-webhook-dialog.tsx @@ -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)} />

-
- -
+
+ + Ereignisse + +
{WEBHOOK_EVENTS.map((webhookEvent) => ( - + id={`webhook-edit-event-${webhookEvent}`} + label={WEBHOOK_EVENT_LABELS[webhookEvent]} + checked={events.includes(webhookEvent)} + onCheckedChange={(checked) => + setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked)) + } + /> ))}
- + {error ? (

{error} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx index 9cc0c81..a71f618 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx @@ -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 (

- draft.set(path, checked)} + onCheckedChange={(checked) => draft.set(path, checked)} describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined} /> {hintText ? ( diff --git a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/system-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/system-dialog.tsx index 578c0c7..233a537 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/system-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/system-dialog.tsx @@ -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 ))} {system ? ( - + setValues({ ...values, active })} + /> ) : null} {error ? (

diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx index 341534f..be5a469 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx @@ -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

) : null}
- + @@ -786,22 +784,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP />
- + diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx new file mode 100644 index 0000000..52cf8cc --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.spec.tsx @@ -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 ; +} + +describe("PermissionMatrix", () => { + it("the group box is partly checked and checks the whole group", async () => { + render(); + 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(); + 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(); + 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")); + } + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.tsx index 9cf52a0..5841f79 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/permission-matrix.tsx @@ -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; onChange: (next: Set) => 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 (
+ {disabled ? ( +

+ {disabledHint} +

+ ) : 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 ( -
-
- toggleGroup(group.entries, checked)} - /> - - - {groupSelectedCount}/{group.entries.length} - -
-
+
+ toggleGroup(group.entries, checked)} + /> +
{group.entries.map((entry) => ( -
- toggleOne(entry.key, checked)} - /> - -
+ toggleOne(entry.key, checked)} + /> ))}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx index a60c0ca..cb9d96d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx @@ -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() {

Browser-Cloud-Erkennung

- +

{allowed ? "Erlaubt" : "Aus"}

diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx index 5076500..e87806b 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx @@ -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() { /> )} - set("queenCellsCapped", checked)} + onCheckedChange={(checked) => set("queenCellsCapped", checked)} /> {tab === "active" ? ( - + ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx index c471e70..2b3eb4c 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx @@ -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({

{target.isLiving ? ( -
- setMarkDeceased(checked === true)} - /> - -
+ ) : null}
) : null} diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index a85c610..236a06b 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -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; } diff --git a/ApiCore-Frontend/src/components/colonies/colony-takeover-section.tsx b/ApiCore-Frontend/src/components/colonies/colony-takeover-section.tsx index 5f6b22c..daafd52 100644 --- a/ApiCore-Frontend/src/components/colonies/colony-takeover-section.tsx +++ b/ApiCore-Frontend/src/components/colonies/colony-takeover-section.tsx @@ -5,6 +5,7 @@ import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspectio import { FEED_CONDITION_LABELS, ratingToCondition } from "@/lib/colonies/condition"; import { COLONY_CONDITION_LABELS } from "@/lib/colonies/labels"; import type { Colony, ColonyCondition } from "@/lib/colonies/types"; +import { CheckboxField } from "@/components/form/checkbox-field"; import { COLONY_TAKEOVER_FIELDS, type ColonyTakeoverField } from "@/lib/inspections/types"; export interface ColonyTakeoverSectionProps { @@ -124,15 +125,12 @@ export function ColonyTakeoverSection({ Diese Angaben auch auf der Volkskarte eintragen:

{rows.map((row) => ( - + toggle(row.field, checked)} + /> ))} ) : null} diff --git a/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx b/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx new file mode 100644 index 0000000..a51c9c0 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/checkbox-field.spec.tsx @@ -0,0 +1,314 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { CheckboxField, type CheckboxFieldProps } from "./checkbox-field"; + +const classesOf = (element: Element) => (element.getAttribute("class") ?? "").split(/\s+/); + +type HarnessProps = Omit & { + initial?: boolean; + onChange?: (next: boolean) => void; +}; + +function Harness({ initial = false, onChange, ...props }: HarnessProps) { + const [checked, setChecked] = useState(initial); + return ( + { + setChecked(next); + onChange?.(next); + }} + /> + ); +} + +describe("CheckboxField (spec 10.3)", () => { + it("the whole row toggles and the name comes from the label", async () => { + const onChange = vi.fn(); + render( + , + ); + const row = screen.getByRole("checkbox", { name: "Schnittmarken am Blattrand" }); + expect(row).toHaveAccessibleDescription("Kleine Striche im Rand an jeder Schnittkante."); + + // A tap on the description (not on the box) still toggles: the row is the control. + await userEvent.click(screen.getByText("Kleine Striche im Rand an jeder Schnittkante.")); + expect(row).toBeChecked(); + await userEvent.click(screen.getByText("Schnittmarken am Blattrand")); + expect(row).not.toBeChecked(); + expect(onChange.mock.calls).toEqual([[true], [false]]); + }); + + it("the row is at least 44 px high (min-h-11) and in a sheet 56 px (min-h-14)", () => { + render( + <> + + + , + ); + const row = screen.getByRole("checkbox", { name: "Zeile" }); + expect(classesOf(row)).toContain("min-h-11"); + expect(classesOf(row)).toContain("text-base"); + expect(classesOf(screen.getByRole("checkbox", { name: "Im Sheet" }))).toContain("min-h-14"); + }); + + it("tile and task have role checkbox from the primitive", () => { + render( + <> + + + , + ); + for (const name of ["V-01", "„Stock prüfen“ als erledigt markieren"]) { + const control = screen.getByRole("checkbox", { name }); + expect(control.tagName).toBe("BUTTON"); + expect(control).toHaveAttribute("data-slot", "checkbox"); + } + expect(screen.getByRole("checkbox", { name: "V-01" }).textContent).toBe("V-01"); + }); + + it("checked shows the tick icon and a 2 px foreground border", async () => { + render(); + const row = screen.getByRole("checkbox", { name: "Aktiv" }); + expect(row.querySelector("svg.lucide-check")).toBeNull(); + + await userEvent.click(row); + const mark = row.querySelector("[data-slot=checkbox-mark]") as HTMLElement; + expect(classesOf(mark)).toEqual(expect.arrayContaining(["border-2", "border-foreground", "bg-primary"])); + expect(mark.querySelector("svg.lucide-check")).not.toBeNull(); + }); + + it("a checked tile has the honey tint, a 2 px ink border and the tick", () => { + render( + <> + {}} /> + {}} /> + , + ); + const on = screen.getByRole("checkbox", { name: "Ja" }); + expect(classesOf(on)).toEqual(expect.arrayContaining(["tint", "border-2", "border-foreground"])); + expect(on.querySelector("svg.lucide-check")).not.toBeNull(); + const off = screen.getByRole("checkbox", { name: "Nein" }); + expect(classesOf(off)).toEqual(expect.arrayContaining(["border", "border-input", "bg-card"])); + expect(classesOf(off)).not.toContain("tint"); + }); + + it("indeterminate shows the dash", () => { + render( {}} />); + const row = screen.getByRole("checkbox", { name: "Bienenstände" }); + expect(row).toHaveAttribute("aria-checked", "mixed"); + expect(row.querySelector("svg.lucide-minus")).not.toBeNull(); + }); + + it("clicking an indeterminate box checks it", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("checkbox", { name: "Gruppe" })); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("disabled shows its hint text", async () => { + const onChange = vi.fn(); + render( + , + ); + const row = screen.getByRole("checkbox", { name: "Mit Details" }); + expect(row).toBeDisabled(); + expect(screen.getByText("Nur mit Verbindung")).toBeVisible(); + expect(row).toHaveAccessibleDescription("Nur mit Verbindung"); + await userEvent.click(row); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("a row shows hover only for a fine pointer and only while enabled", () => { + render( {}} />); + const row = screen.getByRole("checkbox", { name: "Aktiv" }); + expect(classesOf(row)).toContain("pointer-fine:enabled:hover:bg-muted"); + expect(classesOf(row)).not.toContain("hover:bg-muted"); + }); + + it("a disabled tile dims as a whole and keeps its hint outside at full strength", () => { + render( + {}} + />, + ); + const tile = screen.getByRole("checkbox", { name: "Ableger" }); + expect(classesOf(tile)).toContain("opacity-50"); + expect(tile.querySelector(".opacity-50")).toBeNull(); + const hint = screen.getByText("Nur ansehen"); + expect(tile.contains(hint)).toBe(false); + expect(hint.closest(".opacity-50")).toBeNull(); + expect(tile).toHaveAccessibleDescription("Nur ansehen"); + }); + + it("an enabled tile hovers, a disabled one does not", () => { + render( {}} />); + expect(classesOf(screen.getByRole("checkbox", { name: "V-01" }))).toContain("enabled:hover:bg-muted"); + }); + + it("the hint only shows while disabled", () => { + render( {}} />); + expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument(); + }); + + it("joins its description with outside hints in describedBy", () => { + render( + <> +

Annahme

+ {}} + /> + , + ); + expect(screen.getByRole("checkbox", { name: "A9 Wintersitz" })).toHaveAccessibleDescription("Ab Oktober. Annahme"); + }); + + it('role="switch" is a Base UI switch with the tick on the thumb', async () => { + const onChange = vi.fn(); + render( + , + ); + const control = screen.getByRole("switch", { name: "Erinnerung je Aufgabe" }); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + expect(control).toHaveAccessibleDescription("Eine Meldung, wenn eine Aufgabe fällig wird."); + expect(classesOf(control)).toContain("min-h-11"); + + await userEvent.click(screen.getByText("Eine Meldung, wenn eine Aufgabe fällig wird.")); + expect(control).toBeChecked(); + expect(control.querySelector("svg.lucide-check")).not.toBeNull(); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("Tab reaches it and Space toggles it", async () => { + const onChange = vi.fn(); + render(); + await userEvent.tab(); + expect(screen.getByRole("checkbox", { name: "Nur überfällige" })).toHaveFocus(); + await userEvent.keyboard(" "); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("task is the honey-check cell: label as its name, the drawn tick, no visible text", () => { + render( + {}} + />, + ); + const cell = screen.getByRole("checkbox", { name: "„Stock prüfen“ als erledigt markieren" }); + expect(classesOf(cell)).toContain("honey-check"); + expect(cell).toHaveAttribute("aria-checked", "true"); + expect(cell.textContent).toBe(""); + expect(cell.querySelector("svg path")).toHaveAttribute("d", "M5 12.5l4.5 4.5L19 7.5"); + }); +}); + +const css = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +describe("honey-check moment (spec 10.3, 17)", () => { + it("keys on the state of the Base UI primitive (aria-checked)", () => { + expect(css).toMatch(/\.honey-check\[aria-checked="true"\]::after\s*\{/); + expect(css).toMatch(/\.honey-check\[aria-checked="true"\] svg\s*\{/); + }); + + it("today panel ticking a task keeps the honey-check moment only with full motion", () => { + // Subtle and the system setting keep the tick, but drop the fill and the stroke delay. + const subtle = /:root\[data-motion="subtle"\] \.honey-check::after,\s*:root\[data-motion="subtle"\] \.honey-check svg\s*\{([^}]*)\}/.exec( + css, + ); + expect(subtle?.[1]).toMatch(/transition-duration:\s*120ms/); + expect(subtle?.[1]).toMatch(/transition-delay:\s*0ms/); + expect(css).toMatch( + /@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*\.honey-check::after,\s*\.honey-check svg\s*\{[^}]*transition-duration:\s*120ms/, + ); + }); +}); + +const FRONTEND_SRC = join(__dirname, "..", ".."); + +/** Every former checkbox place (31 + the 4 found on recount, see task A7 report). */ +const CHECKBOX_FIELD_FILES = [ + // ToggleChip + "app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx", + "app/(app)/compliance/notices/create-notice-dialog.tsx", + "app/(app)/compliance/restrictions/create-restriction-dialog.tsx", + "app/(app)/compliance/settings/page.tsx", + "app/(app)/extractions/new/page.tsx", + "app/(app)/harvests/new/page.tsx", + "app/(app)/harvests/[id]/harvest-edit-dialog.tsx", + "app/(app)/medicines/medicine-form-dialog.tsx", + "app/(app)/profile/telegram-link-card.tsx", + "app/(app)/settings/automation/automation-rules-tab.tsx", + "app/(app)/settings/automation/rule-forms/rule-form-parts.tsx", + "app/(app)/swarm-control/new/page.tsx", + // native type="checkbox" + "app/(app)/settings/automation/create-webhook-dialog.tsx", + "app/(app)/settings/automation/edit-task-template-dialog.tsx", + "app/(app)/settings/automation/edit-webhook-dialog.tsx", + "app/(app)/settings/hive-systems/system-dialog.tsx", + "app/(app)/settings/label-templates/designer/label-designer.tsx", + "app/(app)/settings/voice/page.tsx", + "app/(app)/tasks/page.tsx", + "components/colonies/colony-takeover-section.tsx", + "components/harvest-select-field.tsx", + // SwitchRow users + "app/(app)/profile/notification-settings-card.tsx", + // Base UI checkbox users + "components/form/colony-multi-select.tsx", + "app/(app)/settings/automation/calendar-sync-tab.tsx", + "app/(app)/settings/users-roles/permission-matrix.tsx", + "app/(app)/winter/winter-check-dialog.tsx", + // honey-check + "components/today/today-panel.tsx", +]; + +describe("every checkbox place uses CheckboxField", () => { + it.each(CHECKBOX_FIELD_FILES)("%s", (file) => { + const text = readFileSync(join(FRONTEND_SRC, file), "utf8"); + expect(text).toMatch(/import \{ CheckboxField \} from "(?:@\/components\/form|\.)\/checkbox-field"/); + expect(text).not.toMatch(/ToggleChip|SwitchRow|components\/ui\/checkbox"/); + }); + + it("the harvest dialog, webhook dialogs and task template dialog use CheckboxField", () => { + for (const file of [ + "app/(app)/harvests/[id]/harvest-edit-dialog.tsx", + "app/(app)/settings/automation/create-webhook-dialog.tsx", + "app/(app)/settings/automation/edit-webhook-dialog.tsx", + "app/(app)/settings/automation/edit-task-template-dialog.tsx", + ]) { + expect(readFileSync(join(FRONTEND_SRC, file), "utf8"), file).toMatch(/ + + + + + ); +} + +/** + * A yes/no field of the kit (spec 10.3). The control is the Base UI primitive itself, so a tap + * anywhere on the row or tile toggles it, Tab stops once and Space toggles. Labels and + * descriptions are tied to it by id. + */ +export function CheckboxField(props: CheckboxFieldProps) { + const { + label, + description, + checked, + onCheckedChange, + disabled, + disabledHint, + appearance, + size, + role, + id, + describedBy, + className, + } = props; + const baseId = useId(); + if (appearance === "task") return ; + + const isSwitch = role === "switch"; + const isTile = appearance === "tile"; + const isOn = checked !== false; + const showHint = Boolean(disabled && disabledHint); + const labelId = `${baseId}-label`; + const descriptionId = `${baseId}-description`; + const hintId = `${baseId}-hint`; + const controlProps = { + id, + disabled, + "aria-labelledby": labelId, + "aria-describedby": joinIds(description && descriptionId, showHint && hintId, describedBy), + className: cn( + "flex items-start gap-3 rounded-lg text-left text-base", + ROW_SIZE[size ?? "default"], + isTile ? tileClass(isOn) : ROW_CLASS, + // A disabled tile dims as a whole (frame and surface); a row dims label and mark only, + // so its hint stays readable. + isTile && disabled && "opacity-50", + className, + ), + }; + const dimmed = disabled && !isTile && "opacity-50"; + const hint = showHint ? ( + + {disabledHint} + + ) : null; + const text = ( + + + {label} + + {description ? ( + + {description} + + ) : null} + {isTile ? null : hint} + + ); + + if (isSwitch) { + return ( + + {text} + + + ); + } + const control = ( + + + {text} + + ); + if (!isTile || !hint) return control; + // The hint of a dimmed tile sits under it at full strength. + return ( + + {control} + {hint} + + ); +} diff --git a/ApiCore-Frontend/src/components/form/colony-multi-select.spec.tsx b/ApiCore-Frontend/src/components/form/colony-multi-select.spec.tsx index 490593b..638df6e 100644 --- a/ApiCore-Frontend/src/components/form/colony-multi-select.spec.tsx +++ b/ApiCore-Frontend/src/components/form/colony-multi-select.spec.tsx @@ -125,9 +125,10 @@ describe("ColonyMultiSelect", () => { const box = await screen.findByRole("checkbox", { name: "V-01" }); - expect(box.closest("label")).toHaveClass("min-h-14"); - expect(screen.getAllByRole("checkbox").map((item) => item.closest("label"))).toSatisfy( - (rows: (HTMLElement | null)[]) => rows.every((row) => row?.classList.contains("min-h-14")), + // The whole row is the checkbox (CheckboxField size="sheet"). + expect(box).toHaveClass("min-h-14"); + expect(screen.getAllByRole("checkbox")).toSatisfy((rows: HTMLElement[]) => + rows.every((row) => row.classList.contains("min-h-14")), ); }); diff --git a/ApiCore-Frontend/src/components/form/colony-multi-select.tsx b/ApiCore-Frontend/src/components/form/colony-multi-select.tsx index 8fc78ba..fd6e386 100644 --- a/ApiCore-Frontend/src/components/form/colony-multi-select.tsx +++ b/ApiCore-Frontend/src/components/form/colony-multi-select.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; +import { CheckboxField } from "@/components/form/checkbox-field"; import { Field } from "@/components/form/field"; import { RecordSelect } from "@/components/form/record-select"; import { colonyOptionLabel } from "@/lib/colonies/labels"; @@ -145,21 +145,14 @@ export function ColonyMultiSelect({
    {list.map((colony) => (
  • - + toggle(colony.id, isChecked)} + />
  • ))}
diff --git a/ApiCore-Frontend/src/components/form/switch-row.tsx b/ApiCore-Frontend/src/components/form/switch-row.tsx deleted file mode 100644 index 4993af2..0000000 --- a/ApiCore-Frontend/src/components/form/switch-row.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { useId } from "react"; - -interface SwitchRowProps { - label: string; - description?: string; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; -} - -/** A yes/no setting as a tall row (>= 44 px) with its explanation under the label. */ -export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) { - const id = useId(); - return ( - - ); -} diff --git a/ApiCore-Frontend/src/components/form/toggle-chip.tsx b/ApiCore-Frontend/src/components/form/toggle-chip.tsx deleted file mode 100644 index a07d781..0000000 --- a/ApiCore-Frontend/src/components/form/toggle-chip.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Check } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface ToggleChipProps { - label: string; - checked: boolean; - onChange: (checked: boolean) => void; - /** Ids of a hint or error shown next to the tile. */ - describedBy?: string; - disabled?: boolean; -} - -/** A yes/no observation as a large tile; state shown by tick AND colour. */ -export function ToggleChip({ label, checked, onChange, describedBy, disabled }: ToggleChipProps) { - return ( - - ); -} diff --git a/ApiCore-Frontend/src/components/harvest-select-field.tsx b/ApiCore-Frontend/src/components/harvest-select-field.tsx index b61cdde..ea09d3f 100644 --- a/ApiCore-Frontend/src/components/harvest-select-field.tsx +++ b/ApiCore-Frontend/src/components/harvest-select-field.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { fetchHarvests } from "@/lib/harvests/api"; -import { Label } from "@/components/ui/label"; +import { CheckboxField } from "@/components/form/checkbox-field"; interface HarvestSelectFieldProps { idPrefix: string; @@ -32,34 +32,32 @@ export function HarvestSelectField({ ); } + const labelId = `${idPrefix}-label`; return ( -
- +
+ + Ernten + {(harvests ?? []).length === 0 ? (

Keine Ernten erfasst.

) : null} -
+
{(harvests ?? []).map((harvest) => ( - + id={`${idPrefix}-${harvest.id}`} + label={[ + new Date(harvest.date).toLocaleDateString("de-DE"), + harvest.netWeight !== null ? `${harvest.netWeight} kg` : null, + harvest.colonies.length > 0 + ? harvest.colonies.map((entry) => entry.colony.code).join(", ") + : null, + ] + .filter(Boolean) + .join(" · ")} + checked={value.includes(harvest.id)} + onCheckedChange={(checked) => toggle(harvest.id, checked)} + /> ))}
diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx index 649d622..2cb0a46 100644 --- a/ApiCore-Frontend/src/components/today/today-panel.tsx +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react"; import { Task, TaskRelatedType } from "@/lib/tasks/types"; import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today"; +import { CheckboxField } from "@/components/form/checkbox-field"; import { Button } from "@/components/ui/button"; import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; @@ -391,28 +392,16 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
  • - + describedBy={describedBy} + onCheckedChange={(next) => { + if (next) onComplete(); + }} + />
    {task.title} diff --git a/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx b/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx new file mode 100644 index 0000000..efdaaa3 --- /dev/null +++ b/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx @@ -0,0 +1,121 @@ +import { useState, type FormEvent } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Checkbox, CheckboxMark } from "./checkbox"; +import { Switch, SwitchMark } from "./switch"; + +const classesOf = (element: Element) => (element.getAttribute("class") ?? "").split(/\s+/); + +function Harness({ onChange }: { onChange?: (next: boolean) => void }) { + const [checked, setChecked] = useState(false); + return ( + { + setChecked(next); + onChange?.(next); + }} + /> + ); +} + +describe("ui/checkbox (spec 10.3)", () => { + it("the box is 20 px with a 2 px --input border and a 6 px radius", () => { + const { container } = render(); + const mark = container.firstElementChild as HTMLElement; + expect(mark).toHaveAttribute("aria-hidden", "true"); + expect(classesOf(mark)).toEqual(expect.arrayContaining(["size-5", "border-2", "border-input", "rounded-[6px]"])); + expect(mark.querySelector("svg")).toBeNull(); + }); + + it("checked: honey surface, ink border and a 14 px tick with stroke width 3", () => { + const { container } = render(); + const mark = container.firstElementChild as HTMLElement; + expect(classesOf(mark)).toEqual( + expect.arrayContaining(["bg-primary", "border-foreground", "text-primary-foreground"]), + ); + const tick = mark.querySelector("svg.lucide-check"); + expect(tick).not.toBeNull(); + expect(classesOf(tick as Element)).toContain("size-3.5"); + expect(tick).toHaveAttribute("stroke-width", "3"); + }); + + it("indeterminate shows a dash, not a tick", () => { + const { container } = render(); + expect(container.querySelector("svg.lucide-minus")).not.toBeNull(); + expect(container.querySelector("svg.lucide-check")).toBeNull(); + }); + + it("the primitive is a native button with role checkbox; click and Space toggle it", async () => { + const onChange = vi.fn(); + render(); + const box = screen.getByRole("checkbox", { name: "Aktiv" }); + expect(box.tagName).toBe("BUTTON"); + expect(box).toHaveAttribute("type", "button"); + expect(box).not.toBeChecked(); + + await userEvent.click(box); + expect(box).toBeChecked(); + box.focus(); + await userEvent.keyboard(" "); + expect(box).not.toBeChecked(); + expect(onChange).toHaveBeenNthCalledWith(1, true); + expect(onChange).toHaveBeenNthCalledWith(2, false); + }); + + it("Enter inside a form neither submits the form nor toggles (APG: Space toggles)", async () => { + const onSubmit = vi.fn((event: FormEvent) => event.preventDefault()); + const onChange = vi.fn(); + const onKeyDown = vi.fn(); + render( + + + + , + ); + screen.getByRole("checkbox", { name: "Aktiv" }).focus(); + await userEvent.keyboard("{Enter}"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); + await userEvent.keyboard(" "); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("disabled is a real disabled button", () => { + render( {}} />); + expect(screen.getByRole("checkbox", { name: "Aktiv" })).toBeDisabled(); + }); +}); + +describe("ui/switch", () => { + it("is a native button with role switch and shows a tick in the thumb when on", async () => { + const onChange = vi.fn(); + render(); + const control = screen.getByRole("switch", { name: "Erlauben" }); + expect(control.tagName).toBe("BUTTON"); + expect(control).toBeChecked(); + expect(control.querySelector("svg.lucide-check")).not.toBeNull(); + + await userEvent.click(control); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it("off: thumb at the start and no tick, so the state is not colour only", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + expect(container.innerHTML).not.toContain("translate-x-5"); + }); + + it("the thumb only slides with full motion", () => { + const { container } = render(); + const thumb = container.querySelector("[data-slot=switch-thumb]") as HTMLElement; + expect(classesOf(thumb)).toContain("translate-x-5"); + expect(classesOf(thumb)).toContain("motion-full:transition-transform"); + expect(thumb.className).not.toMatch(/(^|\s)transition/); + }); +}); diff --git a/ApiCore-Frontend/src/components/ui/checkbox.tsx b/ApiCore-Frontend/src/components/ui/checkbox.tsx index f9aad72..617b74d 100644 --- a/ApiCore-Frontend/src/components/ui/checkbox.tsx +++ b/ApiCore-Frontend/src/components/ui/checkbox.tsx @@ -1,23 +1,78 @@ +"use client" + +import type { ReactNode } from "react" import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" -import { Check } from "lucide-react" +import { Check, Minus } from "lucide-react" import { cn } from "@/lib/utils" -function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { +/** Checked, unchecked or partly checked (a group whose items are only partly chosen). */ +export type CheckState = boolean | "indeterminate" + +/** + * The 20 px box (spec 10.3): 2 px --input border, radius 6 px. Checked: honey surface, ink + * border and a 14 px tick (stroke 3); indeterminate: a dash. Purely visual - the control + * around it carries role and state, and the tick keeps the state from being colour only. + */ +function CheckboxMark({ checked, className }: { checked: CheckState; className?: string }) { + const isOn = checked !== false + return ( + + {checked === "indeterminate" ? ( + + ) : checked ? ( + + ) : null} + + ) +} + +interface CheckboxProps + extends Omit< + CheckboxPrimitive.Root.Props, + "checked" | "indeterminate" | "onCheckedChange" | "nativeButton" | "render" | "children" + > { + checked: CheckState + onCheckedChange?: (next: boolean) => void + /** The content of the control; the box alone without it. */ + children?: ReactNode +} + +/** + * The one checkbox of the app: Base UI `Checkbox` rendered as a native button, so role, + * aria-checked, Space and `disabled` come from the primitive. With children the whole row, + * tile or cell is the control (CheckboxField); without, it is the bare box. + */ +function Checkbox({ checked, onCheckedChange, onKeyDown, className, children, ...props }: CheckboxProps) { return ( } + checked={checked === true} + indeterminate={checked === "indeterminate"} + onCheckedChange={(next) => onCheckedChange?.(next)} + onKeyDown={(event) => { + onKeyDown?.(event) + // Enter neither toggles nor submits a surrounding form (APG: Space toggles a + // checkbox). Base UI skips its submit-on-Enter once the event is prevented, and the + // native button no longer turns Enter into a click. + if (event.key === "Enter") event.preventDefault() + }} + className={cn("disabled:cursor-not-allowed", className)} {...props} > - - - + {children ?? } ) } -export { Checkbox } +export { Checkbox, CheckboxMark } diff --git a/ApiCore-Frontend/src/components/ui/switch.tsx b/ApiCore-Frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..8f5c283 --- /dev/null +++ b/ApiCore-Frontend/src/components/ui/switch.tsx @@ -0,0 +1,63 @@ +"use client" + +import type { ReactNode } from "react" +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +/** + * Track and thumb of a yes/no setting. On: honey track with ink border and the thumb at the + * end carrying a tick, so the state reads from position and icon, not only from colour. The + * thumb only slides with full motion (spec 17); otherwise it jumps. + */ +function SwitchMark({ checked, className }: { checked: boolean; className?: string }) { + return ( + + + {checked ? : null} + + + ) +} + +interface SwitchProps + extends Omit { + checked: boolean + onCheckedChange?: (next: boolean) => void + /** The content of the control; the track alone without it. */ + children?: ReactNode +} + +/** Base UI `Switch` as a native button (role switch, Space, `disabled` from the primitive). */ +function Switch({ checked, onCheckedChange, className, children, ...props }: SwitchProps) { + return ( + } + checked={checked} + onCheckedChange={(next) => onCheckedChange?.(next)} + className={cn("disabled:cursor-not-allowed", className)} + {...props} + > + {children ?? } + + ) +} + +export { Switch, SwitchMark } diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts index d52c1bd..7f0e8c7 100644 --- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts +++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts @@ -19,6 +19,20 @@ interface GuardRule { const FRONTEND = join(__dirname, "..", ".."); const SRC = join(FRONTEND, "src"); +/** + * `keys` (role, type) set to `value` by hand: JSX (role="x", role={on ? "x" : y}), object or + * spread form (role: "x") and setAttribute("role", "x"). A CSS selector ([role="x"]) only finds + * kit controls, and the `role` prop inside a tag is the kit's own API. + */ +function ownRole(keys: string, value: string): RegExp { + const quote = "[\"'`]"; + const quoted = `${quote}${value}${quote}`; + // role="x" | role={... "x" ...} | role: ... "x" (not role?: or role === "x") + const attribute = String.raw`(?]*)(?:${attribute}|${setAttribute})`); +} + const RULES: GuardRule[] = [ // Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone. { @@ -95,6 +109,23 @@ const RULES: GuardRule[] = [ }, ], }, + // Task A7: one checkbox for the whole app (spec 10.3, 10.7). Base UI draws role checkbox and + // its hidden input inside ui/checkbox.tsx, so no source file writes them by hand and the list + // of exceptions stays empty. The `role` prop of picks the kit's own control + // and is no own build (only within that same tag, so put `role` before any arrow handler). + { + id: "no role checkbox or type checkbox outside ui/checkbox.tsx", + pattern: ownRole("role|type", "checkbox"), + scope: "src/**/*.{ts,tsx}", + allow: [], + }, + // Task A7: a yes/no setting is on Base UI Switch (ui/switch.tsx). + { + id: "no role switch outside ui/checkbox.tsx and ui/switch.tsx", + pattern: ownRole("role", "switch"), + scope: "src/**/*.{ts,tsx}", + allow: [], + }, // Task A6: a toggle button sets aria-pressed through Button `pressed` (spec 10.2, 10.7). { id: "aria-pressed only through Button pressed", @@ -167,6 +198,30 @@ describe("baukasten guard (spec 10.7)", () => { } }); + it("ToggleChip and SwitchRow are gone (CheckboxField tile and switch replace them)", () => { + for (const name of ["toggle-chip", "switch-row"]) { + expect(existsSync(join(SRC, "components", "form", `${name}.tsx`)), name).toBe(false); + } + const users = FILES.filter(({ text }) => /\b(?:ToggleChip|SwitchRow)\b|form\/(?:toggle-chip|switch-row)/.test(text)); + expect(users.map(({ file }) => file)).toEqual([]); + }); + + it("a list of CheckboxFields keeps neighbouring targets at least 4 px apart (spec 4.5)", () => { + // `.map(… => ) and the container opened before it. + const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) => + [...text.matchAll(/\.map\(\s*\([^)]*\)\s*=>\s*\(?\s*(?:]*>\s*)? { + const before = text.slice(0, match.index); + const containers = [...before.matchAll(/<(?:div|ul|fieldset)\b[^>]*?className="([^"]*)"/g)]; + const classes = containers.at(-1)?.[1] ?? ""; + const gap = /(?:^|\s)gap-(?:y-)?(\d+(?:\.5)?)(?=\s|$)/.exec(classes)?.[1]; + const line = before.split("\n").length; + return { where: `${file}:${line}`, gapPx: gap === undefined ? 0 : Number(gap) * 4 }; + }), + ); + expect(lists.length).toBeGreaterThanOrEqual(8); + expect(lists.filter(({ gapPx }) => gapPx < 4).map(({ where, gapPx }) => `${where}: ${gapPx} px`)).toEqual([]); + }); + it("no component removes the outline without the global rule", () => { expect(GLOBALS).toMatch(/:where\(:focus-visible\)\s*\{[^}]*outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/); for (const id of ["no ring-ring/50", "no outline-none"]) { @@ -208,6 +263,30 @@ describe("baukasten guard (spec 10.7)", () => { expect(radio.test('{ type: "radio" }')).toBe(true); expect(radio.test('{ role: "radioactive" }')).toBe(false); expect(radio.test(`querySelector('[role="radio"][tabindex="0"]')`)).toBe(false); + const checkbox = rule("no role checkbox or type checkbox outside ui/checkbox.tsx").pattern; + expect(checkbox.test('')).toBe(true); + expect(checkbox.test('