mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(insights): mood calendar and chart arrive together; add via the header plus
On /insights/mood the line chart used to paint first and the calendar, whose aggregate is the slower read, pushed it down when it landed (CLS 0.16 at 390 px on a cold load). The page now holds both on final-height skeletons until the chart's read has settled and the calendar's has landed, then shows them in the same frame. The calendar's height is written in container-query units, so its box is the right size from the first paint, in the skeleton and the painted grid alike. A server wrapper prefetches both reads in parallel (time-boxed, skipped under a record switch, off when SSR prefetch is off), so on a warm cache they are in the first HTML. The route and the prefetch share `moodInsightsWire`, so the seeded cell is the route's shape. The "Log a mood entry" and "Take a mental-wellbeing check-in" text links under the heading are gone. Logging is the header plus, which opens the mood form in place; the check-in keeps its own place in the navigation. The plus is one shared component, `SubPageAddButton`, now also behind the measurement pages' capture link and the workouts page, which drew its own. An audit of every Insights page found no other text add-button of this kind; the remaining buttons are empty-state calls to action. A sweep keeps it that way. `e2e/v142-qol.spec.ts` covers all three surfaces of this change set.
This commit is contained in:
1 parent
1a5eab9f7c
commit
41ed8eb6a3
21 files changed
+1223
-339
No files matched your search
@@ -46,6 +46,79 @@ const CELL_FLOOR_PX = 14;
|
||||
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so
|
||||
// the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`.
|
||||
const CELL_CEIL_PX = 22;
|
||||
const HEADER_PX = 18;
|
||||
|
||||
/**
|
||||
* The grid's height in the stretch layout, as CSS, so it is known before the
|
||||
* grid has measured anything — on the server, in the loading skeleton, and in
|
||||
* the first client frame alike.
|
||||
*
|
||||
* The stretch cell is the container width shared out across the weeks,
|
||||
* clamped to the floor and the ceiling; the grid is the month header plus
|
||||
* seven cells and six gaps. The same rule `MoodHeatmap` applies in JavaScript
|
||||
* once it has measured, written here in container-query units so the box is
|
||||
* the right height from the first paint and nothing below it moves when the
|
||||
* measurement lands. Read against the nearest `container-type: inline-size`
|
||||
* ancestor, which `MoodHeatmapBody` provides.
|
||||
*/
|
||||
export function moodHeatmapStretchHeight(weeks: number): string {
|
||||
const w = Math.max(1, weeks);
|
||||
const cell = `clamp(${CELL_FLOOR_PX}px, calc((100cqw - ${(w - 1) * GAP}px) / ${w}), ${CELL_CEIL_PX}px)`;
|
||||
return `calc(${HEADER_PX + 6 * GAP}px + 7 * ${cell})`;
|
||||
}
|
||||
|
||||
/** Monday-aligned columns a window of `days` spans, as the grid lays it out. */
|
||||
export function moodHeatmapWeeks(days: number, timeZone: string): number {
|
||||
const dates = heatmapDays(new Date(), timeZone, days);
|
||||
const firstDow = dates[0]?.dow ?? 0;
|
||||
return Math.floor((dates.length - 1 + firstDow) / 7) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size container + the reserved box the grid paints into. Shared by the
|
||||
* heatmap and its skeleton so the two can never disagree about the height.
|
||||
*/
|
||||
export function MoodHeatmapBody({
|
||||
weeks,
|
||||
children,
|
||||
}: {
|
||||
weeks: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ containerType: "inline-size" }} className="w-full">
|
||||
<div
|
||||
data-slot="mood-heatmap-body"
|
||||
style={{ height: moodHeatmapStretchHeight(weeks) }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading shape of the stretched heatmap: the reserved grid box and the
|
||||
* legend line, at exactly the height the painted heatmap will take.
|
||||
* `days` is the window when it is already known; without it the shape
|
||||
* assumes a window short enough to reach the cell ceiling, which every
|
||||
* window under a quarter year does on any width.
|
||||
*/
|
||||
export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
|
||||
const timeZone = useDisplayTimezone();
|
||||
return (
|
||||
<div
|
||||
data-slot="mood-heatmap-skeleton"
|
||||
aria-hidden="true"
|
||||
className="w-full"
|
||||
>
|
||||
<MoodHeatmapBody weeks={moodHeatmapWeeks(days, timeZone)}>
|
||||
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
</MoodHeatmapBody>
|
||||
<div className="mt-2 h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
|
||||
@@ -209,7 +282,7 @@ export function MoodHeatmap({
|
||||
});
|
||||
|
||||
const labelWidth = stretch ? 0 : 76;
|
||||
const headerHeight = 18;
|
||||
const headerHeight = HEADER_PX;
|
||||
const cellSize =
|
||||
stretch && containerWidth > 0
|
||||
? Math.min(
|
||||
@@ -243,116 +316,118 @@ export function MoodHeatmap({
|
||||
|
||||
return (
|
||||
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
|
||||
<div
|
||||
className={
|
||||
stretch
|
||||
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
|
||||
: "overflow-x-auto"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
role="img"
|
||||
aria-label={summaryLabel}
|
||||
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
|
||||
// natural, square, left-aligned grid rather than CSS-stretching a few
|
||||
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
|
||||
className={stretch ? "block max-w-full" : "block"}
|
||||
onMouseLeave={() =>
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null))
|
||||
<StretchFrame stretch={stretch} weeks={weeks}>
|
||||
<div
|
||||
className={
|
||||
stretch
|
||||
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
|
||||
: "overflow-x-auto"
|
||||
}
|
||||
>
|
||||
{monthMarkers.map((m, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={labelWidth + m.col * step}
|
||||
y={11}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={10}
|
||||
>
|
||||
{m.label}
|
||||
</text>
|
||||
))}
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
role="img"
|
||||
aria-label={summaryLabel}
|
||||
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
|
||||
// natural, square, left-aligned grid rather than CSS-stretching a few
|
||||
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
|
||||
className={stretch ? "block max-w-full" : "block"}
|
||||
onMouseLeave={() =>
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null))
|
||||
}
|
||||
>
|
||||
{monthMarkers.map((m, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={labelWidth + m.col * step}
|
||||
y={11}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={10}
|
||||
>
|
||||
{m.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{!stretch &&
|
||||
WEEKDAY_LABELS.map(
|
||||
(label, i) =>
|
||||
label && (
|
||||
<text
|
||||
key={i}
|
||||
x={labelWidth - 6}
|
||||
y={headerHeight + i * step + cellSize * 0.65}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={10}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
{!stretch &&
|
||||
WEEKDAY_LABELS.map(
|
||||
(label, i) =>
|
||||
label && (
|
||||
<text
|
||||
key={i}
|
||||
x={labelWidth - 6}
|
||||
y={headerHeight + i * step + cellSize * 0.65}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={10}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => describeCell(cell);
|
||||
return (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
data-day={cell.dateKey}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the rects: a cell subtree nested
|
||||
// under the SVG's `role="img"` is pruned from the a11y tree,
|
||||
// and per-cell tab stops would flood the keyboard order on a
|
||||
// year-long grid. The rects stay a pure pointer affordance.
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.cell !== null && isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.cell === null) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => describeCell(cell);
|
||||
return (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
data-day={cell.dateKey}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the rects: a cell subtree nested
|
||||
// under the SVG's `role="img"` is pruned from the a11y tree,
|
||||
// and per-cell tab stops would flood the keyboard order on a
|
||||
// year-long grid. The rects stay a pure pointer affordance.
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.cell !== null && isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.cell === null) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</StretchFrame>
|
||||
|
||||
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
|
||||
carries an aggregate `role="img"` summary; this list is the
|
||||
@@ -431,3 +506,17 @@ export function MoodHeatmap({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The stretch layout paints into the reserved box; the fixed one sizes itself. */
|
||||
function StretchFrame({
|
||||
stretch,
|
||||
weeks,
|
||||
children,
|
||||
}: {
|
||||
stretch: boolean;
|
||||
weeks: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!stretch) return <>{children}</>;
|
||||
return <MoodHeatmapBody weeks={weeks}>{children}</MoodHeatmapBody>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.42 — the mood calendar no longer pops in after the line chart.
|
||||
*
|
||||
* While the shared read is pending, and while the page holds the reveal until
|
||||
* the chart is ready too, the heatmap region paints the calendar's card with
|
||||
* a skeleton at the calendar's final height — the same reserved box the
|
||||
* painted grid uses — instead of nothing. Before this release the region
|
||||
* rendered nothing while loading, so the calendar arrived later and pushed
|
||||
* the chart down.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1" },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const state = { current: {} as Record<string, unknown> };
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => state.current,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MoodInsightsSections } from "../mood-insights-sections";
|
||||
|
||||
const LOADED = {
|
||||
data: {
|
||||
summary: { totalEntries: 12, inTargetPct: null },
|
||||
heatmap: { windowDays: 40, cells: [] },
|
||||
betterDays: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
|
||||
function render(reveal?: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodInsightsSections region="heatmap" reveal={reveal} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<MoodInsightsSections region=heatmap> reveal", () => {
|
||||
it("paints the calendar card with a reserved-height skeleton while the read is pending", () => {
|
||||
state.current = {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isPending: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain("Mood calendar");
|
||||
expect(html).toContain('data-slot="mood-heatmap-skeleton"');
|
||||
expect(html).toMatch(/data-slot="mood-heatmap-body" style="height:calc\(/);
|
||||
});
|
||||
|
||||
it("holds the skeleton while the page has not revealed, then paints the grid", () => {
|
||||
state.current = LOADED;
|
||||
const held = render(false);
|
||||
expect(held).toContain('data-slot="mood-heatmap-skeleton"');
|
||||
const shown = render(true);
|
||||
expect(shown).not.toContain('data-slot="mood-heatmap-skeleton"');
|
||||
expect(shown).toContain('role="img"');
|
||||
});
|
||||
|
||||
it("the skeleton and the grid reserve the same box for the same window", () => {
|
||||
state.current = LOADED;
|
||||
const box = (html: string) =>
|
||||
/data-slot="mood-heatmap-body" style="(height:[^"]+)"/.exec(html)?.[1];
|
||||
expect(box(render(false))).toBeDefined();
|
||||
expect(box(render(false))).toBe(box(render(true)));
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
|
||||
import {
|
||||
MoodHeatmap,
|
||||
MoodHeatmapSkeleton,
|
||||
} from "@/components/charts/mood-heatmap";
|
||||
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -97,15 +100,14 @@ const MoodInsightsBreakdowns = dynamic(
|
||||
*/
|
||||
export type MoodInsightsRegion = "heatmap" | "assessment" | "rest";
|
||||
|
||||
export function MoodInsightsSections({
|
||||
region = "rest",
|
||||
}: {
|
||||
region?: MoodInsightsRegion;
|
||||
} = {}) {
|
||||
/**
|
||||
* The one read every region shares, exported so the page can wait on the same
|
||||
* cell (same key, same fetch, same options) when it decides when the calendar
|
||||
* and the line chart appear.
|
||||
*/
|
||||
export function useMoodInsights() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
return useQuery({
|
||||
queryKey: queryKeys.moodInsights(),
|
||||
queryFn: async () => {
|
||||
return apiGet<MoodInsightsResponse>("/api/mood/insights");
|
||||
@@ -113,11 +115,42 @@ export function MoodInsightsSections({
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function MoodInsightsSections({
|
||||
region = "rest",
|
||||
reveal = true,
|
||||
}: {
|
||||
region?: MoodInsightsRegion;
|
||||
/**
|
||||
* v1.42 — heatmap region only: hold the calendar on its skeleton until the
|
||||
* page says the line chart below is ready too, so the two appear in the
|
||||
* same frame instead of one popping in after the other. The skeleton holds
|
||||
* the calendar's final height, so the reveal moves nothing.
|
||||
*/
|
||||
reveal?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
|
||||
|
||||
// `isPending`, not `isLoading`: before the session resolves the read is
|
||||
// disabled rather than loading, and the frame must already be there.
|
||||
if (region === "heatmap" && (isPending || (!reveal && !isError))) {
|
||||
// The calendar's frame and final height from the first paint; the
|
||||
// window is known once the read has landed, assumed short until then.
|
||||
if (data && data.summary.totalEntries === 0) return null;
|
||||
return (
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
|
||||
<MoodHeatmapSkeleton days={data?.heatmap.windowDays} />
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Only the "rest" region carries the page-level loading skeleton; the
|
||||
// heatmap / assessment regions stay invisible while loading so they don't
|
||||
// stack three skeletons down the page.
|
||||
// Only the "rest" region carries the page-level loading skeleton (the
|
||||
// heatmap's own is above); the assessment region stays invisible while
|
||||
// loading so the page does not stack skeletons down its length.
|
||||
return region === "rest" ? (
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
) : null;
|
||||
|
||||
@@ -161,6 +161,65 @@ export interface SubPageShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's add action — one plus, one shape, on every Insights page that
|
||||
* offers one. A 40 px ghost icon with the extended hit area its cluster
|
||||
* siblings carry; the accessible name rides on `aria-label` and `title`.
|
||||
*
|
||||
* Two forms: `href` links to a capture form elsewhere (the measurement pages'
|
||||
* `captureType`), `onClick` opens something in place (the workouts and mood
|
||||
* pages). A page never draws its own "log an entry" text link instead.
|
||||
*/
|
||||
export function SubPageAddButton({
|
||||
label,
|
||||
href,
|
||||
onClick,
|
||||
slot,
|
||||
}: {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
/** `data-slot` for tests; defaults to the shared one. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const className = cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
);
|
||||
const icon = <Plus className="size-4" aria-hidden="true" />;
|
||||
if (href) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot={slot ?? "metric-add-reading"}
|
||||
data-header-add=""
|
||||
className={className}
|
||||
>
|
||||
<Link href={href} aria-label={label} title={label}>
|
||||
{icon}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot={slot ?? "metric-add-reading"}
|
||||
data-header-add=""
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubPageShell({
|
||||
title,
|
||||
backLink,
|
||||
@@ -319,24 +378,7 @@ export function SubPageShell({
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{headerAction}
|
||||
{captureHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="metric-add-reading"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={captureHref}
|
||||
aria-label={captureLabel}
|
||||
title={captureLabel}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<SubPageAddButton label={captureLabel} href={captureHref} />
|
||||
) : null}
|
||||
{/* v1.16.8 — "show all readings" rides the header cluster
|
||||
as an icon button, LEFT of the target-adjust gear (it
|
||||
|
||||
Reference in new issue
Block a user