feat(t5): Auswahlgruppe mit Haken, Karten-Variante, Plus/Minus (T5a A6)

- ChoiceGroup nach Spec 10.2: gewählt mit Haken und 2-px-Tintenrahmen,
  „Nicht geprüft“ gestrichelt; neue Variante appearance="cards" mit Icon
  und Beschreibung (Winter-Ergebnis, Darstellung, Behördenmeldung).
- Eigenbau-Radios in Behördenmeldung, Phänologie-Bericht und
  Zeilen-Ausrichtung durch ChoiceGroup ersetzt.
- Button pressed → aria-pressed (Fett/Kursiv, „Aufbau als Liste“);
  Druck-Versatz nur mit motion-full.
- NumberStepper (ganze Schritte) für Winter-Waben, Milbenzahl (neu und
  bearbeiten), Weiselzellen, Entnahme/Zählbestand, Startposition; Entnahme
  nur 1 bis Restmenge, Startposition höchstens 1000.
- Baukasten-Wächter: Radios nur in ChoiceGroup, aria-pressed nur in
  Button und Kalender, auch in Objektform und .ts-Dateien.

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 18:44:31 +02:00
1 parent 6ac9eef93b
commit 208e9baaf0
30 files changed
+809 -198

No files matched your search

@@ -2,6 +2,7 @@ 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 { Sun } from "lucide-react";
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group";
type Feed = "ausreichend" | "niedrig" | "kritisch";
@@ -230,3 +231,108 @@ describe("choiceOptions", () => {
}
});
});
describe("ChoiceGroup – selected look (spec 10.2)", () => {
const classesOf = (element: HTMLElement) => element.className.split(/\s+/);
it("the chosen option has aria-checked true, the tick icon and the ink border", () => {
render(<Harness initial="niedrig" />);
const chosen = screen.getByRole("radio", { name: "Niedrig" });
expect(chosen).toHaveAttribute("aria-checked", "true");
expect(chosen.querySelector("[data-choice-mark='tick']")).not.toBeNull();
expect(chosen.querySelector("[data-choice-mark='tick']")?.getAttribute("aria-hidden")).toBe("true");
expect(classesOf(chosen)).toEqual(
expect.arrayContaining(["border-2", "border-foreground", "bg-primary", "text-primary-foreground", "font-semibold"]),
);
// The tick stands before the label, and the spoken name stays the label.
expect(chosen.firstElementChild?.getAttribute("data-choice-mark")).toBe("tick");
expect(chosen).toHaveAccessibleName("Niedrig");
const other = screen.getByRole("radio", { name: "Kritisch" });
expect(other.querySelector("[data-choice-mark]")).toBeNull();
expect(classesOf(other)).toEqual(expect.arrayContaining(["border", "border-input", "bg-card"]));
});
it("„Nicht geprüft“ chosen shows the dash icon and a dashed border", () => {
render(<Harness />);
const unanswered = screen.getByRole("radio", { name: "Nicht geprüft" });
expect(unanswered.querySelector("[data-choice-mark='dash']")).not.toBeNull();
expect(unanswered.querySelector("[data-choice-mark='tick']")).toBeNull();
expect(classesOf(unanswered)).toEqual(
expect.arrayContaining(["border-2", "border-dashed", "border-foreground", "bg-muted"]),
);
expect(classesOf(unanswered)).not.toContain("bg-primary");
});
it("appearance cards shows the description and is at least 56 px high", () => {
render(
<ChoiceGroup
label="Farbschema"
appearance="cards"
options={[
{ value: "light", label: "Hell", description: "Am besten bei Tageslicht" },
{ value: "dark", label: "Dunkel", description: "Für abends" },
]}
value="light"
onChange={() => undefined}
/>,
);
const light = screen.getByRole("radio", { name: "Hell" });
expect(light).toHaveAccessibleDescription("Am besten bei Tageslicht");
expect(screen.getByText("Für abends")).toHaveClass("text-sm");
expect(screen.getByText("Hell")).toHaveClass("text-base");
for (const radio of screen.getAllByRole("radio")) {
expect(classesOf(radio)).toEqual(expect.arrayContaining(["min-h-14", "w-full", "text-left"]));
}
});
it("an option icon renders before the label", () => {
render(
<ChoiceGroup
label="Ergebnis"
options={[
{ value: "a", label: "Überlebt", icon: Sun },
{ value: "b", label: "Verloren" },
]}
value="b"
onChange={() => undefined}
/>,
);
const radio = screen.getByRole("radio", { name: "Überlebt" });
const icon = radio.querySelector("svg.lucide-sun");
expect(icon).not.toBeNull();
expect(icon).toHaveAttribute("aria-hidden", "true");
const label = screen.getByText("Überlebt");
expect(icon!.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("arrow keys, Home and End move the selection as before", async () => {
const onChange = vi.fn();
render(
<ChoiceGroup
label="Bewegung"
appearance="cards"
options={[
{ value: "full", label: "Voll", description: "a" },
{ value: "subtle", label: "Dezent", description: "b" },
{ value: "off", label: "Aus", description: "c" },
]}
value="full"
onChange={onChange}
/>,
);
await userEvent.tab();
expect(screen.getByRole("radio", { name: "Voll" })).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(onChange).toHaveBeenLastCalledWith("subtle");
await userEvent.keyboard("{End}");
expect(onChange).toHaveBeenLastCalledWith("off");
await userEvent.keyboard("{Home}");
expect(onChange).toHaveBeenLastCalledWith("full");
});
});
@@ -1,6 +1,7 @@
"use client";
import { KeyboardEvent, useRef } from "react";
import { KeyboardEvent, useId, useRef } from "react";
import { Check, Minus, type LucideIcon } from "lucide-react";
import { keysOf } from "@/lib/keys-of";
import { cn } from "@/lib/utils";
@@ -19,8 +20,18 @@ export interface ChoiceOption<T extends ChoiceValue> {
selectedClassName?: string;
/** Shown but not choosable; arrow keys skip it. */
disabled?: boolean;
/** Icon before the label, e.g. for the outcome of the winter. */
icon?: LucideIcon;
/** Second line under the label (14 px); only shown with `appearance="cards"`. */
description?: string;
}
/**
* "buttons": a grid of equal buttons (default). "cards": one full-width option per row with
* label and description, at least 56 px high - for choices that need a sentence each.
*/
export type ChoiceAppearance = "buttons" | "cards";
/** 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)}`;
@@ -52,6 +63,7 @@ interface ChoiceGroupProps<T extends ChoiceValue> {
className?: string;
/** Extra classes for every option button, e.g. a larger font. */
optionClassName?: string;
appearance?: ChoiceAppearance;
}
const KEY_STEPS: Record<string, number> = {
@@ -69,6 +81,72 @@ function targetIndex(key: string, current: number, count: number): number | null
return (current + step + count) % count;
}
const OPTION_CLASS: Record<ChoiceAppearance, string> = {
buttons:
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg px-2 text-base motion-safe:transition-colors",
cards:
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base motion-safe:transition-colors",
};
const MARK_ICON = { tick: Check, dash: Minus } as const;
type Mark = keyof typeof MARK_ICON;
/** Tick (an answer) or dash („Nicht geprüft“) before the label, the option icon, then the text. */
function OptionContent<T extends ChoiceValue>({
option,
id,
mark,
appearance,
isMuted,
}: {
option: ChoiceOption<T>;
id: string;
mark: Mark | null;
appearance: ChoiceAppearance;
isMuted: boolean;
}) {
const MarkIcon = mark ? MARK_ICON[mark] : null;
const Icon = option.icon;
const iconClass = cn("size-4 shrink-0", appearance === "cards" && "mt-1");
const label = (
<>
{option.label}
{option.srSuffix ? (
<>
{" "}
<span className="sr-only">{option.srSuffix}</span>
</>
) : null}
</>
);
return (
<>
{MarkIcon ? (
<MarkIcon aria-hidden="true" data-choice-mark={mark} strokeWidth={3} className={iconClass} />
) : null}
{Icon ? <Icon aria-hidden="true" className={cn(iconClass, appearance === "cards" && "size-5 mt-0.5")} /> : null}
{appearance === "cards" ? (
<span className="flex min-w-0 flex-col">
<span id={`${id}-label`} className="text-base">
{label}
</span>
{option.description !== undefined ? (
<span
id={`${id}-description`}
className={cn("text-sm font-normal", isMuted && "text-muted-foreground")}
>
{option.description}
</span>
) : null}
</span>
) : (
<span>{label}</span>
)}
</>
);
}
/**
* 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
@@ -88,7 +166,9 @@ export function ChoiceGroup<T extends ChoiceValue>({
clearValue,
className,
optionClassName,
appearance = "buttons",
}: ChoiceGroupProps<T>) {
const baseId = useId();
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
const checkedIndex = options.findIndex((option) => option.value === value);
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
@@ -119,6 +199,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
{options.map((option, index) => {
const selected = index === checkedIndex;
const isUnanswered = option.value === null;
const optionId = `${baseId}-${index}`;
const hasDescription = appearance === "cards" && option.description !== undefined;
return (
<button
key={optionKey(option.value)}
@@ -128,6 +210,8 @@ export function ChoiceGroup<T extends ChoiceValue>({
type="button"
role="radio"
aria-checked={selected}
aria-labelledby={hasDescription ? `${optionId}-label` : undefined}
aria-describedby={hasDescription ? `${optionId}-description` : undefined}
disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1}
onClick={() =>
@@ -135,24 +219,25 @@ export function ChoiceGroup<T extends ChoiceValue>({
}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
"min-h-11 rounded-lg border px-2 text-base motion-safe:transition-colors",
!selected && "border-border bg-background hover:bg-muted",
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background",
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
selected &&
!isUnanswered &&
"border-foreground bg-primary font-medium text-primary-foreground",
OPTION_CLASS[appearance],
!selected && "border border-input bg-card hover:bg-muted",
option.disabled && "cursor-not-allowed opacity-50 hover:bg-card",
selected && "border-2 font-semibold",
selected && isUnanswered && "border-dashed bg-muted text-foreground",
selected && !isUnanswered && "bg-primary text-primary-foreground",
selected && option.selectedClassName,
// After the tone of a scale step: the frame of a chosen option is always the ink.
selected && "border-foreground",
optionClassName,
)}
>
{option.label}
{option.srSuffix ? (
<>
{" "}
<span className="sr-only">{option.srSuffix}</span>
</>
) : null}
<OptionContent
option={option}
id={optionId}
mark={selected ? (isUnanswered ? "dash" : "tick") : null}
appearance={appearance}
isMuted={!selected}
/>
</button>
);
})}
@@ -91,4 +91,17 @@ describe("RatingScale", () => {
expect(screen.getByText("1 = ruhig")).toBeInTheDocument();
expect(screen.getByText("5 = stark")).toBeInTheDocument();
});
it("RatingScale shows the tick before the chosen number and keeps its tone", () => {
render(<Harness initial={4} />);
const chosen = screen.getAllByRole("radio")[3];
expect(chosen).toHaveTextContent("4");
expect(chosen.firstElementChild?.getAttribute("data-choice-mark")).toBe("tick");
expect(chosen).toHaveClass("bg-condition-good", "text-condition-good-foreground", "border-2", "border-foreground");
expect(chosen).not.toHaveClass("border-condition-good");
expect(chosen).toHaveAccessibleName("4 von 5");
for (const other of screen.getAllByRole("radio").filter((radio) => radio !== chosen)) {
expect(other.querySelector("[data-choice-mark]")).toBeNull();
}
});
});
@@ -207,6 +207,15 @@ describe("HiveCard", () => {
expect(toggle).toHaveAttribute("aria-pressed", "true");
});
it("„Aufbau als Liste“ is the toolbar toggle of the Button kit (prop pressed), still glass on the stage", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(toggle).toHaveAttribute("data-slot", "button");
expect(toggle).toHaveClass("backdrop-blur-md", "text-holo-label-fg", "h-11");
expect(toggle.className).not.toMatch(/(^|\s)(bg-primary|pointer-fine:h-8|hover:text-foreground)(\s|$)/);
});
it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
@@ -384,20 +384,23 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
Umbauen
</button>
) : null}
<button
{/* A toolbar toggle of the kit; the glass classes win over the ghost look (spec 10.2). */}
<Button
type="button"
aria-pressed={showOutline}
variant="ghost"
size="touch"
pressed={showOutline}
aria-controls={outlineId}
aria-label="Aufbau als Liste"
onClick={() => {
setShowOutline(!showOutline);
onShowOutlineChange?.(!showOutline);
}}
className={GLASS_BUTTON}
className={`${GLASS_BUTTON} hover:text-holo-label-fg`}
>
<ListTree aria-hidden />
Liste
</button>
</Button>
<button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}>
<History aria-hidden />
Verlauf
@@ -109,6 +109,14 @@ describe("PhenologyReportSheet", () => {
}
});
it("the scope is one radio group from the kit, named „Gilt für“", () => {
renderSheet();
const group = screen.getByRole("radiogroup", { name: "Gilt für" });
expect(within(group).getAllByRole("radio")).toHaveLength(2);
expect(document.querySelector('input[type="radio"]')).toBeNull();
});
it("reports for this apiary only after switching the scope", async () => {
renderSheet();
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flower2 } from "lucide-react";
import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
@@ -185,29 +186,18 @@ function ScopeChoice({
scope: Scope;
onChange: (scope: Scope) => void;
}) {
const options: { value: Scope; label: string }[] = [
const labelId = useId();
const options: ChoiceOption<Scope>[] = [
{ value: "all", label: "Für alle Stände" },
{ value: "apiary", label: `Nur ${apiaryName}` },
];
return (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Gilt für</legend>
{options.map((option) => (
<label
key={option.value}
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base focus-ring-within"
>
<input
type="radio"
name="phenology-scope"
className="size-5 accent-primary"
checked={scope === option.value}
onChange={() => onChange(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
<div className="flex flex-col gap-2">
<p id={labelId} className="text-base font-medium">
Gilt für
</p>
<ChoiceGroup options={options} value={scope} onChange={onChange} labelledBy={labelId} />
</div>
);
}
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StockCard } from "./stock-card";
import { fetchStock } from "@/lib/stock/api";
import type { BatchStock } from "@/lib/stock/types";
import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
import type { BatchStock, StockBatchPath } from "@/lib/stock/types";
vi.mock("@/lib/stock/api", () => ({
fetchStock: vi.fn(),
@@ -11,17 +12,17 @@ vi.mock("@/lib/stock/api", () => ({
correctStock: vi.fn(),
}));
function renderCard(canBook = true) {
function renderCard(canBook = true, unit = "Gläser", path: StockBatchPath = "bottling-batches") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<StockCard
path="bottling-batches"
path={path}
id="b1"
initialLabel="Abgefüllt"
unit="Gläser"
unit={unit}
canBook={canBook}
/>
</QueryClientProvider>,
@@ -107,4 +108,61 @@ describe("StockCard", () => {
screen.queryByRole("button", { name: /Inventur/ }),
).not.toBeInTheDocument();
});
it("stock card withdraw and counted stock use NumberStepper", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
vi.mocked(withdrawStock).mockResolvedValue(undefined as never);
vi.mocked(correctStock).mockResolvedValue(undefined as never);
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
let dialog = await screen.findByRole("dialog");
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
expect(quantity).toHaveValue("1");
expect(quantity).toHaveAttribute("inputmode", "numeric");
await userEvent.click(within(dialog).getByRole("button", { name: /Eins mehr/ }));
await userEvent.click(within(dialog).getByRole("button", { name: "Entnahme buchen" }));
expect(vi.mocked(withdrawStock).mock.calls[0][2]).toMatchObject({ quantity: 2 });
await userEvent.click(await screen.findByRole("button", { name: /Inventur korrigieren/ }));
dialog = await screen.findByRole("dialog");
const counted = within(dialog).getByLabelText("Gezählt (Gläser)");
expect(counted).toHaveValue("80");
await userEvent.click(within(dialog).getByRole("button", { name: /Eins weniger/ }));
expect(within(dialog).getByText(/Differenz: -1/)).toBeInTheDocument();
});
it("keeps a typed field for amounts in grams", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(500));
renderCard(true, "g", "raw-material-lots");
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByLabelText("Anzahl (g)")).toHaveAttribute("type", "number");
expect(within(dialog).queryByRole("button", { name: /mehr$/ })).toBeNull();
});
it("does not book an empty withdrawal", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
await userEvent.clear(within(dialog).getByLabelText("Anzahl (Gläser)"));
expect(within(dialog).getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("does not book a withdrawal of 0 or more than is left", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
const dialog = await screen.findByRole("dialog");
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
const book = within(dialog).getByRole("button", { name: "Entnahme buchen" });
await userEvent.clear(quantity);
await userEvent.type(quantity, "0");
expect(book).toBeDisabled();
await userEvent.clear(quantity);
await userEvent.type(quantity, "80");
expect(book).toBeEnabled();
});
});
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberStepper } from "@/components/form/number-stepper";
import { Textarea } from "@/components/ui/textarea";
import { EnumSelect } from "@/components/form/enum-select";
import {
@@ -210,6 +211,56 @@ function useStockMutationDone(path: StockBatchPath, id: string) {
};
}
/**
* Jars and pieces are counted at the shelf: plus/minus for gloved fingers (spec 10.5). Raw
* materials are weighed in grams and keep a typed field.
*/
function QuantityField({
id,
path,
unit,
min,
max,
value,
onChange,
}: {
id: string;
path: StockBatchPath;
unit: string;
min: number;
max?: number;
value: string;
onChange: (value: string) => void;
}) {
if (path === "raw-material-lots") {
return (
<Input
id={id}
type="number"
inputMode="numeric"
min={min}
max={max}
step={1}
required
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
return (
<NumberStepper
id={id}
value={value}
onChange={onChange}
min={min}
max={max !== undefined && max >= min ? max : undefined}
unitLabel={unit}
decrementLabel="Eins weniger"
incrementLabel="Eins mehr"
/>
);
}
function WithdrawDialog({
path,
id,
@@ -227,6 +278,9 @@ function WithdrawDialog({
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const done = useStockMutationDone(path, id);
// The server takes only a positive amount up to what is left; 0 or more would be a 400.
const amount = Number(quantity);
const canWithdraw = quantity !== "" && amount >= 1 && amount <= remaining;
const mutation = useMutation({
mutationFn: () =>
@@ -277,16 +331,14 @@ function WithdrawDialog({
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
<Input
<QuantityField
id="withdraw-quantity"
type="number"
inputMode="numeric"
path={path}
unit={unit}
min={1}
max={remaining}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
onChange={setQuantity}
/>
</div>
<div className="flex flex-col gap-2">
@@ -311,7 +363,7 @@ function WithdrawDialog({
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
<Button type="submit" disabled={mutation.isPending || !canWithdraw}>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button>
</form>
@@ -391,15 +443,13 @@ function CorrectDialog({
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-counted">Gezählt ({unit})</Label>
<Input
<QuantityField
id="correct-counted"
type="number"
inputMode="numeric"
path={path}
unit={unit}
min={0}
step={1}
required
value={counted}
onChange={(event) => setCounted(event.target.value)}
onChange={setCounted}
/>
{difference !== 0 ? (
<p className="text-xs text-muted-foreground">
+12 -2
View File
@@ -9,7 +9,7 @@ const HIT_AREA =
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none motion-full:active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -49,15 +49,25 @@ const buttonVariants = cva(
}
)
interface ButtonProps extends ButtonPrimitive.Props, VariantProps<typeof buttonVariants> {
/**
* A toolbar toggle (bold, italic, a list view): sets aria-pressed. Not for a choice between
* options - that is a ChoiceGroup (spec 10.2).
*/
pressed?: boolean
}
function Button({
className,
variant = "default",
size = "default",
pressed,
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
}: ButtonProps) {
return (
<ButtonPrimitive
data-slot="button"
aria-pressed={pressed}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
@@ -78,4 +78,26 @@ describe("ui sizes, type and focus (spec 4.5, 4.6)", () => {
expect(has(classes, "aria-invalid:ring-destructive")).toBe(true);
expect(classes).not.toMatch(/ring-destructive\/20/);
});
it("Button pressed sets aria-pressed", () => {
const { rerender } = render(
<Button pressed aria-label="Fett">
B
</Button>,
);
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true");
rerender(
<Button pressed={false} aria-label="Fett">
B
</Button>,
);
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "false");
rerender(<Button aria-label="Fett">B</Button>);
expect(screen.getByRole("button", { name: "Fett" })).not.toHaveAttribute("aria-pressed");
});
it("the press nudge of a button only moves with the motion level full", () => {
render(<Button>Los</Button>);
const classes = screen.getByRole("button", { name: "Los" }).className;
expect(has(classes, "motion-full:active:not-aria-[haspopup]:translate-y-px")).toBe(true);
expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:translate-y-px/);
});
});