fix: Paket 0 – data integrity, security hardening and field usability
Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f5b1964223
commit
10cbe9bf2d
310 files changed
+12177
-3746
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonySelectField, LivingColonySelectField } from "./colony-select-field";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import type { ColonyStatus } from "@/lib/colonies/types";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
|
||||
function colony(id: string, code: string, status: ColonyStatus) {
|
||||
return { id, code, status, apiaryId: "apiary-1" };
|
||||
}
|
||||
|
||||
const COLONIES = [
|
||||
colony("c-active", "V-01", "ACTIVE"),
|
||||
colony("c-weak", "V-02", "WEAK"),
|
||||
colony("c-queenless", "V-03", "QUEENLESS"),
|
||||
colony("c-conspicuous", "V-04", "CONSPICUOUS"),
|
||||
colony("c-dissolved", "V-05", "DISSOLVED"),
|
||||
colony("c-sold", "V-06", "SOLD"),
|
||||
];
|
||||
|
||||
function withClient(ui: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function LivingHarness({ initial = "" }: { initial?: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<LivingColonySelectField triggerId="test-colony" value={value} onChange={setValue} />
|
||||
);
|
||||
}
|
||||
|
||||
function AllHarness({ initial }: { initial: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return <ColonySelectField id="test-edit-colony" value={value} onChange={setValue} />;
|
||||
}
|
||||
|
||||
async function openOptions() {
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
return screen.findAllByRole("option");
|
||||
}
|
||||
|
||||
describe("LivingColonySelectField", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
|
||||
});
|
||||
|
||||
it("offers weak, queenless and conspicuous colonies - not only active ones", async () => {
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
const options = await openOptions();
|
||||
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"V-01",
|
||||
"V-02 (Schwach)",
|
||||
"V-03 (Weisellos)",
|
||||
"V-04 (Auffällig)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the chosen colony by its code, not by its internal id", async () => {
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
await openOptions();
|
||||
await userEvent.click(screen.getByRole("option", { name: "V-03 (Weisellos)" }));
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("V-03 (Weisellos)");
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("c-queenless");
|
||||
});
|
||||
|
||||
it("still names an ended colony handed in from a link, without offering it", async () => {
|
||||
withClient(<LivingHarness initial="c-dissolved" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("V-05 (Aufgelöst)"),
|
||||
);
|
||||
const options = await openOptions();
|
||||
expect(options.map((option) => option.textContent)).not.toContain("V-05 (Aufgelöst)");
|
||||
});
|
||||
|
||||
it("says a colony is unavailable once loaded instead of loading forever", async () => {
|
||||
withClient(<LivingHarness initial="c-unknown" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Nicht mehr verfügbar"),
|
||||
);
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("c-unknown");
|
||||
});
|
||||
|
||||
it("says so when the colonies could not be loaded instead of loading forever", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new Error("offline"));
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Volk wählen");
|
||||
});
|
||||
|
||||
it("explains what to do when there is no living colony yet", async () => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue([colony("c-sold", "V-06", "SOLD")] as never);
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Noch keine aktiven Völker/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColonySelectField", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
|
||||
});
|
||||
|
||||
it("is a regular form field: labelled, marked required and able to show an error", async () => {
|
||||
withClient(
|
||||
<ColonySelectField
|
||||
id="edit-colony"
|
||||
value="c-active"
|
||||
onChange={() => undefined}
|
||||
error="Bitte ein Volk auswählen."
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByLabelText(/^Volk/);
|
||||
expect(trigger).toHaveAttribute("id", "edit-colony");
|
||||
expect(trigger).toHaveAttribute("aria-invalid", "true");
|
||||
expect(trigger).toHaveAccessibleDescription("Bitte ein Volk auswählen.");
|
||||
expect(screen.getByText("Pflicht")).toBeInTheDocument();
|
||||
expect(trigger).not.toHaveAttribute("data-field");
|
||||
});
|
||||
|
||||
it("keeps ended colonies selectable and names their real status", async () => {
|
||||
withClient(<AllHarness initial="c-sold" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("V-06 (Verkauft)"),
|
||||
);
|
||||
const options = await openOptions();
|
||||
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"V-01",
|
||||
"V-02 (Schwach)",
|
||||
"V-03 (Weisellos)",
|
||||
"V-04 (Auffällig)",
|
||||
"V-05 (Aufgelöst)",
|
||||
"V-06 (Verkauft)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,117 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
const PLACEHOLDER = "Volk wählen";
|
||||
const LOADING_PLACEHOLDER = "Völker werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
|
||||
function useColonies() {
|
||||
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
}
|
||||
|
||||
interface ColonyChoicesProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
/** The colonies on offer; `undefined` while loading. */
|
||||
options: readonly Colony[] | undefined;
|
||||
/** Every known colony, so a chosen one is named even if it is not offered. */
|
||||
colonies: readonly Colony[] | undefined;
|
||||
isLoading: boolean;
|
||||
dataField?: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */
|
||||
function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) {
|
||||
return (
|
||||
<RecordSelect
|
||||
{...props}
|
||||
items={options}
|
||||
labelItems={colonies}
|
||||
getLabel={colonyOptionLabel}
|
||||
placeholder={isLoading ? LOADING_PLACEHOLDER : PLACEHOLDER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LivingColonySelectFieldProps {
|
||||
/** Id of the select trigger, used to focus it on a validation error. */
|
||||
triggerId: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
error?: string | null;
|
||||
label?: string;
|
||||
/** A colony that must not be offered, e.g. the one another is merged into. */
|
||||
excludeId?: string;
|
||||
/** Shown when there is nothing to choose from. */
|
||||
emptyHint?: string;
|
||||
/** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */
|
||||
dataField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a colony that still lives in the apiary: the one a new record
|
||||
* (inspection, varroa count, feeding, treatment, swarm control check) is filed
|
||||
* under, the colony a queen moves into, the colony merged into another.
|
||||
* Weak, conspicuous and queenless colonies are included - they need the most
|
||||
* attention - and none that has ended. A colony handed in by a link that has
|
||||
* ended since is still named, just not offered.
|
||||
*/
|
||||
export function LivingColonySelectField({
|
||||
triggerId,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
label = "Volk",
|
||||
excludeId,
|
||||
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
|
||||
dataField = "colonyId",
|
||||
}: LivingColonySelectFieldProps) {
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
const living = colonies?.filter(
|
||||
(colony) => isLivingColony(colony) && colony.id !== excludeId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={living}
|
||||
colonies={colonies}
|
||||
isLoading={isLoading}
|
||||
dataField={dataField}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColonySelectFieldProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
label?: string;
|
||||
error?: string | null;
|
||||
dataField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,37 +122,41 @@ interface ColonySelectFieldProps {
|
||||
* against the wrong colony is a data-entry error rather than a fact of
|
||||
* beekeeping - and the only alternative repair would be deleting the record.
|
||||
*
|
||||
* Dissolved colonies stay in the list: a record already filed against one has
|
||||
* Ended colonies stay in the list: a record already filed against one has
|
||||
* to remain visible and selectable, otherwise opening the form would silently
|
||||
* re-file it somewhere else.
|
||||
*/
|
||||
export function ColonySelectField({
|
||||
id,
|
||||
id: triggerId,
|
||||
value,
|
||||
onChange,
|
||||
label = "Volk",
|
||||
error,
|
||||
dataField,
|
||||
}: ColonySelectFieldProps) {
|
||||
const { data: colonies } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(colonies ?? []).map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
{colony.status === "ACTIVE" ? "" : " (aufgelöst)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : undefined}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={colonies}
|
||||
colonies={colonies}
|
||||
isLoading={isLoading}
|
||||
dataField={dataField}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group";
|
||||
|
||||
type Feed = "ausreichend" | "niedrig" | "kritisch";
|
||||
|
||||
const FEED_OPTIONS: ChoiceOption<Feed | null>[] = [
|
||||
{ value: "ausreichend", label: "Ausreichend" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "kritisch", label: "Kritisch" },
|
||||
{ value: null, label: "Nicht geprüft" },
|
||||
];
|
||||
|
||||
function Harness({
|
||||
initial = null,
|
||||
clearable = false,
|
||||
onChange,
|
||||
}: {
|
||||
initial?: Feed | null;
|
||||
clearable?: boolean;
|
||||
onChange?: (value: Feed | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<Feed | null>(initial);
|
||||
return (
|
||||
<ChoiceGroup
|
||||
label="Futter"
|
||||
options={FEED_OPTIONS}
|
||||
value={value}
|
||||
clearValue={clearable ? null : undefined}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ChoiceGroup", () => {
|
||||
it("is one named radio group with one large radio per option", () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole("radiogroup", { name: "Futter" })).toBeInTheDocument();
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios.map((radio) => radio.textContent)).toEqual([
|
||||
"Ausreichend",
|
||||
"Niedrig",
|
||||
"Kritisch",
|
||||
"Nicht geprüft",
|
||||
]);
|
||||
for (const radio of radios) {
|
||||
expect(radio.className).toContain("min-h-11");
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the current value - null included - as checked", () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Niedrig" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects with a single tap", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Kritisch" }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith("kritisch");
|
||||
expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a tap on the chosen option as is unless the group is clearable", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<Harness initial="niedrig" onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Niedrig" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith("niedrig");
|
||||
unmount();
|
||||
|
||||
render(<Harness initial="niedrig" clearable onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Niedrig" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("is reached with one tab stop and moved through with the arrow keys", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial="kritisch" onChange={onChange} />);
|
||||
|
||||
await userEvent.tab();
|
||||
expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveFocus();
|
||||
expect(
|
||||
screen.getAllByRole("radio").filter((radio) => radio.tabIndex === 0),
|
||||
).toHaveLength(1);
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
|
||||
expect(onChange).toHaveBeenLastCalledWith(null);
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus();
|
||||
expect(onChange).toHaveBeenLastCalledWith("ausreichend");
|
||||
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{Home}");
|
||||
expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{End}");
|
||||
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps the visible label inside the spoken name (WCAG 2.5.3)", () => {
|
||||
render(
|
||||
<ChoiceGroup
|
||||
label="Sanftmut"
|
||||
options={[{ value: 4, label: "4", srSuffix: "von 5" }]}
|
||||
value={null}
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const radio = screen.getByRole("radio", { name: "4 von 5" });
|
||||
expect(radio).not.toHaveAttribute("aria-label");
|
||||
expect(radio).toHaveAccessibleName(expect.stringContaining("4"));
|
||||
});
|
||||
|
||||
it("tells apart options whose values only look alike", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ChoiceGroup<string | boolean | null>
|
||||
label="Gemischt"
|
||||
options={[
|
||||
{ value: "null", label: "Text null" },
|
||||
{ value: null, label: "Nichts" },
|
||||
{ value: "true", label: "Text true" },
|
||||
{ value: true, label: "Ja" },
|
||||
]}
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Text null" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith("null");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Ja" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||
expect(
|
||||
consoleError.mock.calls.some((call) => String(call[0]).includes("same key")),
|
||||
).toBe(false);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("makes the first option the tab stop while nothing is chosen", () => {
|
||||
render(
|
||||
<ChoiceGroup
|
||||
label="Methode"
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
value={""}
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "A" })).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("choiceOptions", () => {
|
||||
const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const;
|
||||
|
||||
it("offers every value of a label map in its order - no cast needed", () => {
|
||||
const options = choiceOptions(MOOD_LABELS);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: "NONE", label: "Keine" },
|
||||
{ value: "LOW", label: "Leicht" },
|
||||
{ value: "HIGH", label: "Stark" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only the given values, in the given order", () => {
|
||||
expect(choiceOptions(MOOD_LABELS, ["HIGH", "NONE"])).toEqual([
|
||||
{ value: "HIGH", label: "Stark" },
|
||||
{ value: "NONE", label: "Keine" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardEvent, useRef } from "react";
|
||||
import { keysOf } from "@/lib/keys-of";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChoiceValue = string | number | boolean | null;
|
||||
|
||||
export interface ChoiceOption<T extends ChoiceValue> {
|
||||
value: T;
|
||||
label: string;
|
||||
/**
|
||||
* Words only screen readers hear after the label, e.g. "von 5" for "4".
|
||||
* Appended instead of an aria-label, so the spoken name always contains
|
||||
* the visible label (voice control users say what they see).
|
||||
*/
|
||||
srSuffix?: string;
|
||||
}
|
||||
|
||||
/** Distinct per value and type: "null" (text) and null must not collide. */
|
||||
function optionKey(value: ChoiceValue): string {
|
||||
return `${value === null ? "null" : typeof value}:${String(value)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options from a label map (an enum's labels), named like EnumSelect: all of
|
||||
* its values in map order, or only `values` in that order. Typed from the
|
||||
* map, so callers need no cast.
|
||||
*/
|
||||
export function choiceOptions<T extends string>(
|
||||
labels: Readonly<Record<T, string>>,
|
||||
values: readonly NoInfer<T>[] = keysOf(labels),
|
||||
): ChoiceOption<T>[] {
|
||||
return values.map((value) => ({ value, label: labels[value] }));
|
||||
}
|
||||
|
||||
interface ChoiceGroupProps<T extends ChoiceValue> {
|
||||
options: readonly ChoiceOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
id?: string;
|
||||
/** Id of a visible label; use `label` when there is none. */
|
||||
labelledBy?: string;
|
||||
label?: string;
|
||||
describedBy?: string;
|
||||
/** If set, tapping the chosen option again sets this value ("none"). */
|
||||
clearValue?: T;
|
||||
className?: string;
|
||||
/** Extra classes for every option button, e.g. a larger font. */
|
||||
optionClassName?: string;
|
||||
}
|
||||
|
||||
const KEY_STEPS: Record<string, number> = {
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
};
|
||||
|
||||
function targetIndex(key: string, current: number, count: number): number | null {
|
||||
if (key === "Home") return 0;
|
||||
if (key === "End") return count - 1;
|
||||
const step = KEY_STEPS[key];
|
||||
if (step === undefined) return null;
|
||||
return (current + step + count) % count;
|
||||
}
|
||||
|
||||
/**
|
||||
* One large button per option instead of a dropdown: a single tap with
|
||||
* gloves, and every choice stays visible. A `null` option stands for "not
|
||||
* answered" (e.g. "Nicht geprüft") and is shown muted when chosen, so the
|
||||
* observations that were really made stand out.
|
||||
*
|
||||
* Accessible as a radio group: one tab stop, arrow keys move and select.
|
||||
*/
|
||||
export function ChoiceGroup<T extends ChoiceValue>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
labelledBy,
|
||||
label,
|
||||
describedBy,
|
||||
clearValue,
|
||||
className,
|
||||
optionClassName,
|
||||
}: ChoiceGroupProps<T>) {
|
||||
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const checkedIndex = options.findIndex((option) => option.value === value);
|
||||
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
const next = targetIndex(event.key, index, options.length);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
radioRefs.current[next]?.focus();
|
||||
onChange(options[next].value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="radiogroup"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-label={labelledBy ? undefined : label}
|
||||
aria-describedby={describedBy}
|
||||
className={cn("grid gap-2", className)}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const selected = index === checkedIndex;
|
||||
const isUnanswered = option.value === null;
|
||||
return (
|
||||
<button
|
||||
key={optionKey(option.value)}
|
||||
ref={(element) => {
|
||||
radioRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={index === tabStop ? 0 : -1}
|
||||
onClick={() =>
|
||||
onChange(selected && clearValue !== undefined ? clearValue : option.value)
|
||||
}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
!selected && "border-border bg-background hover:bg-muted",
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
"border-primary bg-primary font-medium text-primary-foreground",
|
||||
optionClassName,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.srSuffix ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="sr-only">{option.srSuffix}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EnumSelect } from "./enum-select";
|
||||
|
||||
type Mood = "NONE" | "LOW" | "HIGH";
|
||||
|
||||
const MOOD_LABELS: Record<Mood, string> = {
|
||||
NONE: "Keine",
|
||||
LOW: "Leicht",
|
||||
HIGH: "Stark",
|
||||
};
|
||||
|
||||
function Required({ onChange }: { onChange?: (value: Mood) => void }) {
|
||||
const [value, setValue] = useState<Mood>("NONE");
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="mood">Schwarmstimmung</label>
|
||||
<EnumSelect
|
||||
id="mood"
|
||||
describedBy="mood-hint"
|
||||
invalid
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
labels={MOOD_LABELS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ onChange }: { onChange?: (value: Mood | "") => void }) {
|
||||
const [value, setValue] = useState<Mood | "">("HIGH");
|
||||
return (
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Stimmung filtern"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
values={["HIGH", "LOW"]}
|
||||
labels={MOOD_LABELS}
|
||||
noneLabel="Alle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function optionTexts() {
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
return (await screen.findAllByRole("option")).map((option) => option.textContent);
|
||||
}
|
||||
|
||||
describe("EnumSelect", () => {
|
||||
it("shows the label of the value, offers every value and reports the typed choice", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Required onChange={onChange} />);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Schwarmstimmung" })).toHaveTextContent("Keine");
|
||||
expect(await optionTexts()).toEqual(["Keine", "Leicht", "Stark"]);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Stark" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("HIGH");
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Stark");
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("HIGH");
|
||||
});
|
||||
|
||||
it("passes the field's description and error state to the trigger", () => {
|
||||
render(<Required />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
expect(trigger).toHaveAttribute("aria-describedby", "mood-hint");
|
||||
expect(trigger).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("offers only the given values, in their order, after the 'none' choice", async () => {
|
||||
render(<Filter />);
|
||||
|
||||
expect(await optionTexts()).toEqual(["Alle", "Stark", "Leicht"]);
|
||||
});
|
||||
|
||||
it("reports the 'none' choice as an empty value and names it", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Filter onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Nach Stimmung filtern" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Alle" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Alle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EnumSelect with decorated options", () => {
|
||||
function Decorated({ onChange }: { onChange?: (value: Mood) => void }) {
|
||||
const [value, setValue] = useState<Mood>("LOW");
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="mood-decorated">Schwarmstimmung</label>
|
||||
<EnumSelect
|
||||
id="mood-decorated"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
labels={MOOD_LABELS}
|
||||
renderOption={(mood) => (
|
||||
<span data-testid={`badge-${mood}`} className={`tone-${mood}`}>
|
||||
● {MOOD_LABELS[mood]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it("shows the decoration in the trigger and in every option row", async () => {
|
||||
render(<Decorated />);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "Schwarmstimmung" });
|
||||
expect(trigger.querySelector('[data-testid="badge-LOW"]')).not.toBeNull();
|
||||
await userEvent.click(trigger);
|
||||
for (const mood of ["NONE", "LOW", "HIGH"]) {
|
||||
expect(
|
||||
(await screen.findAllByTestId(`badge-${mood}`)).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the plain label as the spoken name, not the decoration", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Decorated onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" }));
|
||||
const option = await screen.findByRole("option", { name: "Stark" });
|
||||
await userEvent.click(option);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("HIGH");
|
||||
for (const badge of screen.getAllByTestId("badge-HIGH")) {
|
||||
expect(badge.closest("[aria-hidden='true']")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders exactly as before without a decoration", async () => {
|
||||
render(<Required />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" }));
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual(["Keine", "Leicht", "Stark"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { keysOf } from "@/lib/keys-of";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/** Base UI needs a real item value for the "none" choice; outside it is "". */
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
interface EnumSelectBaseProps<T extends string> {
|
||||
/** Id of the trigger - pass the one from `Field` so its label points here. */
|
||||
id?: string;
|
||||
/** Accessible name when there is no visible label pointing at `id`. */
|
||||
ariaLabel?: string;
|
||||
/** How each value is named, in the list and in the trigger. */
|
||||
labels: Record<T, string>;
|
||||
/** The values on offer, in this order; all keys of `labels` by default. */
|
||||
values?: readonly NoInfer<T>[];
|
||||
/**
|
||||
* Optional visual form of a value (e.g. a coloured badge), shown in the
|
||||
* option rows and the trigger. Decoration only: screen readers still get
|
||||
* the plain label from `labels`.
|
||||
*/
|
||||
renderOption?: (value: NoInfer<T>) => ReactNode;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// T is inferred from `labels` only (NoInfer), so a "" value does not widen it.
|
||||
type EnumSelectProps<T extends string> = EnumSelectBaseProps<T> &
|
||||
(
|
||||
| { noneLabel?: undefined; value: NoInfer<T>; onChange: (value: NoInfer<T>) => void }
|
||||
| {
|
||||
/**
|
||||
* Offers an explicit first choice meaning "none" or "all" (e.g. "Alle
|
||||
* Status"). It stands for the empty value "".
|
||||
*/
|
||||
noneLabel: string;
|
||||
value: NoInfer<T> | "";
|
||||
onChange: (value: NoInfer<T> | "") => void;
|
||||
}
|
||||
);
|
||||
|
||||
/** The label as text - or, with a decoration, the decoration for the eye and the label for screen readers. */
|
||||
function OptionLabel({ label, decoration }: { label: string; decoration?: ReactNode }) {
|
||||
if (decoration === undefined) return label;
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden="true" className="contents">
|
||||
{decoration}
|
||||
</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A select over a fixed set of values (status, category, type ...), named by
|
||||
* a label map. The change handler gets the typed value back, so callers need
|
||||
* no `as` cast, and the trigger always shows the label - never the raw value.
|
||||
* Counterpart of RecordSelect, which picks records by id.
|
||||
*/
|
||||
export function EnumSelect<T extends string>(props: EnumSelectProps<T>) {
|
||||
const { id, ariaLabel, labels, renderOption, describedBy, invalid, disabled, className } = props;
|
||||
const values = props.values ?? keysOf(labels);
|
||||
|
||||
function handleValueChange(next: unknown) {
|
||||
if (props.noneLabel !== undefined && next === NONE_VALUE) {
|
||||
props.onChange("");
|
||||
return;
|
||||
}
|
||||
const chosen = values.find((value) => value === next);
|
||||
if (chosen !== undefined) props.onChange(chosen);
|
||||
}
|
||||
|
||||
const selectValue = props.value === "" ? NONE_VALUE : props.value;
|
||||
const triggerLabel =
|
||||
props.value === "" ? (
|
||||
props.noneLabel
|
||||
) : (
|
||||
<OptionLabel label={labels[props.value]} decoration={renderOption?.(props.value)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={selectValue} onValueChange={handleValueChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
className={cn("h-11 w-full", className)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue>{triggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.noneLabel !== undefined ? (
|
||||
<SelectItem value={NONE_VALUE}>{props.noneLabel}</SelectItem>
|
||||
) : null}
|
||||
{values.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<OptionLabel label={labels[value]} decoration={renderOption?.(value)} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Field } from "./field";
|
||||
|
||||
describe("Field", () => {
|
||||
it("labels a single control with label-for", () => {
|
||||
render(<Field label="Datum">{({ id }) => <input id={id} type="date" />}</Field>);
|
||||
|
||||
expect(screen.getByLabelText(/Datum/)).toHaveAttribute("type", "date");
|
||||
});
|
||||
|
||||
it("uses a given id for the control, e.g. a select trigger focused on errors", () => {
|
||||
render(
|
||||
<Field label="Volk" id="inspection-colony" required>
|
||||
{({ id }) => <button id={id} type="button" />}
|
||||
</Field>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/Volk/)).toHaveAttribute("id", "inspection-colony");
|
||||
});
|
||||
|
||||
it("names a group of buttons without a label-for that points at a div", () => {
|
||||
const { container } = render(
|
||||
<Field label="Futterart" group hint="Eine Art antippen.">
|
||||
{({ id, labelId, describedBy }) => (
|
||||
<div id={id} role="radiogroup" aria-labelledby={labelId} aria-describedby={describedBy}>
|
||||
<button type="button" role="radio" aria-checked="false">
|
||||
Zuckerwasser
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("label")).toBeNull();
|
||||
const group = screen.getByRole("radiogroup", { name: /^Futterart/ });
|
||||
expect(group).toHaveAccessibleDescription("Eine Art antippen.");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,14 @@ interface FieldProps {
|
||||
hint?: string;
|
||||
error?: string | null;
|
||||
className?: string;
|
||||
/** Id for the control instead of a generated one. */
|
||||
id?: string;
|
||||
/**
|
||||
* The child is a group of buttons (radio group, chips), not one form
|
||||
* control. A <label for> may only point at a labelable element, so the
|
||||
* caption becomes plain text the group references via `labelId`.
|
||||
*/
|
||||
group?: boolean;
|
||||
/** Receives the ids the control must carry for label, hint and error. */
|
||||
children: (ids: {
|
||||
id: string;
|
||||
@@ -28,18 +36,22 @@ export function Field({
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
id: idProp,
|
||||
group = false,
|
||||
children,
|
||||
}: FieldProps) {
|
||||
const id = useId();
|
||||
const generatedId = useId();
|
||||
const id = idProp ?? generatedId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
const errorId = error ? `${id}-error` : undefined;
|
||||
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
const Caption = group ? "span" : "label";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<label
|
||||
<Caption
|
||||
id={`${id}-label`}
|
||||
htmlFor={id}
|
||||
htmlFor={group ? undefined : id}
|
||||
className="flex items-baseline gap-2 text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
@@ -51,7 +63,7 @@ export function Field({
|
||||
>
|
||||
{required ? "Pflicht" : "optional"}
|
||||
</span>
|
||||
</label>
|
||||
</Caption>
|
||||
{children({
|
||||
id,
|
||||
labelId: `${id}-label`,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
|
||||
|
||||
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = [1, 2, 3, 4, 5].map(
|
||||
(rating) => ({ value: rating, label: String(rating), srSuffix: "von 5" }),
|
||||
);
|
||||
|
||||
interface RatingScaleProps {
|
||||
id: string;
|
||||
@@ -30,35 +34,17 @@ export function RatingScale({
|
||||
}: RatingScaleProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
role="radiogroup"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-describedby={describedBy}
|
||||
className="grid grid-cols-5 gap-2"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((option) => {
|
||||
const selected = value === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={`${option} von 5`}
|
||||
onClick={() => onChange(selected ? null : option)}
|
||||
className={cn(
|
||||
"h-11 rounded-lg border text-base font-semibold motion-safe:transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
labelledBy={labelledBy}
|
||||
describedBy={describedBy}
|
||||
options={RATING_OPTIONS}
|
||||
value={value}
|
||||
clearValue={null}
|
||||
onChange={onChange}
|
||||
className="grid-cols-5"
|
||||
optionClassName="text-base font-semibold"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>1 = {lowLabel}</span>
|
||||
<span>5 = {highLabel}</span>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RecordSelect } from "./record-select";
|
||||
|
||||
interface Apiary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const APIARIES: Apiary[] = [
|
||||
{ id: "cmg3xabc", name: "Obstwiese" },
|
||||
{ id: "cmg3xdef", name: "Waldrand" },
|
||||
];
|
||||
|
||||
function Harness({
|
||||
initial = "",
|
||||
loading = false,
|
||||
noneLabel,
|
||||
onChange,
|
||||
}: {
|
||||
initial?: string;
|
||||
loading?: boolean;
|
||||
noneLabel?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<RecordSelect
|
||||
id="test-apiary"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
items={loading ? undefined : APIARIES}
|
||||
getLabel={(apiary) => apiary.name}
|
||||
placeholder="Bienenstand wählen"
|
||||
noneLabel={noneLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RecordSelect", () => {
|
||||
it("names the chosen record in the trigger, not its internal id", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("cmg3xdef");
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Waldrand");
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xdef");
|
||||
});
|
||||
|
||||
it("names a record that was chosen before the form opened", () => {
|
||||
render(<Harness initial="cmg3xabc" />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Obstwiese");
|
||||
});
|
||||
|
||||
it("shows the placeholder while nothing is chosen", () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Bienenstand wählen");
|
||||
});
|
||||
|
||||
it("says it is loading instead of showing the id while the records load", () => {
|
||||
render(<Harness initial="cmg3xabc" loading />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Wird geladen…");
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xabc");
|
||||
});
|
||||
|
||||
it("never shows the id of a record that is not in the list", () => {
|
||||
render(<Harness initial="cmg3xgone" />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Nicht mehr verfügbar");
|
||||
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xgone");
|
||||
});
|
||||
|
||||
it("names a chosen record from a wider lookup list even if it is not offered", async () => {
|
||||
const closed = { id: "cmg3xold", name: "Alter Stand (aufgelöst)" };
|
||||
render(
|
||||
<RecordSelect
|
||||
id="test-apiary"
|
||||
value="cmg3xold"
|
||||
onChange={() => undefined}
|
||||
items={APIARIES}
|
||||
labelItems={[...APIARIES, closed]}
|
||||
getLabel={(apiary) => apiary.name}
|
||||
placeholder="Bienenstand wählen"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Alter Stand (aufgelöst)");
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual(["Obstwiese", "Waldrand"]);
|
||||
});
|
||||
|
||||
it("offers a 'none' choice first and names it while the value is empty", async () => {
|
||||
render(<Harness noneLabel="Kein Bienenstand" />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"Kein Bienenstand",
|
||||
"Obstwiese",
|
||||
"Waldrand",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the 'none' choice as an empty value", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial="cmg3xabc" noneLabel="Kein Bienenstand" onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Kein Bienenstand" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const LOADING_LABEL = "Wird geladen…";
|
||||
const UNAVAILABLE_LABEL = "Nicht mehr verfügbar";
|
||||
/** Base UI needs a real item value for the "none" choice; outside it is "". */
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
interface RecordSelectProps<T extends { id: string }> {
|
||||
/** Id of the trigger - pass the one from `Field` so its label points here. */
|
||||
id?: string;
|
||||
/** Accessible name when there is no visible label pointing at `id`. */
|
||||
ariaLabel?: string;
|
||||
/** The chosen record's id, or "" for none / nothing chosen yet. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The records on offer; `undefined` while they are still loading. */
|
||||
items: readonly T[] | undefined;
|
||||
/**
|
||||
* Where the chosen record is looked up for the trigger, when that is a
|
||||
* wider list than the offer - e.g. every colony, while only living ones can
|
||||
* be picked, so a colony handed in by a link is still named. Defaults to
|
||||
* `items`; load both from the same query so they arrive together.
|
||||
*/
|
||||
labelItems?: readonly T[] | undefined;
|
||||
/** How a record is named, in the list and in the trigger. */
|
||||
getLabel: (item: T) => string;
|
||||
/** Shown while nothing is chosen (not used together with `noneLabel`). */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Offers an explicit first choice meaning "none" (e.g. "Kein Produkt",
|
||||
* "Alle Bienenstände"). It stands for the empty value "".
|
||||
*/
|
||||
noneLabel?: string;
|
||||
/** `data-field` of the trigger, for focusing it on a validation error. */
|
||||
dataField?: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A select whose values are record ids (apiary, honey batch, container,
|
||||
* role ...). The trigger always names the chosen record: without explicit
|
||||
* children Base UI shows the raw value - an internal id nobody can read.
|
||||
* Same approach as the colony picker (colony-select-field.tsx).
|
||||
*/
|
||||
export function RecordSelect<T extends { id: string }>({
|
||||
id,
|
||||
ariaLabel,
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
labelItems = items,
|
||||
getLabel,
|
||||
placeholder,
|
||||
noneLabel,
|
||||
dataField,
|
||||
describedBy,
|
||||
invalid,
|
||||
disabled,
|
||||
className,
|
||||
}: RecordSelectProps<T>) {
|
||||
const selectValue = noneLabel !== undefined && value === "" ? NONE_VALUE : value;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(next) => onChange(!next || next === NONE_VALUE ? "" : next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
data-field={dataField}
|
||||
className={cn("h-11 w-full", className)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
>
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{noneLabel !== undefined ? <SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem> : null}
|
||||
{(items ?? []).map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{getLabel(item)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function triggerLabel<T extends { id: string }>({
|
||||
value,
|
||||
labelItems,
|
||||
getLabel,
|
||||
placeholder,
|
||||
noneLabel,
|
||||
}: Pick<RecordSelectProps<T>, "value" | "labelItems" | "getLabel" | "placeholder" | "noneLabel">) {
|
||||
if (!value) return noneLabel ?? placeholder;
|
||||
if (!labelItems) return LOADING_LABEL;
|
||||
const chosen = labelItems.find((item) => item.id === value);
|
||||
return chosen ? getLabel(chosen) : UNAVAILABLE_LABEL;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Keeps the one primary action within thumb reach on a phone, however long
|
||||
* the form is; on wider screens it simply ends the form.
|
||||
* Keeps the one primary action within thumb reach, however long the form is:
|
||||
* on a phone it sticks just above the bottom nav, on wider screens to the
|
||||
* bottom of the window (clear of the iOS home indicator).
|
||||
*
|
||||
* `position: sticky` only works while no ancestor between this bar and the
|
||||
* window is a scroll container (overflow auto/scroll/hidden).
|
||||
*
|
||||
* Buttons in the bar keep a 48px touch target on every breakpoint - the
|
||||
* default button size shrinks to 32px from `sm` up (`sm:h-8`), which would
|
||||
* otherwise win over the forms' `h-12`.
|
||||
*/
|
||||
export function StickySubmit({
|
||||
children,
|
||||
@@ -12,7 +20,7 @@ export function StickySubmit({
|
||||
note?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky bottom-16 z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
|
||||
<div className="sticky bottom-(--bottom-nav-height) z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur **:data-[slot=button]:min-h-12 sm:bottom-0 sm:pb-[calc(--spacing(3)+var(--safe-area-bottom))]">
|
||||
{note}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TriStateField } from "./tri-state-field";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: boolean | null;
|
||||
onChange: (value: boolean | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<TriStateField
|
||||
label="Königin gesehen"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TriStateField", () => {
|
||||
it("starts as 'Nicht geprüft' - an untouched observation is not a finding", () => {
|
||||
render(<Harness initial={null} onChange={vi.fn()} />);
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: "Königin gesehen" });
|
||||
expect(within(group).getAllByRole("radio").map((radio) => radio.textContent)).toEqual([
|
||||
"Ja",
|
||||
"Nein",
|
||||
"Nicht geprüft",
|
||||
]);
|
||||
expect(within(group).getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("records yes, no and back to not checked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={null} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Ja" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Nein" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(false);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Nicht geprüft" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows a stored 'Nein' as such", () => {
|
||||
render(<Harness initial={false} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Nein" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
|
||||
|
||||
export const TRI_STATE_OPTIONS: readonly ChoiceOption<boolean | null>[] = [
|
||||
{ value: true, label: "Ja" },
|
||||
{ value: false, label: "Nein" },
|
||||
{ value: null, label: "Nicht geprüft" },
|
||||
];
|
||||
|
||||
interface TriStateFieldProps {
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onChange: (value: boolean | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A yes/no observation that may also not have been checked at all. Starts as
|
||||
* "Nicht geprüft" (null), so only what the beekeeper actually looked at is
|
||||
* saved as a finding - an untouched "no" would otherwise read as "seen: none".
|
||||
*/
|
||||
export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={labelId} className="text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
labelledBy={labelId}
|
||||
options={TRI_STATE_OPTIONS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,13 +21,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,6 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
interface StockCardProps {
|
||||
path: StockBatchPath;
|
||||
@@ -238,7 +233,7 @@ function WithdrawDialog({
|
||||
withdrawStock(path, id, {
|
||||
quantity: Number(quantity),
|
||||
reason,
|
||||
notes: notes.trim() || undefined,
|
||||
notes: emptyToNull(notes),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
done();
|
||||
@@ -296,25 +291,12 @@ function WithdrawDialog({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="withdraw-reason">Grund</Label>
|
||||
<Select
|
||||
<EnumSelect
|
||||
id="withdraw-reason"
|
||||
value={reason}
|
||||
onValueChange={(value) =>
|
||||
setReason((value ?? reason) as WithdrawalReason)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="withdraw-reason" className="w-full">
|
||||
<SelectValue>{WITHDRAWAL_REASON_LABELS[reason]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]
|
||||
).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{WITHDRAWAL_REASON_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={setReason}
|
||||
labels={WITHDRAWAL_REASON_LABELS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="withdraw-notes">Notiz (optional)</Label>
|
||||
|
||||
@@ -12,13 +12,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchQueens } from "@/lib/queens/api";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
interface RelatedOption {
|
||||
id: string;
|
||||
@@ -98,54 +93,47 @@ export function TaskRelatedField({
|
||||
})),
|
||||
GENERAL: [],
|
||||
};
|
||||
const options = optionsByType[relatedType];
|
||||
const isLoadedByType: Record<TaskRelatedType, boolean> = {
|
||||
COLONY: colonies.data !== undefined,
|
||||
APIARY: apiaries.data !== undefined,
|
||||
QUEEN: queens.data !== undefined,
|
||||
HONEY_BATCH: honeyBatches.data !== undefined,
|
||||
STORAGE: containers.data !== undefined,
|
||||
GENERAL: true,
|
||||
};
|
||||
// undefined while loading, so the select says so instead of guessing.
|
||||
const options = isLoadedByType[relatedType] ? optionsByType[relatedType] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`${idPrefix}-type`}>Bezug</Label>
|
||||
<Select
|
||||
<EnumSelect
|
||||
id={`${idPrefix}-type`}
|
||||
value={relatedType}
|
||||
onValueChange={(value) => {
|
||||
const next = (value ?? relatedType) as TaskRelatedType;
|
||||
onChange={(next) => {
|
||||
onRelatedTypeChange(next);
|
||||
// The old id belongs to the old type's records, so it would point
|
||||
// at nothing after the switch.
|
||||
onRelatedIdChange("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
||||
<SelectValue>{TASK_RELATED_TYPE_LABELS[relatedType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_RELATED_TYPES.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{TASK_RELATED_TYPE_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
values={TASK_RELATED_TYPES}
|
||||
labels={TASK_RELATED_TYPE_LABELS}
|
||||
/>
|
||||
</div>
|
||||
{relatedType === "GENERAL" ? null : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`${idPrefix}-record`}>
|
||||
{TASK_RELATED_TYPE_LABELS[relatedType]}
|
||||
</Label>
|
||||
<Select
|
||||
<RecordSelect
|
||||
id={`${idPrefix}-record`}
|
||||
value={relatedId}
|
||||
onValueChange={(value) => onRelatedIdChange(value ?? relatedId)}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-record`} className="w-full">
|
||||
<SelectValue placeholder="Datensatz wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={onRelatedIdChange}
|
||||
items={options}
|
||||
getLabel={(option) => option.label}
|
||||
placeholder="Datensatz wählen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,12 +8,15 @@ function Card({
|
||||
interactive = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; interactive?: boolean }) {
|
||||
// overflow-clip, not -hidden: both clip to the rounded corners, but hidden
|
||||
// makes the card a scroll container that captures `position: sticky` - the
|
||||
// StickySubmit save bar inside a form card would never stick.
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
|
||||
interactive &&
|
||||
"motion-safe:hover:-translate-y-0.5 hover:shadow-[var(--shadow-elevated)] hover:border-border",
|
||||
className
|
||||
|
||||
Reference in new issue
Block a user