diff --git a/messages/de.json b/messages/de.json index a85e3273a..e4a12f169 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 86645a1e4..083e46de2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 30a3741db..fc0f84eeb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 8397776d8..d6f04c335 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 428691394..dacbf1728 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 48f25e772..af007eb90 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2502,7 +2502,8 @@ "days90Label": "90일", "days90Title": "최근 90일", "daysAllLabel": "전체", - "daysAllTitle": "전체 기간" + "daysAllTitle": "전체 기간", + "medicationsEmptyAction": "약 추가하기" }, "chart": { "overlay": { diff --git a/messages/pl.json b/messages/pl.json index 1f4892372..53e772597 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index cdc5ea3c6..ce216abdf 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -86,7 +86,6 @@ export default function InsightsBmiPage() { icon={} title={t("insights.bmiEmptyTitle")} description={t("insights.bmiEmptyDescription")} - ctaSize="lg" action={ diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index e6492bf1c..8a7327dee 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -199,7 +199,6 @@ export default function InsightsMedikamentePage() { icon={} title={t("insights.emptyState.medication.title")} description={t("insights.emptyState.medication.description")} - ctaSize="lg" action={ diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 375088b05..5c3a146d2 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -408,7 +408,6 @@ export default function InsightsPageClient() { icon={} title={t("insights.emptyTitle")} description={t("insights.emptyDescription")} - ctaSize="lg" action={ @@ -624,7 +623,6 @@ export default function InsightsPageClient() { icon={} title={t("insights.editMode.emptyTitle")} description={t("insights.editMode.emptyDescription")} - ctaSize="lg" action={ diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 86faac75d..303e952c1 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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(null); @@ -185,7 +186,9 @@ export default function LabsPage() { - ) : 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. setDialogOpen(true)} className="min-h-11 sm:min-h-9" @@ -263,7 +266,10 @@ export default function LabsPage() { /> ) : null} - setDialogOpen(true)} /> + setDialogOpen(true)} + onEmptyChange={setListEmpty} + /> ); } diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index ac0efe6cc..13a017f4c 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -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( () => 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 ? ( } > { setReturnTo(null); setDialogOpen(true); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index b37d4ef51..7fd88566d 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -521,6 +521,8 @@ export default function MedicationsPageClient() { ), }); + const listEmpty = !isLoading && !isError && !medications?.length; + return ( @@ -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 && ( diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index 05e41c7f3..4bf441c6b 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -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 `` 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 && ( setDialogOpen(true)} className="min-h-11 sm:min-h-9" @@ -136,7 +139,10 @@ export default function MoodPageClient() { /> - setDialogOpen(true)} /> + 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 diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 628ce42ce..6464c3d59 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -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 ? ( + } + title={t("medications.emptyTitle")} + action={ + canWriteDomain("medications") ? ( + + + {t("charts.medicationsEmptyAction")} + + + ) : undefined + } + /> ) : !hasData ? ( {t("charts.noData")} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index ffe1b6457..dbecd0757 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -984,13 +984,16 @@ export function DocumentsView() { <> {/* Renders only with a connected Paperless-ngx or Papra. */} - uploadInputRef.current?.click()} - > - - {t("documents.pageUpload")} - + {/* An empty vault's empty state carries the upload. */} + {showEmpty && !isFiltered ? null : ( + uploadInputRef.current?.click()} + > + + {t("documents.pageUpload")} + + )} > ) : null} @@ -1072,7 +1075,6 @@ export function DocumentsView() { icon={} title={t("documents.empty.title")} description={t("documents.empty.description")} - ctaSize="lg" action={ canManageDocuments ? ( {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" /> ) : ( diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 21aa16783..20b3c50e9 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -91,7 +91,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { {t("practitioners.linkLabel")} - {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" /> ) : ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 0ef5b8f1f..4133f5ecf 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -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) && ( setNewOpen(true)} className="min-h-11 sm:min-h-9" @@ -500,7 +502,6 @@ export function IllnessView() { icon={} title={t("illness.empty.title")} description={t("illness.empty.body")} - ctaSize="lg" action={ canAddEpisode ? ( setNewOpen(true)}> diff --git a/src/components/insights/metric-empty-state.tsx b/src/components/insights/metric-empty-state.tsx index b5d51ff35..c3777c3b5 100644 --- a/src/components/insights/metric-empty-state.tsx +++ b/src/components/insights/metric-empty-state.tsx @@ -11,7 +11,7 @@ import { EmptyState } from "@/components/ui/empty-state"; * * Pre-fix, every sub-page repeated the same five-line incantation: * - * + * * * * The action slot was always a `…` @@ -64,7 +64,6 @@ export function MetricEmptyState({ icon={icon} title={title} description={description} - ctaSize="lg" action={cta} /> diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 668ca4813..934bb138a 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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 ( diff --git a/src/components/layout/module-disabled-notice.tsx b/src/components/layout/module-disabled-notice.tsx index 84e5a35aa..3fb4373e0 100644 --- a/src/components/layout/module-disabled-notice.tsx +++ b/src/components/layout/module-disabled-notice.tsx @@ -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} /> diff --git a/src/components/layout/shared-record-unavailable.tsx b/src/components/layout/shared-record-unavailable.tsx index aa9775faf..9e22ee107 100644 --- a/src/components/layout/shared-record-unavailable.tsx +++ b/src/components/layout/shared-record-unavailable.tsx @@ -36,7 +36,6 @@ export function SharedRecordUnavailable() { icon={} title={t("recordSharing.unavailable.title")} description={t("recordSharing.unavailable.description")} - ctaSize="lg" action={ {wrenchButton} - {addButton} + {/* The empty state carries the add action while there is + nothing to list. */} + {!isLoading && !isError && (reminders?.length ?? 0) === 0 + ? null + : addButton} > } /> diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 71ed9f544..ba8e71c6f 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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. diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 44750aa0d..f946a6481 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -243,7 +243,6 @@ export function InventorySection({ icon={} title={t("medications.detail.bestand.empty")} description={t("medications.detail.bestand.emptyHelper")} - ctaSize="lg" action={ setAddOpen(true)}> diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 21e1badb9..eab8e9de8 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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. diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 1c91a139e..7aa6524b0 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -152,7 +152,6 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { : "practitioners.emptyDescription", )} action={debounced ? undefined : (addButton ?? undefined)} - ctaSize="lg" /> ) : ( diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 3f736a7ed..42704191e 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -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({ {action ? ( ` / `asChild` `` to the 44 px tap floor. - // The selectors target the wrapper's immediate child so - // existing CTAs that pass a plain `` (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} @@ -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} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 31e3493b8..af9c4ced8 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -104,7 +104,11 @@ export function VaccinationsView() { } 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 + } />