mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): auto-hide mobile Coach FAB while a chart tooltip is open
The Coach launch FAB sits fixed at bottom-right on mobile viewports and used to overlay any Recharts tooltip painted near the chart's lower-right corner. Tapping a data point on /insights/blutdruck opened the tooltip but the FAB covered the value readout, so the user couldn't read it. Add a singleton MutationObserver that watches every .recharts-tooltip-wrapper in the DOM for inline visibility flips (Recharts toggles `style.visibility: visible` when the tooltip should paint, and clears it otherwise). A useSyncExternalStore- backed hook surfaces the live boolean to the FAB, which fades to opacity-0 + pointer-events-none + aria-hidden while a tooltip is active. When the user releases / scrolls away and Recharts clears the wrapper the FAB fades back in. The observer is module-scoped: it attaches on the first subscriber and disconnects on the last unmount, so it costs nothing on routes that don't mount the FAB. SSR snapshot is always false so the initial server-rendered FAB stays fully interactive before hydration. End-to-end auto-hide behaviour belongs to the Playwright mobile suite (chart hover/long-press → FAB fades); the unit tests pin the SSR-safe contract and the FAB markup so the regression cannot silently revert.
This commit is contained in:
1 parent
e65cdc97b2
commit
a5a91e40bc
5 files changed
+409
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__resetChartTooltipObserverForTests,
|
||||
getChartTooltipActive,
|
||||
getChartTooltipActiveServer,
|
||||
subscribeChartTooltipActive,
|
||||
} from "../chart-tooltip-observer";
|
||||
|
||||
/**
|
||||
* v1.4.33 (F15) — chart-tooltip observer contract tests.
|
||||
*
|
||||
* The full DOM-side behaviour (MutationObserver watching `[class*=
|
||||
* "recharts-tooltip-wrapper"]` visibility flips) lives behind a guard
|
||||
* that no-ops when `document` is undefined — which is the case in the
|
||||
* default vitest node environment. These tests pin the SSR-safe path
|
||||
* and the subscribe/snapshot contract that `useSyncExternalStore`
|
||||
* leans on.
|
||||
*
|
||||
* The integration with a real Recharts tooltip is exercised via the
|
||||
* Playwright suite (mobile insight chart hover → FAB fades).
|
||||
*/
|
||||
|
||||
describe("chart-tooltip-observer", () => {
|
||||
beforeEach(() => {
|
||||
__resetChartTooltipObserverForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetChartTooltipObserverForTests();
|
||||
});
|
||||
|
||||
it("getChartTooltipActiveServer always returns false", () => {
|
||||
// The SSR snapshot must never claim a tooltip is active on the
|
||||
// server — there's no DOM to inspect, and a stale `true` would
|
||||
// render the FAB invisible on the initial pass.
|
||||
expect(getChartTooltipActiveServer()).toBe(false);
|
||||
});
|
||||
|
||||
it("getChartTooltipActive returns false when no wrappers exist", () => {
|
||||
expect(getChartTooltipActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("subscribeChartTooltipActive returns an unsubscribe function", () => {
|
||||
const unsubscribe = subscribeChartTooltipActive(() => undefined);
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("no-ops cleanly on a server-side render (no document available)", () => {
|
||||
// Document is undefined in the vitest node environment — the
|
||||
// module's `start()` early-returns. Subscribing should still
|
||||
// succeed; the listener simply never fires.
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeChartTooltipActive(() => {
|
||||
calls += 1;
|
||||
});
|
||||
expect(getChartTooltipActive()).toBe(false);
|
||||
expect(calls).toBe(0);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("supports multiple subscribers without throwing", () => {
|
||||
const a = subscribeChartTooltipActive(() => undefined);
|
||||
const b = subscribeChartTooltipActive(() => undefined);
|
||||
expect(getChartTooltipActive()).toBe(false);
|
||||
a();
|
||||
b();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
import { LayoutCoachFab } from "../layout-coach-fab";
|
||||
|
||||
/**
|
||||
* v1.4.33 (F15) — layout-level Coach FAB contract.
|
||||
*
|
||||
* The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid
|
||||
* Recharts tooltips when the user tapped a data point in the chart's
|
||||
* lower-right corner — the maintainer flagged this as F15 in the
|
||||
* runtime audit. The fix wires the FAB to `useChartTooltipActive()`
|
||||
* which reads a singleton MutationObserver of every
|
||||
* `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB
|
||||
* fades and drops `pointer-events`.
|
||||
*
|
||||
* Project convention is SSR-only tests (no `@testing-library/react`),
|
||||
* so we pin the initial-paint state — no tooltip can be active before
|
||||
* hydration, so the FAB renders without the active-flag attribute
|
||||
* and stays interactive on first paint. The end-to-end auto-hide
|
||||
* behaviour is exercised in the Playwright mobile suite.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LayoutCoachFab>", () => {
|
||||
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
|
||||
// The hook returns `null` outside the provider; the FAB then
|
||||
// renders nothing rather than crashing — same posture as
|
||||
// `<CoachLaunchButton>`.
|
||||
const html = render(<LayoutCoachFab />);
|
||||
expect(html).not.toContain('data-slot="coach-launch-fab"');
|
||||
});
|
||||
|
||||
it("mounts the FAB with the launch-fab slot under the provider", () => {
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="coach-launch-fab"/);
|
||||
});
|
||||
|
||||
it("renders without the chart-tooltip-active flag on first paint", () => {
|
||||
// SSR snapshot of `useChartTooltipActive()` is `false` so the
|
||||
// FAB stays interactive on initial render. The active classes
|
||||
// (opacity-0 + pointer-events-none) are applied client-side only
|
||||
// once a Recharts tooltip wrapper flips its inline visibility.
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The data attribute is `undefined` when inactive — React drops
|
||||
// it from the DOM so the rendered markup never carries it.
|
||||
expect(html).not.toContain('data-chart-tooltip-active="true"');
|
||||
// The shadcn `<Button>` primitive bakes in
|
||||
// `disabled:pointer-events-none` and `[&_svg]:pointer-events-none`,
|
||||
// so we look for the unprefixed token that the fade-out adds.
|
||||
// Match it as a class atom (preceded by space/start, followed by
|
||||
// space/end-of-class-list/quote) so the primitive's prefixed
|
||||
// variants can't false-match.
|
||||
expect(html).not.toMatch(/(?:^|["\s])pointer-events-none(?=["\s])/);
|
||||
expect(html).not.toContain("opacity-0");
|
||||
});
|
||||
|
||||
it("carries the transition class so the fade is animated", () => {
|
||||
// The fade-out + fade-in shouldn't pop visually — the FAB
|
||||
// animates opacity over 150ms.
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toContain("transition-opacity");
|
||||
expect(html).toContain("duration-150");
|
||||
});
|
||||
|
||||
it("hides the FAB above lg via `lg:hidden`", () => {
|
||||
// The desktop Coach surface is the inline pill; the FAB stays
|
||||
// mobile-only.
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toContain("lg:hidden");
|
||||
});
|
||||
|
||||
it("renders the localised label in German", () => {
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Coach fragen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* v1.4.33 (F15) — chart-tooltip active observer.
|
||||
*
|
||||
* The mobile Coach FAB at `bottom-right` of every routed `/insights`
|
||||
* sub-page used to overlay the Recharts tooltip bubble when a user
|
||||
* tapped a data point near the bottom-right of the chart. We can't
|
||||
* route a `tooltipActive` boolean directly from the chart to the FAB
|
||||
* because the chart sits inside the routed page subtree while the FAB
|
||||
* is mounted at the layout level — and the chart component itself is
|
||||
* out of this work-item's touch surface.
|
||||
*
|
||||
* Instead, listen at the DOM level. Recharts paints every active
|
||||
* tooltip into a div whose class always carries `recharts-tooltip-
|
||||
* wrapper`. The `<TooltipBoundingBox>` upstream component flips the
|
||||
* inline `visibility` from `hidden` (idle) to `visible` (cursor over
|
||||
* a point or long-press on touch). A single shared `MutationObserver`
|
||||
* watches every such wrapper for `style`-attribute changes and feeds
|
||||
* a tiny external store; consumers subscribe via `useSyncExternalStore`
|
||||
* to avoid prop-drilling or react-context coupling between unrelated
|
||||
* subtrees.
|
||||
*
|
||||
* The store is module-scoped so the listener stays a singleton — the
|
||||
* observer connects on the first subscriber and disconnects on the
|
||||
* last unmount. SSR-safe: every browser API touch is gated on
|
||||
* `typeof document`.
|
||||
*/
|
||||
|
||||
let activeCount = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let listening = false;
|
||||
|
||||
// Track wrappers we've already seen so we don't double-count their
|
||||
// visibility transitions. A `WeakSet` lets the GC reclaim removed
|
||||
// wrapper elements naturally.
|
||||
const knownWrappers = new WeakSet<HTMLElement>();
|
||||
|
||||
function isVisible(wrapper: HTMLElement): boolean {
|
||||
// Recharts surfaces `visibility: visible` via inline style when the
|
||||
// tooltip should paint, and clears it otherwise. We mirror that exact
|
||||
// contract — `getComputedStyle()` is not required because the inline
|
||||
// style is always set.
|
||||
return wrapper.style.visibility === "visible";
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function syncWrapperState(wrapper: HTMLElement, nextVisible: boolean) {
|
||||
const flagged = wrapper.dataset.coachTooltipActive === "1";
|
||||
if (nextVisible && !flagged) {
|
||||
wrapper.dataset.coachTooltipActive = "1";
|
||||
activeCount += 1;
|
||||
emit();
|
||||
} else if (!nextVisible && flagged) {
|
||||
delete wrapper.dataset.coachTooltipActive;
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
function adoptWrapper(wrapper: HTMLElement) {
|
||||
if (knownWrappers.has(wrapper)) return;
|
||||
knownWrappers.add(wrapper);
|
||||
// Seed the active-count with whatever state the wrapper carried on
|
||||
// first sight so a tooltip already painted before our observer
|
||||
// attached still counts.
|
||||
syncWrapperState(wrapper, isVisible(wrapper));
|
||||
}
|
||||
|
||||
function scanForWrappers(root: ParentNode) {
|
||||
// `querySelectorAll` returns every existing wrapper inside the
|
||||
// mutated subtree on every mutation — cheap because the DOM
|
||||
// typically holds 0–1 wrappers at a time.
|
||||
const wrappers = root.querySelectorAll<HTMLElement>(
|
||||
'[class*="recharts-tooltip-wrapper"]',
|
||||
);
|
||||
for (const wrapper of wrappers) adoptWrapper(wrapper);
|
||||
}
|
||||
|
||||
function handleMutations(mutations: MutationRecord[]) {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (node.matches?.('[class*="recharts-tooltip-wrapper"]')) {
|
||||
adoptWrapper(node);
|
||||
} else {
|
||||
scanForWrappers(node);
|
||||
}
|
||||
}
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
// Removed wrappers cannot contribute to active count anymore;
|
||||
// drop the flag so a re-added wrapper re-counts cleanly.
|
||||
if (node.dataset?.coachTooltipActive === "1") {
|
||||
delete node.dataset.coachTooltipActive;
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
emit();
|
||||
}
|
||||
}
|
||||
} else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) {
|
||||
const target = mutation.target;
|
||||
if (target.matches('[class*="recharts-tooltip-wrapper"]')) {
|
||||
syncWrapperState(target, isVisible(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (listening) return;
|
||||
if (typeof document === "undefined") return;
|
||||
listening = true;
|
||||
observer = new MutationObserver(handleMutations);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
// Seed with wrappers that already exist in the DOM at subscribe-time.
|
||||
scanForWrappers(document.body);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!listening) return;
|
||||
listening = false;
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
// Reset the counter — re-seeding on the next subscriber re-scans
|
||||
// the DOM anyway.
|
||||
activeCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe-API used by `useSyncExternalStore`. Connects the singleton
|
||||
* observer on the first subscriber and disconnects on the last.
|
||||
*/
|
||||
export function subscribeChartTooltipActive(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) start();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) stop();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot getter for `useSyncExternalStore`. Returns `true` while at
|
||||
* least one Recharts tooltip wrapper is currently visible.
|
||||
*/
|
||||
export function getChartTooltipActive(): boolean {
|
||||
return activeCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR snapshot — no chart tooltip can be active on the server, so we
|
||||
* always return `false`. Keeps the hook safe on the initial render
|
||||
* before hydration.
|
||||
*/
|
||||
export function getChartTooltipActiveServer(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only reset hook. Vitest tears the DOM down between tests but
|
||||
* the module-scoped counter survives the teardown; exposing a reset
|
||||
* keeps cross-test state from bleeding.
|
||||
*/
|
||||
export function __resetChartTooltipObserverForTests(): void {
|
||||
activeCount = 0;
|
||||
listeners.clear();
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
listening = false;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
|
||||
import { useChartTooltipActive } from "./use-chart-tooltip-active";
|
||||
|
||||
/**
|
||||
* Layout-level mobile Coach launch FAB.
|
||||
*
|
||||
@@ -22,11 +24,20 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
* Hidden on `lg+` viewports — desktop users reach the Coach via the
|
||||
* inline `<CoachLaunchButton>` pill that each page mounts in its
|
||||
* action row.
|
||||
*
|
||||
* v1.4.33 (F15) — the FAB sits at `bottom-right` and used to overlay
|
||||
* Recharts tooltips on the bottom-right of any insight chart, so a
|
||||
* mobile user tapping a data point near the chart's lower-right
|
||||
* couldn't read the bubble. The button now auto-hides while a chart
|
||||
* tooltip is active (the wrapper's `visibility: visible` flips us
|
||||
* to fade-out + pointer-events-none). When the user releases / scrolls
|
||||
* away the tooltip clears and the FAB fades back in.
|
||||
*/
|
||||
export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const tooltipActive = useChartTooltipActive();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide. The
|
||||
// FAB returns null when the master OR the coach sub-flag is off.
|
||||
@@ -39,14 +50,28 @@ export function LayoutCoachFab() {
|
||||
type="button"
|
||||
size="lg"
|
||||
data-slot="coach-launch-fab"
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={() => launch.askCoach(null)}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
// v1.4.33 (F15) — `aria-hidden` + `tabIndex={-1}` while a chart
|
||||
// tooltip is painted so screen-reader users + keyboard users
|
||||
// don't trip over a button that's visually faded; pointer-events
|
||||
// are gone, so a tap can't land on the invisible FAB either.
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
// v1.4.33 (F15) — fade out while a Recharts tooltip is open.
|
||||
// 150 ms transition matches the rest of the Coach drawer's
|
||||
// fade timing; `motion-reduce:transition-none` honours user
|
||||
// preference. `pointer-events-none` keeps the faded FAB from
|
||||
// intercepting taps that should reach the tooltip behind.
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import {
|
||||
getChartTooltipActive,
|
||||
getChartTooltipActiveServer,
|
||||
subscribeChartTooltipActive,
|
||||
} from "./chart-tooltip-observer";
|
||||
|
||||
/**
|
||||
* v1.4.33 (F15) — read the live "is a Recharts tooltip painted right
|
||||
* now" boolean. Backed by a singleton `MutationObserver` that watches
|
||||
* every `.recharts-tooltip-wrapper` on the page for inline-style
|
||||
* `visibility` changes; see `chart-tooltip-observer.ts` for the
|
||||
* full contract.
|
||||
*
|
||||
* Returns `false` on the server so the FAB renders fully on the SSR
|
||||
* pass; the client takes over after hydration.
|
||||
*/
|
||||
export function useChartTooltipActive(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeChartTooltipActive,
|
||||
getChartTooltipActive,
|
||||
getChartTooltipActiveServer,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user