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:
1 parent
32dfd8d8a7
commit
78325af1fe
10 files changed
+142
-11
No files matched your search
@@ -142,7 +142,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
<Icon
|
||||
className={cn(
|
||||
"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>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: 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 (
|
||||
<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">
|
||||
<Icon className="size-6" />
|
||||
<Icon className="size-6" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-1">{action}</div> : null}
|
||||
</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 className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,11 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// Phones get at least 40 px: tapped with a thumb, often with gloves.
|
||||
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",
|
||||
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",
|
||||
// 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",
|
||||
|
||||
@@ -53,7 +53,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -53,7 +53,7 @@ function SheetContent({
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in new issue
Block a user