feat(ui): touch-size controls, scrollable dialogs, motion levels everywhere, load states

- Inputs and selects are 44 px on phones (36 px from sm up); default and
  small buttons get 40/36 px on phones.
- Dialogs scroll inside the viewport, so long edit forms keep their
  save button reachable on a phone.
- Dialog zoom, sheet slide, sidebar icon scale and stat-bar growth only
  move at the motion level "Voll" (motion-full), not at "Dezent".
- QueryState / ErrorState / LoadingState: loading, error with "Erneut
  versuchen", empty, data - a failed load can no longer look like an
  empty list. EmptyState takes an action for the obvious next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:33:50 +02:00
1 parent 32dfd8d8a7
commit 78325af1fe
10 files changed
+142 -11

No files matched your search

+1 -1
View File
@@ -142,7 +142,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
<Icon <Icon
className={cn( className={cn(
"size-4.5 shrink-0 transition-transform duration-150", "size-4.5 shrink-0 transition-transform duration-150",
!isActive && "group-hover:scale-110", !isActive && "motion-full:group-hover:scale-110",
)} )}
/> />
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
@@ -1,21 +1,25 @@
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
interface EmptyStateProps { interface EmptyStateProps {
icon: LucideIcon; icon: LucideIcon;
title: string; title: string;
description?: string; description?: string;
/** The one obvious next step, e.g. a link button "Erstes Volk anlegen". */
action?: ReactNode;
} }
export function EmptyState({ icon: Icon, title, description }: EmptyStateProps) { export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-12 text-center"> <div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border px-4 py-12 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground"> <span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="size-6" /> <Icon className="size-6" aria-hidden />
</span> </span>
<div> <div>
<p className="font-medium">{title}</p> <p className="font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div> </div>
{action ? <div className="mt-1">{action}</div> : null}
</div> </div>
); );
} }
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryState } from "./query-state";
const base = { isLoading: false, isError: false, isFetching: false, refetch: vi.fn() };
describe("QueryState", () => {
it("never shows the empty state for a failed load, and offers a retry", async () => {
const refetch = vi.fn();
render(
<QueryState
query={{ ...base, data: undefined, isError: true, refetch }}
what="Völker"
empty={<p>Noch keine Völker</p>}
>
{() => <p>Daten</p>}
</QueryState>,
);
expect(screen.queryByText("Noch keine Völker")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(refetch).toHaveBeenCalled();
});
it("shows the empty state for an empty list", () => {
render(
<QueryState query={{ ...base, data: [] }} what="Völker" empty={<p>Noch keine Völker</p>}>
{() => <p>Daten</p>}
</QueryState>,
);
expect(screen.getByText("Noch keine Völker")).toBeInTheDocument();
});
it("renders the data", () => {
render(
<QueryState query={{ ...base, data: [1] }} what="Völker">
{(data) => <p>{data.length} Volk</p>}
</QueryState>,
);
expect(screen.getByText("1 Volk")).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
"use client";
import { ReactNode } from "react";
import { AlertCircle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ErrorStateProps {
/** What could not be loaded, e.g. "Völker". */
what: string;
onRetry?: () => void;
isRetrying?: boolean;
}
/**
* A failed load says what failed and offers the next step right there -
* never a dead end, and never mistaken for "there is nothing here".
*/
export function ErrorState({ what, onRetry, isRetrying }: ErrorStateProps) {
return (
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4 sm:flex-row sm:items-center"
>
<p className="flex flex-1 items-center gap-2 text-sm">
<AlertCircle className="size-4 shrink-0 text-destructive" aria-hidden />
{what} konnten nicht geladen werden. Meist hilft es, es gleich noch einmal zu versuchen.
</p>
{onRetry ? (
<Button type="button" size="touch" variant="outline" onClick={onRetry} disabled={isRetrying}>
<RotateCw aria-hidden />
{isRetrying ? "Lädt…" : "Erneut versuchen"}
</Button>
) : null}
</div>
);
}
export function LoadingState({ what }: { what: string }) {
return (
<p role="status" className="text-sm text-muted-foreground">
{what} werden geladen…
</p>
);
}
interface QueryLike<T> {
data: T | undefined;
isLoading: boolean;
isError: boolean;
isFetching?: boolean;
refetch: () => unknown;
}
interface QueryStateProps<T> {
query: QueryLike<T>;
/** Plural noun for messages: "Völker", "Ernten". */
what: string;
/** Rendered when the data is an empty list. */
empty?: ReactNode;
children: (data: T) => ReactNode;
}
/**
* Loading, error (with retry), empty and data - always in that order, so
* a failed request can never look like an empty list.
*/
export function QueryState<T>({ query, what, empty, children }: QueryStateProps<T>) {
if (query.isLoading) {
return <LoadingState what={what} />;
}
if (query.isError || query.data === undefined) {
return (
<ErrorState
what={what}
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
);
}
if (empty && Array.isArray(query.data) && query.data.length === 0) {
return <>{empty}</>;
}
return <>{children(query.data)}</>;
}
@@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
</div> </div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div <div
className="h-full rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-700 motion-safe:ease-out" className="h-full rounded-full bg-primary motion-full:transition-[width] motion-full:duration-700 motion-full:ease-out"
style={{ width: `${item.value > 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }} style={{ width: `${item.value > 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }}
/> />
</div> </div>
@@ -21,10 +21,11 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
// Phones get at least 40 px: tapped with a thumb, often with gloves.
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-10 sm:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-9 sm:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
// 44 px: reliable with gloves or sticky fingers at the apiary. // 44 px: reliable with gloves or sticky fingers at the apiary.
touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5", touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5",
@@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 motion-full:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-full:data-closed:zoom-out-95",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-9 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className
)} )}
{...props} {...props}
@@ -41,7 +41,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -53,7 +53,7 @@ function SheetContent({
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className className
)} )}
{...props} {...props}