From 2a60f21a8b14c12cab9b54075289462649ef1407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:39:27 +0100 Subject: [PATCH] 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. --- .../baselines/source-function-lines.json | 2 +- .../__tests__/api-token-truncate.test.tsx | 14 +- .../admin/api-token-overview-section.tsx | 44 ++--- src/components/charts/trend-card.tsx | 184 ++++++++++-------- .../insights/derived/coverage-meter.tsx | 170 ++++++++-------- .../insights/measurement-diversity-nudge.tsx | 40 ++-- .../sleep-source-discrepancy-marker.tsx | 63 +++--- .../targets/__tests__/range-bar.test.tsx | 9 +- .../__tests__/target-status-pill.test.tsx | 7 +- src/components/targets/range-bar.tsx | 53 ++--- src/components/targets/target-status-pill.tsx | 63 +++--- 11 files changed, 323 insertions(+), 326 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 512931154..e033dd14a 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/components/admin/__tests__/api-token-truncate.test.tsx b/src/components/admin/__tests__/api-token-truncate.test.tsx index aab52dfa4..403d45c3b 100644 --- a/src/components/admin/__tests__/api-token-truncate.test.tsx +++ b/src/components/admin/__tests__/api-token-truncate.test.tsx @@ -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( + / - - -
-

{summaryLabel}

-

- {t("insights.derived.coverage.historyDays", { - count: clampInt(coverage.historyDays, 0, 99999), + {summaryLabel} + + + +

+

{summaryLabel}

+

+ {t("insights.derived.coverage.historyDays", { + count: clampInt(coverage.historyDays, 0, 99999), + })} +

+ {coverage.missing.length > 0 && ( +

+ {t("insights.derived.coverage.missing", { + list: coverage.missing.join(", "), })}

- {coverage.missing.length > 0 && ( -

- {t("insights.derived.coverage.missing", { - list: coverage.missing.join(", "), - })} -

- )} -
- - - + )} +
+ + ); } diff --git a/src/components/insights/measurement-diversity-nudge.tsx b/src/components/insights/measurement-diversity-nudge.tsx index ad4d1e775..8663287c4 100644 --- a/src/components/insights/measurement-diversity-nudge.tsx +++ b/src/components/insights/measurement-diversity-nudge.tsx @@ -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 ( - - - - - - - {message} - - - + ); } diff --git a/src/components/insights/sleep-source-discrepancy-marker.tsx b/src/components/insights/sleep-source-discrepancy-marker.tsx index 8c972f80f..bac82012a 100644 --- a/src/components/insights/sleep-source-discrepancy-marker.tsx +++ b/src/components/insights/sleep-source-discrepancy-marker.tsx @@ -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 = { 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 ( - - - - - - -

- {t("insights.sleep.sourceDiscrepancy.tooltipTitle")} -

-

+ // On the dashboard the marker sits in the tile's link: its tap opens the + // popover, not the tile. + { + event.preventDefault(); + event.stopPropagation(); + }} + > + + + {t("insights.sleep.sourceDiscrepancy.tooltipTitle")} + {discrepancy.sources .map( (b) => `${sourceDisplayName(b.source, b.deviceType, t)} ${formatDurationMinutes(b.asleepMinutes, t)}`, ) .join(" · ")} -

-
-
-
+ + } + label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")} + icon={TriangleAlert} + className="-mx-3" + align={align} + triggerDataSlot="sleep-source-discrepancy" + bodyDataSlot="sleep-source-discrepancy-body" + /> + ); } diff --git a/src/components/targets/__tests__/range-bar.test.tsx b/src/components/targets/__tests__/range-bar.test.tsx index bb44a7a84..e3edc111c 100644 --- a/src/components/targets/__tests__/range-bar.test.tsx +++ b/src/components/targets/__tests__/range-bar.test.tsx @@ -85,16 +85,17 @@ describe("", () => { 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( + /