Give every empty list one empty state and one add action

EmptyState now has a single action shape: centred at its own width on
the 44 px tap floor, never stretched edge to edge on some pages and
compact on others; the per-call-site size switch is gone, and the
dashed card takes the card radius. While a list is empty its empty
state carries the add action, so the header drops its own add button
there (measurements, mood, medications, labs, vaccinations, documents,
illness, check-ups, visits, procedures). The dashboard medication tile
of an account without medications offers 'Add a medication' instead of
'no data in the chosen range'.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:53:24 +02:00
1 parent 793c19e179
commit 005cb32ea8
30 files changed
+158 -65

No files matched your search

+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.",
"complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.",
"complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage."
}
},
"medicationsEmptyAction": "Medikament anlegen"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Mood heatmap, no days logged yet.",
"complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.",
"complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet."
}
},
"medicationsEmptyAction": "Add a medication"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.",
"complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.",
"complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos."
}
},
"medicationsEmptyAction": "Añadir un medicamento"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.",
"complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.",
"complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant."
}
},
"medicationsEmptyAction": "Ajouter un médicament"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.",
"complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.",
"complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo."
}
},
"medicationsEmptyAction": "Aggiungi un farmaco"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"days90Label": "90일",
"days90Title": "최근 90일",
"daysAllLabel": "전체",
"daysAllTitle": "전체 기간"
"daysAllTitle": "전체 기간",
"medicationsEmptyAction": "약 추가하기"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -2502,7 +2502,8 @@
"moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.",
"complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.",
"complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni."
}
},
"medicationsEmptyAction": "Dodaj lek"
},
"chart": {
"overlay": {
-1
View File
@@ -86,7 +86,6 @@ export default function InsightsBmiPage() {
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
description={t("insights.bmiEmptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/account">
-1
View File
@@ -199,7 +199,6 @@ export default function InsightsMedikamentePage() {
icon={<Pill className="size-6" />}
title={t("insights.emptyState.medication.title")}
description={t("insights.emptyState.medication.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/medications">
-2
View File
@@ -408,7 +408,6 @@ export default function InsightsPageClient() {
icon={<TrendingUp className="size-6" />}
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements">
@@ -624,7 +623,6 @@ export default function InsightsPageClient() {
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/layout/insights">
+8 -2
View File
@@ -58,6 +58,7 @@ export default function LabsPage() {
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
const [listEmpty, setListEmpty] = useState(false);
// Sticky-footer slot element for the add-result sheet (filled by the
// ResponsiveSheet `footer` ref so the form portals its action row there).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
@@ -185,7 +186,9 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canAddLab ? (
) : canAddLab && !listEmpty ? (
// Hidden while the list is empty: its empty state carries the
// manual add then. The scan choice above has no such twin.
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -263,7 +266,10 @@ export default function LabsPage() {
/>
) : null}
<LabList onAddFirst={() => setDialogOpen(true)} />
<LabList
onAddFirst={() => setDialogOpen(true)}
onEmptyChange={setListEmpty}
/>
</div>
);
}
+5 -1
View File
@@ -67,6 +67,9 @@ export default function MeasurementsPage() {
? `${addParam}\u0000${returnToParam ?? ""}`
: null;
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
() => initialAdd ?? undefined,
);
@@ -151,7 +154,7 @@ export default function MeasurementsPage() {
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAddMeasurement ? (
canAddMeasurement && !listEmpty ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
@@ -212,6 +215,7 @@ export default function MeasurementsPage() {
fallback={<Skeleton className="h-[28rem] w-full rounded-xl" />}
>
<MeasurementList
onEmptyChange={setListEmpty}
onAddFirst={() => {
setReturnTo(null);
setDialogOpen(true);
+5 -1
View File
@@ -521,6 +521,8 @@ export default function MedicationsPageClient() {
),
});
const listEmpty = !isLoading && !isError && !medications?.length;
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
@@ -575,7 +577,9 @@ export default function MedicationsPageClient() {
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAddMedication && (
{/* While the list is empty its empty state carries the one
add action; a second one in the header said it twice. */}
{canAddMedication && !listEmpty && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
+8 -2
View File
@@ -28,6 +28,9 @@ export default function MoodPageClient() {
const router = useRouter();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
@@ -109,7 +112,7 @@ export default function MoodPageClient() {
)}
{/* A mood entry is a MANAGE create: a WRITE delegate gets no add
path here, a guardian or a MANAGE delegate does. */}
{canAddMood && (
{canAddMood && !listEmpty && (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -136,7 +139,10 @@ export default function MoodPageClient() {
/>
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
<MoodList
onAddFirst={() => setDialogOpen(true)}
onEmptyChange={setListEmpty}
/>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
screeners, and the mood insights page form one mental-health
@@ -32,6 +32,7 @@
* `findAllByType` reconciliation; the dashboard's `next/dynamic` for
* the wrapper itself stays intact.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState, useRef } from "react";
import { queryKeys } from "@/lib/query-keys";
@@ -50,6 +51,8 @@ import { ComplianceInfoTip } from "@/components/medications/card-parts/complianc
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useAuth } from "@/hooks/use-auth";
import {
useDateFormatPreference,
@@ -276,6 +279,22 @@ export function MedicationComplianceChart({
enabled: isAuthenticated,
});
// An empty compliance window means one of two things: no dose fell in it,
// or there is no medication at all. The second is not "no data in the
// chosen range" — it is a first step, so the tile offers it. The list
// read is the medications page's own cell (usually warm already).
const complianceEmpty = !isLoading && !isError && (data?.length ?? 0) === 0;
const medicationList = useQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet<{ id: string }[]>("/api/medications"),
enabled: isAuthenticated && complianceEmpty,
});
const noMedications =
complianceEmpty &&
medicationList.isSuccess &&
(medicationList.data?.length ?? 0) === 0;
const { canWriteDomain } = useRecordCapabilities();
// v1.16.0 — report the settled query to the dashboard's shared
// reveal gate (see `onDataReady` prop doc).
//
@@ -533,6 +552,24 @@ export function MedicationComplianceChart({
actionContext={displayTitle}
onAction={() => void refetch()}
/>
) : !hasData && noMedications ? (
<EmptyState
variant="plain"
size="compact"
data-slot="medication-compliance-no-medications"
className="flex h-48 items-center justify-center"
icon={<Pill className="size-4" />}
title={t("medications.emptyTitle")}
action={
canWriteDomain("medications") ? (
<Button asChild size="sm">
<Link href="/medications/new">
{t("charts.medicationsEmptyAction")}
</Link>
</Button>
) : undefined
}
/>
) : !hasData ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
+10 -8
View File
@@ -984,13 +984,16 @@ export function DocumentsView() {
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
{/* An empty vault's empty state carries the upload. */}
{showEmpty && !isFiltered ? null : (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
)}
</>
) : null}
</div>
@@ -1072,7 +1075,6 @@ export function DocumentsView() {
icon={<FolderOpen className="size-6" aria-hidden />}
title={t("documents.empty.title")}
description={t("documents.empty.description")}
ctaSize="lg"
action={
canManageDocuments ? (
<Button
@@ -86,7 +86,12 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
titleId="procedures-section-title"
title={t("encounters.procedures.title")}
description={t("encounters.procedures.description")}
actions={addButton}
// The empty state carries the add action while there is none.
actions={
!list.isPending && !list.isError && total === 0 && !filtered
? null
: addButton
}
/>
{list.isError ? (
@@ -106,7 +111,6 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
title={t("encounters.procedures.emptyTitle")}
description={t("encounters.procedures.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-4">
+3 -2
View File
@@ -91,7 +91,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
{t("practitioners.linkLabel")}
</Link>
</Button>
{addButton}
{/* The empty state carries the add action while there is no
visit. */}
{!list.isPending && !list.isError && empty ? null : addButton}
</>
}
/>
@@ -113,7 +115,6 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
title={t("encounters.emptyTitle")}
description={t("encounters.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-6">
+3 -2
View File
@@ -432,7 +432,9 @@ export function IllnessView() {
)}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
{canAddEpisode && (
{/* Hidden while there is no episode: the empty state below
carries the add action then. */}
{canAddEpisode && (isLoading || isError || hasEpisodes) && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -500,7 +502,6 @@ export function IllnessView() {
icon={<Stethoscope className="size-6" />}
title={t("illness.empty.title")}
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
@@ -11,7 +11,7 @@ import { EmptyState } from "@/components/ui/empty-state";
*
* Pre-fix, every sub-page repeated the same five-line incantation:
*
* <EmptyState icon={…} title={…} description={…} ctaSize="lg" action={…} />
* <EmptyState icon={…} title={…} description={…} action={…} />
* <CoachLaunchButton prefill={…} />
*
* The action slot was always a `<Button asChild><Link href="…">…</Link></Button>`
@@ -64,7 +64,6 @@ export function MetricEmptyState({
icon={icon}
title={title}
description={description}
ctaSize="lg"
action={cta}
/>
<CoachLaunchButton prefill={coachPrefill ?? undefined} />
+14 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo } from "react";
import { useMemo, useEffect } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
@@ -136,7 +136,14 @@ const MOBILE_CELL = {
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
export function LabList({
onAddFirst,
onEmptyChange,
}: {
onAddFirst?: () => void;
/** Told whether the list is empty, so the page can drop its header add. */
onEmptyChange?: (empty: boolean) => void;
} = {}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const labDate = useLabDate();
@@ -196,6 +203,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const shown = data?.results?.length ?? 0;
const truncated = total > shown;
const listEmpty = !isLoading && !isError && groups.length === 0;
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
if (isLoading) {
return (
<div className="space-y-3" data-slot="lab-list-loading">
@@ -88,7 +88,6 @@ export function ModuleDisabledNotice({
icon={icon}
title={t(copy.title, { module: moduleName })}
description={t(copy.description, { module: moduleName })}
ctaSize="lg"
action={state === "disabled" ? action : undefined}
/>
</div>
@@ -36,7 +36,6 @@ export function SharedRecordUnavailable() {
icon={<Lock className="size-6" />}
title={t("recordSharing.unavailable.title")}
description={t("recordSharing.unavailable.description")}
ctaSize="lg"
action={
<Button
data-slot="shared-record-unavailable-leave"
@@ -456,7 +456,11 @@ export function VorsorgeSection({
actions={
<>
{wrenchButton}
{addButton}
{/* The empty state carries the add action while there is
nothing to list. */}
{!isLoading && !isError && (reminders?.length ?? 0) === 0
? null
: addButton}
</>
}
/>
@@ -195,6 +195,11 @@ interface MeasurementListProps {
* (fallback to the header CTA) instead of crashing.
*/
onAddFirst?: () => void;
/**
* Told whether the unfiltered list is empty, so the page can drop its
* header add button while the empty state carries the add action.
*/
onEmptyChange?: (empty: boolean) => void;
/**
* v1.8.5 — pin the list to a single `MeasurementType` and hide the
* type selector. Used by the insights "all readings" subpage, which
@@ -286,6 +291,7 @@ function sourceBadgeClass(source: string): string {
export function MeasurementList({
onEdit,
onAddFirst,
onEmptyChange,
lockedType,
}: MeasurementListProps) {
const { t } = useTranslations();
@@ -660,6 +666,15 @@ export function MeasurementList({
enabled: isAuthenticated,
});
const listEmpty =
!isLoading &&
!isError &&
!data?.measurements?.length &&
(typeFilter === "ALL" || !!lockedType);
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
// v1.16.4 — deletes are soft (tombstones), so the success toast can
// carry a real Undo: it POSTs the ids to `/api/measurements/restore`,
// which clears `deletedAt` and re-fires the same dependent-key bundle.
@@ -243,7 +243,6 @@ export function InventorySection({
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
ctaSize="lg"
action={
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus aria-hidden="true" className="h-4 w-4" />
+12 -1
View File
@@ -174,9 +174,14 @@ interface MoodListProps {
* rendering a no-op button.
*/
onAddFirst?: () => void;
/**
* Told whether the unfiltered list is empty, so the page can drop its
* header add button while the empty state carries the add action.
*/
onEmptyChange?: (empty: boolean) => void;
}
export function MoodList({ onAddFirst }: MoodListProps = {}) {
export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
@@ -362,6 +367,12 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
enabled: isAuthenticated,
});
const listEmpty =
!isLoading && !isError && !data?.entries?.length && moodFilter === "ALL";
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
// v1.16.4 — deletes are soft (tombstones), so the success toast can
// carry a real Undo: it POSTs the ids to `/api/mood-entries/restore`,
// which clears `deletedAt` and re-fires the same dependent-key bundle.
@@ -152,7 +152,6 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
: "practitioners.emptyDescription",
)}
action={debounced ? undefined : (addButton ?? undefined)}
ctaSize="lg"
/>
) : (
<div className="space-y-4" data-slot="practitioner-list">
+7 -23
View File
@@ -36,16 +36,6 @@ export interface EmptyStateProps extends Omit<
* Compact density for use inside tiles or table-row empty rows.
*/
size?: "default" | "compact";
/**
* v1.4.27 MB7 / CF-36 — size hint for the inner action wrapper.
*
* `"default"` keeps the legacy inline-tight CTA. `"lg"` adds a
* `[&_button]:min-h-11 [&_a]:min-h-11` selector so the inner button
* or `asChild` link meets the 44 px floor on mobile, and lifts the
* wrapper to `w-full sm:w-auto` so the CTA fills the column at
* narrow viewports and centres at `sm+`.
*/
ctaSize?: "default" | "lg";
}
function EmptyState({
@@ -55,7 +45,6 @@ function EmptyState({
action,
variant = "card",
size = "default",
ctaSize = "default",
className,
...props
}: EmptyStateProps) {
@@ -97,17 +86,12 @@ function EmptyState({
</div>
{action ? (
<div
className={cn(
"mt-1",
// v1.4.27 MB7 / CF-36 — when `ctaSize === "lg"` the wrapper
// lifts to full-width on mobile and constrains the inner
// `<Button>` / `asChild` `<Link>` to the 44 px tap floor.
// The selectors target the wrapper's immediate child so
// existing CTAs that pass a plain `<Button>` (no class
// overrides) pick up the lift automatically.
ctaSize === "lg" &&
"w-full sm:w-auto [&>a]:min-h-11 [&>a]:w-full sm:[&>a]:w-auto [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:w-auto",
)}
// One shape for every empty state's action: centred under the
// copy at its own width, never stretched edge to edge, and on the
// 44 px tap floor (back to the compact 36 px under a mouse). A
// per-call-site "lg" variant used to stretch some CTAs full width
// on a phone and leave others compact, side by side in the app.
className="mt-1 [&>a]:min-h-11 pointer-fine:[&>a]:min-h-9 [&>button]:min-h-11 pointer-fine:[&>button]:min-h-9"
>
{action}
</div>
@@ -135,7 +119,7 @@ function EmptyState({
role="status"
aria-live="polite"
className={cn(
"border-border bg-card rounded-lg border border-dashed",
"border-border bg-card rounded-xl border border-dashed",
className,
)}
{...props}
@@ -104,7 +104,11 @@ export function VaccinationsView() {
</span>
}
description={t("vaccinations.subtitle")}
actions={addButton}
// The empty state carries the add action while the Impfpass is
// empty; the header offers it once there is a list to add to.
actions={
!isLoading && !isError && records.length === 0 ? null : addButton
}
/>
<VaccinationSheet