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

@@ -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