mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(ui): open hint content on a tap instead of a hover
Seven places kept information in a hover tooltip, which never opens on a phone: the measurement-diversity lightbulb on the metric pages, the sleep-source warning on the dashboard tile, the score coverage breakdown, the target status pill, the range bar's marker, the colour hint of the dashboard averages and the full token and user names in the admin token list. The lightbulb showed a button that did nothing, the warning a sign nobody could read. They open on a tap now: InfoPopover for the two icon buttons, a Popover on the meter, pill, marker, averages and cells. Where the trigger sits in a link (a dashboard tile, a lab row) its tap opens the popover instead of the page.
This commit is contained in:
1 parent
6f9b42c35e
commit
2a60f21a8b
11 files changed
+323
-326
No files matched your search
@@ -70,7 +70,7 @@
|
||||
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
||||
"components/charts/mood-chart.tsx :: MoodChart": 896,
|
||||
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
|
||||
"components/charts/trend-card.tsx :: TrendCard": 365,
|
||||
"components/charts/trend-card.tsx :: TrendCard": 351,
|
||||
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
|
||||
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
|
||||
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 231,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
*
|
||||
* The fix is aggressive: every cell that could carry a long string
|
||||
* gets `truncate` (overflow-hidden + text-ellipsis + whitespace-nowrap)
|
||||
* AND a tooltip on hover/long-press so the full value is still
|
||||
* AND a popover on a tap so the full value is still
|
||||
* discoverable. This is the same pattern UserManagementSection uses
|
||||
* — apply it consistently here.
|
||||
*/
|
||||
@@ -83,7 +83,7 @@ function render() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiTokenOverviewSection — truncate + tooltip (4th attempt)", () => {
|
||||
describe("ApiTokenOverviewSection — truncate + popover", () => {
|
||||
it("mobile token-name span does NOT carry the contradictory `break-all` modifier", () => {
|
||||
// `truncate` already sets `white-space: nowrap`, which beats
|
||||
// `word-break: break-all` per CSS spec — `break-all` was dead
|
||||
@@ -125,11 +125,11 @@ describe("ApiTokenOverviewSection — truncate + tooltip (4th attempt)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders shadcn tooltip wrappers around truncated cells so the full value stays discoverable", () => {
|
||||
it("makes each truncated cell a button that opens the full value on a tap", () => {
|
||||
const html = render();
|
||||
// Tooltip primitive uses `data-slot="tooltip-content"` for the
|
||||
// hover bubble. Verify at least one tooltip-content node was
|
||||
// rendered next to a truncated cell.
|
||||
expect(html).toContain('data-slot="tooltip-trigger"');
|
||||
// A hover tooltip never opens on a phone; the popover trigger does.
|
||||
expect(html).toMatch(
|
||||
/<button type="button"[^>]*data-slot="popover-trigger"[^>]*\btruncate\b/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,10 @@ import { Key, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -21,8 +20,9 @@ import { type ApiTokenInfo } from "./_shared";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Wraps a string in `truncate` + tooltip so a long token name /
|
||||
* username / permission cannot push the row past its bounds.
|
||||
* Wraps a string in `truncate` + a popover so a long token name /
|
||||
* username cannot push the row past its bounds, and a tap still shows all
|
||||
* of it.
|
||||
*
|
||||
* v1.4.19 (4th attempt) — earlier fixes (column-hide,
|
||||
* mobile card-list, mobile-strip `no-scrollbar`) cleared every
|
||||
@@ -34,11 +34,10 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
* desktop table cells had no upper-bound on width — a long token
|
||||
* name on a 768-1024 px viewport could spill into the next column.
|
||||
*
|
||||
* Switching every potentially-long cell to a hover tooltip means
|
||||
* the visible row stays inside the card no matter what the data
|
||||
* looks like. The native `title` attribute is a graceful fallback
|
||||
* for screen readers and right-click → "view full name" on desktop;
|
||||
* the radix tooltip handles hover and keyboard focus.
|
||||
* Truncating every potentially-long cell means the visible row stays
|
||||
* inside the card no matter what the data looks like. The full value opens
|
||||
* on a tap or a key (a hover tooltip never opened on a phone); `title`
|
||||
* still shows it on hover.
|
||||
*/
|
||||
function TruncatedCell({
|
||||
value,
|
||||
@@ -48,16 +47,17 @@ function TruncatedCell({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`block truncate ${className ?? ""}`} title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{value}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={`focus-visible:ring-ring/50 block w-full truncate rounded-sm text-left focus-visible:ring-2 focus-visible:outline-none ${className ?? ""}`}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="break-all">
|
||||
{value}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
|
||||
import type { TrendSlope } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
@@ -25,6 +24,43 @@ interface SecondaryMetric {
|
||||
avg30: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An average whose colour has a meaning: a tap shows what the colour says.
|
||||
* The tile is a link to the metric's page, and this tap opens the hint, not
|
||||
* the page.
|
||||
*/
|
||||
function HintedValue({
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
hint: React.ReactNode;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="contents"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 rounded-sm underline decoration-dotted underline-offset-2 focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="space-y-1">{hint}</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrendCardProps {
|
||||
label: string;
|
||||
latest: number | null;
|
||||
@@ -389,90 +425,76 @@ export function TrendCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
|
||||
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
|
||||
so a narrow tile cannot grow vertically beyond the
|
||||
140-px contract. `sm:` releases back to the original
|
||||
wrap behaviour. */}
|
||||
<div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
|
||||
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t(avg7LabelKey)}:{" "}
|
||||
{avg7Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg7ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
<div className="space-y-1 text-xs">{avg7Hint}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg7ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</span>
|
||||
)}
|
||||
{trend7Delta != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
deltaColor,
|
||||
)}
|
||||
data-slot="trend7-delta"
|
||||
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
|
||||
>
|
||||
({formatDelta(trend7Delta)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t("charts.avg30dShort")}:{" "}
|
||||
{avg30Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg30ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-muted border-border text-foreground">
|
||||
<div className="space-y-1 text-xs">{avg30Hint}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg30ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* v1.4.28 — the all-time third sub-value retired
|
||||
<div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
|
||||
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t(avg7LabelKey)}:{" "}
|
||||
{avg7Hint ? (
|
||||
<HintedValue
|
||||
hint={avg7Hint}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg7ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</HintedValue>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg7ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</span>
|
||||
)}
|
||||
{trend7Delta != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
deltaColor,
|
||||
)}
|
||||
data-slot="trend7-delta"
|
||||
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
|
||||
>
|
||||
({formatDelta(trend7Delta)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t("charts.avg30dShort")}:{" "}
|
||||
{avg30Hint ? (
|
||||
<HintedValue
|
||||
hint={avg30Hint}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg30ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
|
||||
</HintedValue>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg30ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* v1.4.28 — the all-time third sub-value retired
|
||||
alongside the `avgAllTime*` props. The BD-Zielbereich
|
||||
tile was the only consumer; the same number stays
|
||||
visible on the Insights blood-pressure target panel with
|
||||
full context. Every dashboard tile now ships the same two
|
||||
sub-rows. */}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TrendTile>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -20,7 +18,7 @@ import type {
|
||||
*
|
||||
* `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs`
|
||||
* ratio off `Derived<T>.coverage`, tinted by the `confidence.band`, with an
|
||||
* accessible breakdown tooltip (present vs required inputs, history days,
|
||||
* accessible breakdown on a tap (present vs required inputs, history days,
|
||||
* and the named missing inputs). This is the one place the whole app speaks
|
||||
* data-confidence — every score tile, anatomy view, correlation card,
|
||||
* briefing and report header renders it identically.
|
||||
@@ -29,7 +27,8 @@ import type {
|
||||
* shown WITH its confidence, never silently downgraded and never blank. The
|
||||
* dots read as intentional ("4 of 5 inputs · 80%"), not apologetic.
|
||||
*
|
||||
* CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime.
|
||||
* CSS-only dots + a `Popover` for the breakdown: it opens on a tap, which a
|
||||
* hover tooltip never does on a phone.
|
||||
*/
|
||||
|
||||
export interface CoverageMeterProps {
|
||||
@@ -46,7 +45,7 @@ export interface CoverageMeterProps {
|
||||
size?: "sm" | "md";
|
||||
/**
|
||||
* Replaces the bare `{present}/{required}` fraction, in the visible
|
||||
* label, the `aria-label` and the tooltip heading alike.
|
||||
* label, the `aria-label` and the breakdown heading alike.
|
||||
*
|
||||
* The default fraction reads as a proportion of the reader's own data,
|
||||
* and for one caller it is not: the Health Score's composite counts
|
||||
@@ -94,7 +93,6 @@ export function CoverageMeter({
|
||||
className,
|
||||
}: CoverageMeterProps) {
|
||||
const { t } = useTranslations();
|
||||
const tooltipId = useId();
|
||||
|
||||
const required = clampInt(coverage.requiredInputs, 0, 999);
|
||||
const present = clampInt(coverage.presentInputs, 0, required || 999);
|
||||
@@ -128,90 +126,86 @@ export function CoverageMeter({
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coverage-meter"
|
||||
data-band={band}
|
||||
data-present={present}
|
||||
data-required={required}
|
||||
data-percent={percent}
|
||||
aria-describedby={tooltipId}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coverage-meter"
|
||||
data-band={band}
|
||||
data-present={present}
|
||||
data-required={required}
|
||||
data-percent={percent}
|
||||
className={cn(
|
||||
// 44px hit target via padding while the dots stay optically
|
||||
// small; negative margin collapses the row back so the meter
|
||||
// doesn't inflate its host row.
|
||||
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-slot="coverage-meter-dots"
|
||||
className="inline-flex items-center gap-[3px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: totalDots }).map((_, i) => {
|
||||
const isLit = i < litDots;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-dot-index={i}
|
||||
data-dot-state={isLit ? "lit" : "unlit"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
dotSize,
|
||||
isLit ? dotLit : "bg-muted/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
data-slot="coverage-meter-label"
|
||||
className={cn(
|
||||
// 44px hit target via padding while the dots stay optically
|
||||
// small; negative margin collapses the row back so the meter
|
||||
// doesn't inflate its host row.
|
||||
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
"text-muted-foreground tabular-nums",
|
||||
size === "md" ? "text-xs" : "text-2xs",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-slot="coverage-meter-dots"
|
||||
className="inline-flex items-center gap-[3px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: totalDots }).map((_, i) => {
|
||||
const isLit = i < litDots;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-dot-index={i}
|
||||
data-dot-state={isLit ? "lit" : "unlit"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
dotSize,
|
||||
isLit ? dotLit : "bg-muted/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
{axisLabel ??
|
||||
t("insights.derived.coverage.ratioLabel", {
|
||||
present,
|
||||
required,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
data-slot="coverage-meter-label"
|
||||
className={cn(
|
||||
"text-muted-foreground tabular-nums",
|
||||
size === "md" ? "text-xs" : "text-2xs",
|
||||
)}
|
||||
>
|
||||
{axisLabel ??
|
||||
t("insights.derived.coverage.ratioLabel", {
|
||||
present,
|
||||
required,
|
||||
})}
|
||||
</span>
|
||||
{/* The visible ratio names the button; the sentence adds what it
|
||||
</span>
|
||||
{/* The visible ratio names the button; the sentence adds what it
|
||||
counts (WCAG 2.5.3: the name contains what is shown). */}
|
||||
<span className="sr-only">{summaryLabel}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
id={tooltipId}
|
||||
data-slot="coverage-meter-tooltip"
|
||||
className="bg-muted border-border text-foreground max-w-[15rem]"
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium">{summaryLabel}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("insights.derived.coverage.historyDays", {
|
||||
count: clampInt(coverage.historyDays, 0, 99999),
|
||||
<span className="sr-only">{summaryLabel}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="coverage-meter-tooltip"
|
||||
className="max-w-[15rem]"
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium">{summaryLabel}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("insights.derived.coverage.historyDays", {
|
||||
count: clampInt(coverage.historyDays, 0, 99999),
|
||||
})}
|
||||
</p>
|
||||
{coverage.missing.length > 0 && (
|
||||
<p
|
||||
data-slot="coverage-meter-missing"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("insights.derived.coverage.missing", {
|
||||
list: coverage.missing.join(", "),
|
||||
})}
|
||||
</p>
|
||||
{coverage.missing.length > 0 && (
|
||||
<p
|
||||
data-slot="coverage-meter-missing"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("insights.derived.coverage.missing", {
|
||||
list: coverage.missing.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -29,7 +24,7 @@ import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity
|
||||
* inline `role="note"` block confused on the category pages (it read
|
||||
* like a data finding rather than a tip), so the nudge now renders as a
|
||||
* small `Lightbulb` glyph beside the page title. The hint text the block
|
||||
* used to show inline lives in a hover / focus Tooltip; the glyph mounts
|
||||
* used to show inline opens on a tap (`InfoPopover`); the glyph mounts
|
||||
* only when the spread is actually lopsided, so a healthy metric shows
|
||||
* no extra affordance at all.
|
||||
*
|
||||
@@ -95,26 +90,15 @@ export function MeasurementDiversityNudge({
|
||||
const triggerLabel = t("insights.subPage.diversity.trigger");
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="measurement-diversity-nudge"
|
||||
aria-label={triggerLabel}
|
||||
className="text-warning hover:text-warning/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Lightbulb className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-slot="measurement-diversity-nudge-body"
|
||||
align="start"
|
||||
className="max-w-xs leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<InfoPopover
|
||||
content={message}
|
||||
label={triggerLabel}
|
||||
icon={Lightbulb}
|
||||
iconClassName="size-4"
|
||||
className="text-warning hover:text-warning/80"
|
||||
align="start"
|
||||
triggerDataSlot="measurement-diversity-nudge"
|
||||
bodyDataSlot="measurement-diversity-nudge-body"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels";
|
||||
@@ -17,7 +12,7 @@ import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels
|
||||
* `sleep-hypnogram.tsx` (which introduced it in v1.28.21) so the dashboard
|
||||
* sleep tile and the hypnogram render the identical hint off the identical
|
||||
* wire shape. Observational only: the headline number always stays the
|
||||
* winning writer's total; the tooltip lists each writer's own total so a
|
||||
* winning writer's total; the popover lists each writer's own total so a
|
||||
* multi-tracker user can see WHY the night reads surprising.
|
||||
*/
|
||||
|
||||
@@ -35,7 +30,7 @@ export interface SleepSourceDiscrepancyDto {
|
||||
* Localised device-type label keys (`Measurement.deviceType`), reused from
|
||||
* the source-priority settings surface. Appended in parentheses so two
|
||||
* writer apps behind the same source (watch vs phone under Apple Health)
|
||||
* stay distinguishable in the discrepancy tooltip.
|
||||
* stay distinguishable in the discrepancy popover.
|
||||
*/
|
||||
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
watch: "settings.sections.sources.deviceLabels.watch",
|
||||
@@ -64,7 +59,7 @@ export function sourceDisplayName(
|
||||
|
||||
export interface SleepSourceDiscrepancyMarkerProps {
|
||||
discrepancy: SleepSourceDiscrepancyDto | null | undefined;
|
||||
/** Tooltip alignment relative to the trigger (default `"start"`). */
|
||||
/** Popover alignment relative to the trigger (default `"start"`). */
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
@@ -82,36 +77,36 @@ export function SleepSourceDiscrepancyMarker({
|
||||
const { t } = useTranslations();
|
||||
if (!discrepancy) return null;
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="sleep-source-discrepancy"
|
||||
aria-label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
|
||||
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<TriangleAlert className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-slot="sleep-source-discrepancy-body"
|
||||
align={align}
|
||||
className="max-w-xs leading-relaxed"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
|
||||
</p>
|
||||
<p>
|
||||
// On the dashboard the marker sits in the tile's link: its tap opens the
|
||||
// popover, not the tile.
|
||||
<span
|
||||
className="contents"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<InfoPopover
|
||||
content={
|
||||
<>
|
||||
<span className="text-foreground block font-medium">
|
||||
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
|
||||
</span>
|
||||
{discrepancy.sources
|
||||
.map(
|
||||
(b) =>
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatDurationMinutes(b.asleepMinutes, t)}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
}
|
||||
label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
|
||||
icon={TriangleAlert}
|
||||
className="-mx-3"
|
||||
align={align}
|
||||
triggerDataSlot="sleep-source-discrepancy"
|
||||
bodyDataSlot="sleep-source-discrepancy-body"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -85,16 +85,17 @@ describe("<RangeBar>", () => {
|
||||
expect(marker).toBeLessThan(100 - EDGE_PADDING_PERCENT);
|
||||
});
|
||||
|
||||
it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => {
|
||||
it("makes the marker a button named with the value + range, so a tap or a key opens its numbers", () => {
|
||||
const html = render({
|
||||
value: 72,
|
||||
min: 60,
|
||||
max: 100,
|
||||
unit: "bpm",
|
||||
});
|
||||
expect(html).toContain('tabindex="0"');
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain('aria-label="Current: 72 bpm');
|
||||
expect(html).toMatch(
|
||||
/<button type="button" aria-label="Current: 72 bpm[^"]*"/,
|
||||
);
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
it("prints value, bounds and delta in the reader's number format", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
* so the Targets card and the Insights reference panel render the
|
||||
* identical pill from the same server-emitted classification category.
|
||||
* These tests pin the category → semantic-group mapping (which drives
|
||||
* the pill colour) and the rendered label + range/source tooltip.
|
||||
* the pill colour) and the rendered label + range/source popover.
|
||||
*/
|
||||
|
||||
function render(props: Parameters<typeof TargetStatusPill>[0]) {
|
||||
@@ -61,8 +61,9 @@ describe("<TargetStatusPill>", () => {
|
||||
expect(html).toContain('data-slot="target-status-pill"');
|
||||
expect(html).toContain('data-status="in"');
|
||||
expect(html).toContain("Optimal");
|
||||
// The tooltip trigger is the pill itself; the range/source content
|
||||
// lives behind a closed Radix tooltip and is not in static markup.
|
||||
// The pill sits in a button that opens the range and source on a tap;
|
||||
// the popover is closed, so its content is not in static markup.
|
||||
expect(html).toMatch(/<button type="button"[^>]*aria-haspopup="dialog"/);
|
||||
expect(html).toContain('data-state="closed"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* A horizontal range bar with green/yellow/red zones showing where
|
||||
* the current value falls relative to a target range. The marker dot
|
||||
* carries a tooltip with the current value + the target range + a
|
||||
* delta sentence (e.g. "6 mmHg above target band").
|
||||
* the current value falls relative to a target range. A tap on the marker
|
||||
* dot shows the current value + the target range + a delta sentence (e.g.
|
||||
* "6 mmHg above target band").
|
||||
*
|
||||
* Behaviour is unchanged from the v1.4.22 inline version; only the
|
||||
* file boundary moves.
|
||||
@@ -44,6 +43,15 @@ export interface RangeBarProps {
|
||||
*/
|
||||
export const EDGE_PADDING_PERCENT = 4;
|
||||
|
||||
/**
|
||||
* The bar can sit in a list row that is itself a link: a tap on the marker
|
||||
* or its popover opens the popover, not the row.
|
||||
*/
|
||||
function keepTapInside(event: React.MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
export function RangeBar({
|
||||
value,
|
||||
min,
|
||||
@@ -147,11 +155,7 @@ export function RangeBar({
|
||||
: t("targets.aboveTarget", { delta: fmt.number(delta, 1), unit })
|
||||
: t("targets.inTarget");
|
||||
|
||||
// The marker's value + range live only in a
|
||||
// hover-only Radix tooltip on a non-focusable `<div>`. Reusing the same
|
||||
// three strings the tooltip already shows as one text alternative closes
|
||||
// 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger
|
||||
// focusable, which is all Radix needs to also open the tooltip on focus.
|
||||
// The same three strings the popover shows, as the marker's name.
|
||||
const markerAriaLabel = [
|
||||
t("targets.currentValue", { value: show(value), unit }),
|
||||
t("targets.targetRangeValue", { min: show(min), max: show(max), unit }),
|
||||
@@ -209,13 +213,12 @@ export function RangeBar({
|
||||
width: `${greenEnd - greenStart}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current value marker with tooltip */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
{/* Current value marker; a tap shows its numbers. */}
|
||||
<span className="contents" onClick={keepTapInside}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={markerAriaLabel}
|
||||
className={`focus-visible:ring-ring absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full shadow-sm focus-visible:ring-2 focus-visible:outline-none ${
|
||||
compact ? "h-5 w-1 border" : "h-5 w-5 border-2"
|
||||
@@ -226,8 +229,8 @@ export function RangeBar({
|
||||
borderColor: markerColor,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-xs font-medium">
|
||||
{t("targets.currentValue", { value: show(value), unit })}
|
||||
</p>
|
||||
@@ -239,9 +242,9 @@ export function RangeBar({
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-medium">{deltaText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-muted-foreground relative ${
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
|
||||
@@ -185,9 +184,9 @@ export interface TargetStatusPillProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbal status pill with a tooltip carrying the target range + the
|
||||
* guideline source. The pill colour is derived from the classification
|
||||
* category's semantic group; unmapped categories paint amber.
|
||||
* Verbal status pill; a tap shows the target range + the guideline source.
|
||||
* The pill colour is derived from the classification category's semantic
|
||||
* group; unmapped categories paint amber.
|
||||
*/
|
||||
export function TargetStatusPill({
|
||||
classification,
|
||||
@@ -202,30 +201,28 @@ export function TargetStatusPill({
|
||||
const statusLabel = translateStatus(classification.category, t);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ScorePill
|
||||
data-slot="target-status-pill"
|
||||
data-status={statusGroup}
|
||||
className={statusPillStyle}
|
||||
>
|
||||
{statusLabel}
|
||||
</ScorePill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{range && (
|
||||
<p className="text-xs">
|
||||
{t("targets.targetRangeValue", {
|
||||
min: String(range.min),
|
||||
max: String(range.max),
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Popover>
|
||||
<PopoverTrigger className="focus-visible:ring-ring/50 inline-flex rounded-full focus-visible:ring-2 focus-visible:outline-none">
|
||||
<ScorePill
|
||||
data-slot="target-status-pill"
|
||||
data-status={statusGroup}
|
||||
className={statusPillStyle}
|
||||
>
|
||||
{statusLabel}
|
||||
</ScorePill>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
{range && (
|
||||
<p className="text-xs">
|
||||
{t("targets.targetRangeValue", {
|
||||
min: String(range.min),
|
||||
max: String(range.max),
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user