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

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

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

No files matched your search

@@ -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/);
});
});
+65 -10
View File
@@ -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 }