mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Extract shared BackLink and adopt on detail pages
The workout, values, score, and medication detail pages each hand-rolled the same ghost Button + Link + ArrowLeft back-nav block. Fold it into a single BackLink (href + label + optional data-slot) so the affordance reads identically — same hit area, icon, and left-bleed alignment — on every page and lives in one place. Each page passes its existing label translation, so no new i18n keys are introduced and the markup stays byte-identical.
This commit is contained in:
1 parent
cc34cbd03d
commit
e2f37bbe40
6 files changed
+109
-55
No files matched your search
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
@@ -88,18 +86,11 @@ export default function CompositeScorePage() {
|
||||
description={t(header.description)}
|
||||
explainerMetric={EXPLAINER_METRIC[metric]}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="composite-score-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.scoresBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label={t("insights.subPage.scoresBack")}
|
||||
dataSlot="composite-score-back"
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
} from "@/lib/insights/values-back-link";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -63,22 +61,17 @@ export default function InsightsMetricValuesPage({
|
||||
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
|
||||
description={t("insights.subPage.valuesPageDescription")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="metric-values-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{backToOrigin
|
||||
<BackLink
|
||||
href={backHref}
|
||||
label={
|
||||
backToOrigin
|
||||
? t("insights.subPage.valuesBackToMetric", {
|
||||
metric: metricLabel,
|
||||
})
|
||||
: t("insights.subPage.valuesBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
: t("insights.subPage.valuesBack")
|
||||
}
|
||||
dataSlot="metric-values-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MeasurementList lockedType={type} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -41,18 +39,11 @@ export default function InsightsWorkoutDetailPage({
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="workout-detail-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights/workouts">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.workouts.detail.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
label={t("insights.workouts.detail.backToList")}
|
||||
dataSlot="workout-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -13,16 +13,15 @@
|
||||
*/
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
MedicationDetailTabs,
|
||||
@@ -87,12 +86,7 @@ export default function MedicationDetailPage({
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink href="/medications" label={t("medications.back")} />
|
||||
<Card
|
||||
className="p-6"
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { BackLink } from "../back-link";
|
||||
|
||||
describe("<BackLink>", () => {
|
||||
it("renders an anchor to the href carrying the label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/insights/workouts" label="Back to workouts" />,
|
||||
);
|
||||
expect(html).toContain('href="/insights/workouts"');
|
||||
expect(html).toContain("Back to workouts");
|
||||
// The decorative chevron is hidden from screen readers; the label
|
||||
// carries the meaning.
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it("keeps the shared left-bleed alignment on the control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/medications" label="Back" />,
|
||||
);
|
||||
// The `-ml-2 w-fit` left-bleed is the standard back-nav placement; it
|
||||
// must survive on every adopting page.
|
||||
expect(html).toContain("-ml-2");
|
||||
expect(html).toContain("w-fit");
|
||||
});
|
||||
|
||||
it("threads the optional data-slot for e2e/visual targeting", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label="Back"
|
||||
dataSlot="composite-score-back"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="composite-score-back"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.16 — shared back-navigation control.
|
||||
*
|
||||
* The detail / sub-page surfaces (`/insights/workouts/[id]`,
|
||||
* `/insights/values/[type]`, `/insights/scores/[metric]`,
|
||||
* `/medications/[id]`) each hand-rolled the identical
|
||||
* `Button(ghost, sm, -ml-2 w-fit) > Link > ArrowLeft + label` block. This
|
||||
* folds that into one component so the back-nav reads the same — same hit
|
||||
* area, same icon, same left-bleed alignment — on every page, and a future
|
||||
* change to the affordance lives in one place.
|
||||
*
|
||||
* The label travels in from the caller (each page already owns its own
|
||||
* translation key, e.g. `insights.workouts.detail.backToList`), so this
|
||||
* component adds no new i18n surface.
|
||||
*/
|
||||
export interface BackLinkProps {
|
||||
/** Internal href the back-nav points at. */
|
||||
href: string;
|
||||
/** Already-translated label text. */
|
||||
label: string;
|
||||
/** Optional per-surface `data-slot` for e2e/visual targeting. */
|
||||
dataSlot?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackLink({ href, label, dataSlot, className }: BackLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot={dataSlot}
|
||||
className={cn("-ml-2 w-fit", className)}
|
||||
>
|
||||
<Link href={href}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user