feat(ui): add shared ListRow primitive and adopt it for mini-cards

The bordered list-row "mini-card" appeared three times with three
shapes: the Vorsorge dashboard row (`rounded-md p-2.5`), the daily-
briefing rows (`rounded-md p-3`), and the measurement-list mobile row
(bespoke `rounded-lg p-3` div). Introduce `ListRow` (`rounded-lg border
p-3`, `asChild` per the shadcn convention) and migrate all three to it.
The primitive owns only the box shape; each caller keeps its layout,
colour, and interaction classes, so this is structural normalization
with no content or behaviour change.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:18:11 +02:00
1 parent d2be72d57a
commit 54a2d19833
4 files changed
+64 -16

No files matched your search

+9 -8
View File
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
@@ -230,28 +231,28 @@ function BriefingRow({
);
if (href) {
return (
<Link
href={href}
<ListRow
asChild
data-slot={dataSlot}
data-metric={sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3",
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{rowContent}
</Link>
<Link href={href}>{rowContent}</Link>
</ListRow>
);
}
return (
<div
<ListRow
data-slot={dataSlot}
data-metric={sourceMetric}
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
className="border-border/60 bg-card/40 relative flex items-start gap-3"
>
{rowContent}
</div>
</ListRow>
);
}
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
@@ -119,10 +120,12 @@ export function VorsorgeDashboardCard() {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
return (
<li
<ListRow
key={reminder.id}
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
asChild
className="flex items-center justify-between gap-3"
>
<li>
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium">
{resolveLabel(reminder, t)}
@@ -139,8 +142,8 @@ export function VorsorgeDashboardCard() {
</div>
<Button
type="button"
size="sm"
className="shrink-0"
size="default"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
@@ -156,7 +159,8 @@ export function VorsorgeDashboardCard() {
</>
)}
</Button>
</li>
</li>
</ListRow>
);
})}
</ul>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import {
FilterBar,
FilterBarDateRange,
@@ -1080,10 +1081,10 @@ export function MeasurementList({
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
<div
<ListRow
key={m.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 rounded-lg border p-3"
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
@@ -1243,7 +1244,7 @@ export function MeasurementList({
/>
</div>
)}
</div>
</ListRow>
);
})}
</div>
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.18.7 — shared list-row primitive.
*
* Before this, every list-row "mini-card" (a bordered row sitting inside
* a section or a `<Card>`) invented its own padding + radius: the
* Vorsorge dashboard card used `rounded-md p-2.5`, the daily-briefing
* rows `rounded-md p-3`, the measurement-list mobile row a bespoke
* `rounded-lg p-3` div. Three surfaces, three shapes for one visual
* concept. `ListRow` pins the canonical shape — `rounded-lg border p-3`
* — so the rows read as one language across the app.
*
* It owns ONLY the box shape (radius + border + padding). Layout
* (flex direction, alignment, gap), colour overrides, and interaction
* state stay with the caller via `className`, so migrating a bespoke row
* is purely dropping the radius/border/padding literals and keeping the
* rest.
*
* `asChild` renders the row as its single child (e.g. a `<Link>` or
* `<li>`) while still applying the shared classes, matching the shadcn
* primitive convention used elsewhere in `ui/`.
*/
function ListRow({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="list-row"
className={cn("rounded-lg border p-3", className)}
{...props}
/>
);
}
export { ListRow };