mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
793c19e179
commit
005cb32ea8
30 files changed
+158
-65
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -2502,7 +2502,8 @@
|
||||
"days90Label": "90일",
|
||||
"days90Title": "최근 90일",
|
||||
"daysAllLabel": "전체",
|
||||
"daysAllTitle": "전체 기간"
|
||||
"daysAllTitle": "전체 기간",
|
||||
"medicationsEmptyAction": "약 추가하기"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user