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:
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>
|
||||
|
||||
+4
-3
@@ -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">
|
||||
|
||||
+13
-25
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
</p>
|
||||
{rows.map((row) => (
|
||||
<label key={row.field} className="flex min-h-11 items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-5 shrink-0 accent-primary"
|
||||
checked={selected.includes(row.field)}
|
||||
onChange={(event) => toggle(row.field, event.target.checked)}
|
||||
/>
|
||||
{row.label}
|
||||
</label>
|
||||
<CheckboxField
|
||||
key={row.field}
|
||||
label={row.label}
|
||||
checked={selected.includes(row.field)}
|
||||
onCheckedChange={(checked) => toggle(row.field, checked)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
@@ -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<CheckboxFieldProps, "checked" | "onCheckedChange"> & {
|
||||
initial?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
};
|
||||
|
||||
function Harness({ initial = false, onChange, ...props }: HarnessProps) {
|
||||
const [checked, setChecked] = useState(initial);
|
||||
return (
|
||||
<CheckboxField
|
||||
{...props}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
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(
|
||||
<Harness
|
||||
label="Schnittmarken am Blattrand"
|
||||
description="Kleine Striche im Rand an jeder Schnittkante."
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<>
|
||||
<Harness label="Zeile" />
|
||||
<Harness label="Im Sheet" size="sheet" />
|
||||
</>,
|
||||
);
|
||||
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(
|
||||
<>
|
||||
<Harness label="V-01" appearance="tile" />
|
||||
<Harness label="„Stock prüfen“ als erledigt markieren" appearance="task" />
|
||||
</>,
|
||||
);
|
||||
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(<Harness label="Aktiv" />);
|
||||
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(
|
||||
<>
|
||||
<CheckboxField label="Ja" appearance="tile" checked onCheckedChange={() => {}} />
|
||||
<CheckboxField label="Nein" appearance="tile" checked={false} onCheckedChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
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(<CheckboxField label="Bienenstände" checked="indeterminate" onCheckedChange={() => {}} />);
|
||||
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(<CheckboxField label="Gruppe" checked="indeterminate" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Gruppe" }));
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("disabled shows its hint text", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CheckboxField
|
||||
label="Mit Details"
|
||||
checked={false}
|
||||
disabled
|
||||
disabledHint="Nur mit Verbindung"
|
||||
onCheckedChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(<CheckboxField label="Aktiv" checked={false} onCheckedChange={() => {}} />);
|
||||
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(
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Ableger"
|
||||
checked
|
||||
disabled
|
||||
disabledHint="Nur ansehen"
|
||||
onCheckedChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(<CheckboxField appearance="tile" label="V-01" checked={false} onCheckedChange={() => {}} />);
|
||||
expect(classesOf(screen.getByRole("checkbox", { name: "V-01" }))).toContain("enabled:hover:bg-muted");
|
||||
});
|
||||
|
||||
it("the hint only shows while disabled", () => {
|
||||
render(<CheckboxField label="Aktiv" checked={false} disabledHint="Nur mit Verbindung" onCheckedChange={() => {}} />);
|
||||
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("joins its description with outside hints in describedBy", () => {
|
||||
render(
|
||||
<>
|
||||
<p id="outside-hint">Annahme</p>
|
||||
<CheckboxField
|
||||
label="A9 Wintersitz"
|
||||
description="Ab Oktober."
|
||||
describedBy="outside-hint"
|
||||
checked={false}
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
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(
|
||||
<Harness
|
||||
role="switch"
|
||||
label="Erinnerung je Aufgabe"
|
||||
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(<Harness label="Nur überfällige" onChange={onChange} />);
|
||||
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(
|
||||
<CheckboxField
|
||||
appearance="task"
|
||||
label="„Stock prüfen“ als erledigt markieren"
|
||||
checked
|
||||
onCheckedChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(/<CheckboxField\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Checkbox, CheckboxMark, type CheckState } from "@/components/ui/checkbox";
|
||||
import { Switch, SwitchMark } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "row" (default): the whole row is the control, label 16 px. "tile": a framed tile for yes/no
|
||||
* observations in a grid. "task": the tick cell of the today list with the honey-check moment.
|
||||
*/
|
||||
export type CheckboxAppearance = "row" | "tile" | "task";
|
||||
|
||||
export interface CheckboxFieldProps {
|
||||
/** Visible label and accessible name; for "task" only the name (the row shows the title). */
|
||||
label: string;
|
||||
/** Second line under the label (14 px), read as the description. */
|
||||
description?: string;
|
||||
checked: CheckState;
|
||||
onCheckedChange(next: boolean): void;
|
||||
disabled?: boolean;
|
||||
/** Says why the field is off; shown (and read) only while disabled. */
|
||||
disabledHint?: string;
|
||||
appearance?: CheckboxAppearance;
|
||||
/** "sheet": rows of a capture sheet are 56 px instead of 44 px. */
|
||||
size?: "default" | "sheet";
|
||||
/** "switch": a yes/no setting that takes effect at once (Base UI Switch, row only). */
|
||||
role?: "checkbox" | "switch";
|
||||
id?: string;
|
||||
/** Ids of hints or errors outside the field. */
|
||||
describedBy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ROW_SIZE = { default: "min-h-11 py-2.5", sheet: "min-h-14 py-4" } as const;
|
||||
|
||||
/** Frameless row: the hover wash is only for a mouse, never while disabled. */
|
||||
const ROW_CLASS = "w-full px-2 pointer-fine:enabled:hover:bg-muted";
|
||||
|
||||
function tileClass(isOn: boolean): string {
|
||||
// Honey tint with a 2 px ink border; in sun `.tint` turns into the card with the ink border.
|
||||
return isOn
|
||||
? "items-center tint border-2 border-foreground px-[11px] font-semibold [--tint-ink:var(--foreground)]"
|
||||
: "items-center border border-input bg-card px-3 enabled:hover:bg-muted";
|
||||
}
|
||||
|
||||
const HINT_CLASS = "text-sm font-medium text-muted-foreground";
|
||||
|
||||
function joinIds(...ids: (string | false | undefined)[]): string | undefined {
|
||||
return ids.filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
|
||||
/** The tick cell of the today list: a 44 px honey cell that fills and draws its tick. */
|
||||
function TaskCell({ label, checked, onCheckedChange, disabled, id, describedBy, className }: CheckboxFieldProps) {
|
||||
return (
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-describedby={describedBy}
|
||||
className={cn("honey-check", className)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <TaskCell {...props} />;
|
||||
|
||||
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 ? (
|
||||
<span id={hintId} className={HINT_CLASS}>
|
||||
{disabledHint}
|
||||
</span>
|
||||
) : null;
|
||||
const text = (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span id={labelId} className={cn(dimmed)}>
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={descriptionId} className={cn("text-sm font-normal text-muted-foreground", dimmed)}>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
{isTile ? null : hint}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<Switch {...controlProps} checked={checked === true} onCheckedChange={onCheckedChange}>
|
||||
{text}
|
||||
<SwitchMark checked={checked === true} className={cn(dimmed)} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
const control = (
|
||||
<Checkbox {...controlProps} checked={checked} onCheckedChange={onCheckedChange}>
|
||||
<CheckboxMark checked={checked} className={cn(!isTile && "mt-0.5", dimmed)} />
|
||||
{text}
|
||||
</Checkbox>
|
||||
);
|
||||
if (!isTile || !hint) return control;
|
||||
// The hint of a dimmed tile sits under it at full strength.
|
||||
return (
|
||||
<span className="inline-flex flex-col gap-1">
|
||||
{control}
|
||||
{hint}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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")),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((colony) => (
|
||||
<li key={colony.id}>
|
||||
<label
|
||||
htmlFor={`${id}-${colony.id}`}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 focus-ring-within"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${colony.id}`}
|
||||
className="size-6"
|
||||
aria-labelledby={`${id}-${colony.id}-label`}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
<span id={`${id}-${colony.id}-label`} className="text-base">
|
||||
{colonyOptionLabel(colony)}
|
||||
</span>
|
||||
</label>
|
||||
<CheckboxField
|
||||
id={`${id}-${colony.id}`}
|
||||
size="sheet"
|
||||
className="border border-border px-3"
|
||||
label={colonyOptionLabel(colony)}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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 (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 focus-ring-within">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span id={`${id}-label`} className="font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={`${id}-description`} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-describedby={describedBy}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
checked
|
||||
? "border-foreground bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-foreground bg-primary text-primary-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="size-3.5" /> : null}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ernten</Label>
|
||||
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-2">
|
||||
<span id={labelId} className="text-sm font-medium">
|
||||
Ernten
|
||||
</span>
|
||||
{(harvests ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Ernten erfasst.</p>
|
||||
) : null}
|
||||
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
|
||||
{(harvests ?? []).map((harvest) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={harvest.id}
|
||||
className="flex min-h-11 items-center gap-3 text-sm"
|
||||
htmlFor={`${idPrefix}-${harvest.id}`}
|
||||
>
|
||||
<input
|
||||
id={`${idPrefix}-${harvest.id}`}
|
||||
type="checkbox"
|
||||
className="size-5 shrink-0"
|
||||
checked={value.includes(harvest.id)}
|
||||
onChange={(event) => toggle(harvest.id, event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
{harvest.netWeight !== null ? ` · ${harvest.netWeight} kg` : ""}
|
||||
{harvest.colonies.length > 0
|
||||
? ` · ${harvest.colonies.map((entry) => entry.colony.code).join(", ")}`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
<li className="today-row" data-leaving={leaving}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={leaving}
|
||||
aria-label={`„${task.title}“ als erledigt markieren`}
|
||||
className="honey-check"
|
||||
<CheckboxField
|
||||
appearance="task"
|
||||
label={`„${task.title}“ als erledigt markieren`}
|
||||
checked={leaving}
|
||||
disabled={leaving || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
onClick={onComplete}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
describedBy={describedBy}
|
||||
onCheckedChange={(next) => {
|
||||
if (next) onComplete();
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
|
||||
<span className="line-clamp-2 font-medium">{task.title}</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
|
||||
@@ -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 (
|
||||
<Checkbox
|
||||
aria-label="Aktiv"
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
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(<CheckboxMark checked={false} />);
|
||||
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(<CheckboxMark checked />);
|
||||
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(<CheckboxMark checked="indeterminate" />);
|
||||
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(<Harness onChange={onChange} />);
|
||||
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(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Checkbox aria-label="Aktiv" checked={false} onCheckedChange={onChange} onKeyDown={onKeyDown} />
|
||||
<button type="submit">Speichern</button>
|
||||
</form>,
|
||||
);
|
||||
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(<Checkbox aria-label="Aktiv" checked={false} disabled onCheckedChange={() => {}} />);
|
||||
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(<Switch aria-label="Erlauben" checked onCheckedChange={onChange} />);
|
||||
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(<SwitchMark checked={false} />);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
expect(container.innerHTML).not.toContain("translate-x-5");
|
||||
});
|
||||
|
||||
it("the thumb only slides with full motion", () => {
|
||||
const { container } = render(<SwitchMark checked />);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="checkbox-mark"
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-[6px] border-2",
|
||||
isOn ? "border-foreground bg-primary text-primary-foreground" : "border-input bg-card",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{checked === "indeterminate" ? (
|
||||
<Minus className="size-3.5" strokeWidth={3} />
|
||||
) : checked ? (
|
||||
<Check className="size-3.5" strokeWidth={3} />
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
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}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="size-3" strokeWidth={3} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
{children ?? <CheckboxMark checked={checked} />}
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox, CheckboxMark }
|
||||
@@ -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 (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="switch-mark"
|
||||
className={cn(
|
||||
"flex h-7 w-12 shrink-0 items-center rounded-full border-2 p-0.5",
|
||||
checked ? "border-foreground bg-primary" : "border-input bg-muted",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"grid size-5 place-items-center rounded-full bg-card text-foreground shadow-xs motion-full:transition-transform",
|
||||
checked && "translate-x-5"
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="size-3.5" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface SwitchProps
|
||||
extends Omit<SwitchPrimitive.Root.Props, "checked" | "onCheckedChange" | "nativeButton" | "render" | "children"> {
|
||||
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 (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => onCheckedChange?.(next)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <SwitchMark checked={checked} />}
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch, SwitchMark }
|
||||
@@ -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 <CheckboxField> 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`(?<![\w[-])(?:${keys})\s*(?:=\s*(?:\{[^}]*?)?|:\s*[^,;}\n]*?)${quoted}`;
|
||||
const setAttribute = String.raw`setAttribute\(\s*${quote}(?:${keys})${quote}\s*,\s*${quoted}`;
|
||||
return new RegExp(String.raw`(?<!<CheckboxField\b[^<>]*)(?:${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 <CheckboxField> 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 <CheckboxField role="switch"> 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(… => <CheckboxField` (optionally inside an <li>) and the container opened before it.
|
||||
const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) =>
|
||||
[...text.matchAll(/\.map\(\s*\([^)]*\)\s*=>\s*\(?\s*(?:<li\b[^>]*>\s*)?<CheckboxField\b/g)].map((match) => {
|
||||
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('<input type="checkbox" checked={on} />')).toBe(true);
|
||||
expect(checkbox.test('<button type="button" role="checkbox" aria-checked={on}>')).toBe(true);
|
||||
expect(checkbox.test("<span role={'checkbox'}>")).toBe(true);
|
||||
expect(checkbox.test('{ type: "checkbox" }')).toBe(true);
|
||||
expect(checkbox.test(`querySelector('[role="checkbox"]')`)).toBe(false);
|
||||
expect(checkbox.test('<CheckboxField label="Aktiv" role="checkbox" checked={on} />')).toBe(false);
|
||||
expect(checkbox.test('role?: "checkbox" | "switch"')).toBe(false);
|
||||
const toggle = rule("no role switch outside ui/checkbox.tsx and ui/switch.tsx").pattern;
|
||||
expect(toggle.test('<input type="checkbox" role="switch" />')).toBe(true);
|
||||
expect(toggle.test('{ role: "switch" }')).toBe(true);
|
||||
expect(toggle.test('<CheckboxField\n role="switch"\n label="Mit Details"\n/>')).toBe(false);
|
||||
expect(toggle.test('role === "switch"')).toBe(false);
|
||||
expect(checkbox.test('<span role={isTask ? "checkbox" : undefined}>')).toBe(true);
|
||||
expect(checkbox.test('<span role={isTask ? undefined : "checkbox"}>')).toBe(true);
|
||||
expect(checkbox.test('element.setAttribute("role", "checkbox")')).toBe(true);
|
||||
expect(checkbox.test("element.setAttribute('type','checkbox')")).toBe(true);
|
||||
expect(checkbox.test('{ role: on ? "checkbox" : "button" }')).toBe(true);
|
||||
expect(checkbox.test('role === "checkbox"')).toBe(false);
|
||||
expect(toggle.test('<button role={on ? "switch" : "button"}>')).toBe(true);
|
||||
expect(toggle.test('node.setAttribute("role", "switch")')).toBe(true);
|
||||
// The CheckboxField exemption ends with its own tag: a later element is caught again.
|
||||
expect(toggle.test('<CheckboxField label="A" />\n<input role="switch" />')).toBe(true);
|
||||
expect(checkbox.test('<CheckboxField label="A" /><span role="checkbox" />')).toBe(true);
|
||||
expect(rule("no text-white").pattern.test('"text-white/80"')).toBe(true);
|
||||
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user