mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix: keep the two-step on chart points on touch
A point and a calendar cell are small targets; one tap that opened the day opened days by accident. On a touch screen the first tap shows the value again and the tooltip's calm 44 px row opens the day, on every day-linked chart and calendar alike. A date in a list still opens in one tap; a click on a fine pointer still opens directly. The tooltip keeps not gliding into place.
This commit is contained in:
1 parent
fee6088e08
commit
8bcb3e6301
25 files changed
+213
-79
No files matched your search
+20
-6
@@ -13,11 +13,13 @@ import { expect, test } from "./setup/test";
|
||||
*
|
||||
* The day view first reached six metric pages; the recovery page beside them
|
||||
* offered no day at all. These journeys walk the surfaces that came after,
|
||||
* each through the same door: a click on a point (or a calendar cell) opens
|
||||
* the day beside the page, and on a touch screen a tap does the same as a
|
||||
* bottom sheet, the tapped value on top. The dashed line (or outline) marks
|
||||
* the open day, the row of dots and the caption sit under every chart, and
|
||||
* the data table's dates are the keyboard way to the same days.
|
||||
* each through the same doors: on a fine pointer a click on a point (or a
|
||||
* calendar cell) opens the day beside the page; on a touch screen the first
|
||||
* tap shows the value and the tooltip's "View the whole day" opens it as a
|
||||
* bottom sheet, the tapped value on top. A date in a list opens in one tap.
|
||||
* The dashed line (or outline) marks the open day, the row of dots and the
|
||||
* caption sit under every chart, and the data table's dates are the
|
||||
* keyboard way to the same days.
|
||||
*
|
||||
* - recovery (the page the beta reported), sleep, a lab marker, a chart
|
||||
* with its comparison period on and a dashboard chart: line charts;
|
||||
@@ -41,8 +43,14 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
|
||||
const x = box.x + box.width * 0.8;
|
||||
const y = box.y + box.height / 2;
|
||||
if (isPhone(testInfo)) {
|
||||
// One tap, the same door as the click: no tooltip step in between.
|
||||
await page.touchscreen.tap(x, y);
|
||||
const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible');
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet: a point is a small target.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
// The button is a calm, full 44 px row.
|
||||
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
await page.mouse.move(x, y);
|
||||
@@ -143,6 +151,12 @@ test.describe("every chart drawn in days opens its days", () => {
|
||||
const day = await cell.getAttribute("data-day");
|
||||
if (isPhone(testInfo)) {
|
||||
await cell.tap();
|
||||
const open = page.locator(
|
||||
'[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]',
|
||||
);
|
||||
await expect(open).toBeVisible();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
await cell.click();
|
||||
|
||||
+10
-7
@@ -15,9 +15,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* day, what the history does, and where no door is.
|
||||
*
|
||||
* - a chart point opens the day beside the chart (desktop);
|
||||
* - on a touch screen a tap on a point opens the day at once, as a bottom
|
||||
* sheet with the tapped value on top (the maintainer's call on the beta:
|
||||
* the two-step tooltip broke the flow);
|
||||
* - on a touch screen the first tap shows the value and the tooltip offers
|
||||
* the day, the second opens it as a bottom sheet;
|
||||
* - a date in a list opens the day while the row keeps its own target;
|
||||
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
|
||||
* the future is dropped;
|
||||
@@ -25,7 +24,7 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* - the dashboard's today area offers no door; its charts below do.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
* `chart-plot[data-day-links]`, `day-prev` /
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`.
|
||||
*/
|
||||
|
||||
@@ -151,7 +150,7 @@ test.describe("the day view", () => {
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("on a phone a tap on a point opens its day at once", async ({
|
||||
test("on a phone the first tap shows the value, the second opens the day", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
|
||||
@@ -162,11 +161,15 @@ test.describe("the day view", () => {
|
||||
await plot.scrollIntoViewIfNeeded();
|
||||
await expect(plot).toBeVisible();
|
||||
const box = (await plot.boundingBox())!;
|
||||
// One tap, the same door as a click on a desktop: the day opens as a
|
||||
// bottom sheet with the tapped value on top.
|
||||
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
// The tapped value heads the day.
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Erfassen",
|
||||
"inTimelineShort": "Zeitleiste",
|
||||
"askCoachPrompt": "Was war am {date}?",
|
||||
"chartHintTouch": "Antippen eines Punkts öffnet den Tag.",
|
||||
"chartHintTouch": "Antippen zeigt den Wert.",
|
||||
"chartHintBar": "Klick auf einen Balken öffnet den Tag.",
|
||||
"chartHintTouchBar": "Antippen eines Balkens öffnet den Tag.",
|
||||
"chartHintTouchBar": "Antippen zeigt die Werte.",
|
||||
"chartHintCell": "Klick auf einen Tag öffnet ihn.",
|
||||
"chartHintTouchCell": "Antippen eines Tages öffnet ihn.",
|
||||
"chartHintTouchCell": "Antippen zeigt den Wert des Tages.",
|
||||
"coachChips": "Tage in dieser Antwort",
|
||||
"description": "Alles, was deine Einträge für diesen Tag enthalten.",
|
||||
"openDocument": "Dokument öffnen",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Log",
|
||||
"inTimelineShort": "Timeline",
|
||||
"askCoachPrompt": "What happened on {date}?",
|
||||
"chartHintTouch": "Tap a point to open its day.",
|
||||
"chartHintTouch": "Tap a point to see its value.",
|
||||
"chartHintBar": "Click a bar to open its day.",
|
||||
"chartHintTouchBar": "Tap a bar to open its day.",
|
||||
"chartHintTouchBar": "Tap a bar to see its values.",
|
||||
"chartHintCell": "Click a day to open it.",
|
||||
"chartHintTouchCell": "Tap a day to open it.",
|
||||
"chartHintTouchCell": "Tap a day to see its value.",
|
||||
"coachChips": "Days in this answer",
|
||||
"description": "Everything your records hold for this day.",
|
||||
"openDocument": "Open document",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Registrar",
|
||||
"inTimelineShort": "Cronología",
|
||||
"askCoachPrompt": "¿Qué pasó el {date}?",
|
||||
"chartHintTouch": "Toca un punto para abrir su día.",
|
||||
"chartHintTouch": "Toca un punto para ver su valor.",
|
||||
"chartHintBar": "Haz clic en una barra para abrir su día.",
|
||||
"chartHintTouchBar": "Toca una barra para abrir su día.",
|
||||
"chartHintTouchBar": "Toca una barra para ver sus valores.",
|
||||
"chartHintCell": "Haz clic en un día para abrirlo.",
|
||||
"chartHintTouchCell": "Toca un día para abrirlo.",
|
||||
"chartHintTouchCell": "Toca un día para ver su valor.",
|
||||
"coachChips": "Días en esta respuesta",
|
||||
"description": "Todo lo que tus registros contienen para este día.",
|
||||
"openDocument": "Abrir documento",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Enregistrer",
|
||||
"inTimelineShort": "Chronologie",
|
||||
"askCoachPrompt": "Que s’est-il passé le {date} ?",
|
||||
"chartHintTouch": "Touchez un point pour ouvrir son jour.",
|
||||
"chartHintTouch": "Touchez un point pour voir sa valeur.",
|
||||
"chartHintBar": "Cliquez sur une barre pour ouvrir son jour.",
|
||||
"chartHintTouchBar": "Touchez une barre pour ouvrir son jour.",
|
||||
"chartHintTouchBar": "Touchez une barre pour voir ses valeurs.",
|
||||
"chartHintCell": "Cliquez sur un jour pour l’ouvrir.",
|
||||
"chartHintTouchCell": "Touchez un jour pour l’ouvrir.",
|
||||
"chartHintTouchCell": "Touchez un jour pour voir sa valeur.",
|
||||
"coachChips": "Jours dans cette réponse",
|
||||
"description": "Tout ce que vos entrées contiennent pour ce jour.",
|
||||
"openDocument": "Ouvrir le document",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Registra",
|
||||
"inTimelineShort": "Cronologia",
|
||||
"askCoachPrompt": "Cosa è successo il {date}?",
|
||||
"chartHintTouch": "Tocca un punto per aprire il suo giorno.",
|
||||
"chartHintTouch": "Tocca un punto per vederne il valore.",
|
||||
"chartHintBar": "Fai clic su una barra per aprire il suo giorno.",
|
||||
"chartHintTouchBar": "Tocca una barra per aprire il suo giorno.",
|
||||
"chartHintTouchBar": "Tocca una barra per vederne i valori.",
|
||||
"chartHintCell": "Fai clic su un giorno per aprirlo.",
|
||||
"chartHintTouchCell": "Tocca un giorno per aprirlo.",
|
||||
"chartHintTouchCell": "Tocca un giorno per vederne il valore.",
|
||||
"coachChips": "Giorni in questa risposta",
|
||||
"description": "Tutto ciò che le tue voci contengono per questo giorno.",
|
||||
"openDocument": "Apri documento",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "기록",
|
||||
"inTimelineShort": "타임라인",
|
||||
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
|
||||
"chartHintTouch": "점을 탭하면 그날이 열려요.",
|
||||
"chartHintTouch": "점을 탭하면 값이 보여요.",
|
||||
"chartHintBar": "막대를 클릭하면 그날이 열려요.",
|
||||
"chartHintTouchBar": "막대를 탭하면 그날이 열려요.",
|
||||
"chartHintTouchBar": "막대를 탭하면 값이 보여요.",
|
||||
"chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.",
|
||||
"chartHintTouchCell": "날짜 칸을 탭하면 그날이 열려요.",
|
||||
"chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.",
|
||||
"coachChips": "이 답변에 쓰인 날",
|
||||
"description": "이 날에 대한 모든 기록이에요.",
|
||||
"openDocument": "문서 열기",
|
||||
|
||||
+3
-3
@@ -12201,11 +12201,11 @@
|
||||
"captureShort": "Zapisz",
|
||||
"inTimelineShort": "Oś czasu",
|
||||
"askCoachPrompt": "Co się działo {date}?",
|
||||
"chartHintTouch": "Dotknij punktu, aby otworzyć jego dzień.",
|
||||
"chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.",
|
||||
"chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.",
|
||||
"chartHintTouchBar": "Dotknij słupka, aby otworzyć jego dzień.",
|
||||
"chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.",
|
||||
"chartHintCell": "Kliknij dzień, aby go otworzyć.",
|
||||
"chartHintTouchCell": "Dotknij dnia, aby go otworzyć.",
|
||||
"chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.",
|
||||
"coachChips": "Dni w tej odpowiedzi",
|
||||
"description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
|
||||
"openDocument": "Otwórz dokument",
|
||||
|
||||
@@ -2404,6 +2404,11 @@ export function HealthChart({
|
||||
: dateLabel
|
||||
}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint
|
||||
? dayLinkSource?.indexOf(hoverPoint)
|
||||
: undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -644,8 +644,18 @@ export function MedicationComplianceChart({
|
||||
delta,
|
||||
},
|
||||
];
|
||||
const hoverPoint = payload[0]?.payload;
|
||||
return (
|
||||
<RichChartTooltip active label={dateLabel} rows={rows} />
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint
|
||||
? chartData.indexOf(hoverPoint)
|
||||
: undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1015,7 +1015,16 @@ export function MoodChart({
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return <RichChartTooltip active label={dateLabel} rows={rows} />;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
|
||||
@@ -155,6 +155,7 @@ export function NutrientDailyBarChart({
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -239,6 +239,7 @@ export function CustomMetricChart({
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* it surfaces a "log your morning temperature" hint rather than a flat dot.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Thermometer } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -288,7 +288,20 @@ export function BbtChart({
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={<BbtTooltip />}
|
||||
content={(props) => {
|
||||
const payload = props.payload as unknown as
|
||||
{ payload: BbtPoint }[] | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
return (
|
||||
<BbtTooltip
|
||||
active={props.active}
|
||||
payload={payload}
|
||||
action={chartDays.tooltipAction(
|
||||
point ? points.indexOf(point) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{ovulationMs != null ? (
|
||||
<ReferenceLine
|
||||
@@ -374,6 +387,8 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
|
||||
export function BbtTooltip(props: {
|
||||
active?: boolean;
|
||||
payload?: { payload: BbtPoint }[];
|
||||
/** v1.42 — the tooltip's way to the day on a touch screen. */
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -407,6 +422,7 @@ export function BbtTooltip(props: {
|
||||
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{props.action ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { describe, expect, it } from "vitest";
|
||||
* The day view arrived on six metric pages and nowhere else, and the recovery
|
||||
* page beside them offered no day at all: nothing noticed, because every check
|
||||
* proved one chart. This guard walks `src/app` and `src/components` and holds
|
||||
* three kinds of surface to the same doors (a click or a tap opens the day,
|
||||
* the dashed line through the open day, the row of day dots, the caption):
|
||||
* three kinds of surface to the same doors (a click opens the day, on touch
|
||||
* the tooltip's "View the whole day", the dashed line through the open day,
|
||||
* the row of day dots, the caption):
|
||||
*
|
||||
* A. a mount of a chart whose doors are a prop (`HealthChart`, its dynamic
|
||||
* wrapper, `MoodChart`, the adherence chart, the nutrient bar chart)
|
||||
@@ -20,7 +21,7 @@ import { describe, expect, it } from "vitest";
|
||||
* B. a file that draws its own Recharts chart over a date axis (an
|
||||
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
|
||||
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
|
||||
* `OPEN_DAY_LINE`, the tooltip's props);
|
||||
* `OPEN_DAY_LINE`, the tooltip's props and its touch action);
|
||||
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
|
||||
* `useHeatmapDay`.
|
||||
*
|
||||
@@ -195,14 +196,15 @@ function findMissingDoors(): {
|
||||
/\buseChartDayLinks\(/.test(text) &&
|
||||
/\bChartDayFooter\b/.test(text) &&
|
||||
/\bOPEN_DAY_LINE\b/.test(text) &&
|
||||
/\.tooltipProps\b/.test(text);
|
||||
/\.tooltipProps\b/.test(text) &&
|
||||
/\.tooltipAction\(/.test(text);
|
||||
if (wired) {
|
||||
doors.charts += 1;
|
||||
doors.chartFiles.push(file);
|
||||
} else if (!listed) {
|
||||
missing.push({
|
||||
file,
|
||||
why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props)",
|
||||
why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props and touch action)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
@@ -8,6 +8,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
ChartDayCaption,
|
||||
DayRug,
|
||||
TooltipDayAction,
|
||||
useCoarsePointer,
|
||||
type ChartDayAxis,
|
||||
type ChartDayMark,
|
||||
@@ -25,11 +26,13 @@ import { useTodayKey } from "./use-today-key";
|
||||
* button, the dashed line through the open day and the row of dots under the
|
||||
* axis behave the same on every page:
|
||||
*
|
||||
* - a click, or a tap, on the plot opens the day of the point under it.
|
||||
* On a fine pointer hovering still shows the tooltip; on a touch screen
|
||||
* the tap opens the day at once, and the day puts the tapped value at
|
||||
* its top. A scroll that grazes the chart is a pan, not a tap, and opens
|
||||
* nothing;
|
||||
* - fine pointer: a click on the plot opens the day of the point under the
|
||||
* cursor; hovering shows the tooltip as before;
|
||||
* - touch: a tap shows the value and pins the tooltip, whose button
|
||||
* ("View the whole day", a calm 44 px row) opens the day. Two steps on
|
||||
* purpose (the maintainer's call, after trying one on the beta): a
|
||||
* point is a small target, and one tap that opened a sheet would open
|
||||
* days by accident;
|
||||
* - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line)
|
||||
* at `openIndex`;
|
||||
* - `ChartDayFooter` draws the row of day dots and the one-line caption.
|
||||
@@ -91,12 +94,19 @@ export interface ChartDayLinks {
|
||||
onChartClick:
|
||||
((state: ChartClickState | null | undefined) => void) | undefined;
|
||||
/**
|
||||
* Spread on `<Tooltip>`. On touch, and under reduced motion, the tooltip
|
||||
* does not glide: Recharts slides it from where it last was for 400 ms,
|
||||
* which on a tap drew a box sweeping across the chart while the day was
|
||||
* already opening.
|
||||
* Spread on `<Tooltip>`: on touch it stays where the tap put it and takes
|
||||
* taps. It does not glide there either: Recharts slides the tooltip from
|
||||
* where it last was for 400 ms, which turned "View the whole day" into a
|
||||
* moving target under the finger. Reduced motion stops the glide on every
|
||||
* pointer.
|
||||
*/
|
||||
tooltipProps: { isAnimationActive?: boolean };
|
||||
tooltipProps: {
|
||||
trigger: "click" | "hover";
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
isAnimationActive?: boolean;
|
||||
};
|
||||
/** The tooltip's button for point `index`, on a touch screen only. */
|
||||
tooltipAction: (index: number | undefined) => ReactNode | undefined;
|
||||
}
|
||||
|
||||
export function useChartDayLinks({
|
||||
@@ -134,16 +144,17 @@ export function useChartDayLinks({
|
||||
return at === -1 ? undefined : at;
|
||||
}, [enabled, openKey, days]);
|
||||
|
||||
const onChartClick = enabled
|
||||
? (state: ChartClickState | null | undefined) => {
|
||||
if (!state) return;
|
||||
const index = indexOfClick
|
||||
? indexOfClick(state)
|
||||
: Number(state.activeTooltipIndex);
|
||||
if (index === null || !Number.isInteger(index)) return;
|
||||
open(index);
|
||||
}
|
||||
: undefined;
|
||||
const onChartClick =
|
||||
enabled && !coarse
|
||||
? (state: ChartClickState | null | undefined) => {
|
||||
if (!state) return;
|
||||
const index = indexOfClick
|
||||
? indexOfClick(state)
|
||||
: Number(state.activeTooltipIndex);
|
||||
if (index === null || !Number.isInteger(index)) return;
|
||||
open(index);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
active: enabled,
|
||||
@@ -159,7 +170,19 @@ export function useChartDayLinks({
|
||||
plotClassName: enabled && !coarse ? "cursor-pointer" : "",
|
||||
onChartClick,
|
||||
tooltipProps:
|
||||
coarse || prefersReducedMotion() ? { isAnimationActive: false } : {},
|
||||
enabled && coarse
|
||||
? {
|
||||
trigger: "click",
|
||||
wrapperStyle: { pointerEvents: "auto", zIndex: 20 },
|
||||
isAnimationActive: false,
|
||||
}
|
||||
: prefersReducedMotion()
|
||||
? { trigger: "hover", isAnimationActive: false }
|
||||
: { trigger: "hover" },
|
||||
tooltipAction: (index) =>
|
||||
enabled && coarse && index !== undefined && canOpen(index) ? (
|
||||
<TooltipDayAction onOpen={() => open(index)} />
|
||||
) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
@@ -15,10 +16,12 @@ import { useTodayKey } from "./use-today-key";
|
||||
/**
|
||||
* The day view's entry points on a value chart.
|
||||
*
|
||||
* - A click, or a tap, on the plot opens the day of the point under it,
|
||||
* with that value at the top of the day. Hovering on a fine pointer
|
||||
* keeps showing the tooltip; a scroll that grazes the chart is a pan,
|
||||
* not a tap, and opens nothing.
|
||||
* - On a fine pointer, a click on the plot opens the day of the point under
|
||||
* the cursor; hovering keeps showing the tooltip as before.
|
||||
* - On a touch screen a tap shows the value, as it always did, and the
|
||||
* tooltip carries "View the whole day". Two steps on purpose: a point is
|
||||
* a small target, and one tap that opened a sheet would open days by
|
||||
* accident. A date in a list is a bigger target and opens in one tap.
|
||||
* - Under the axis, a quiet row of dots marks the days that hold anything,
|
||||
* a ring the days with a notable observation. Decoration for the eye; the
|
||||
* keyboard and screen-reader way to a day is the chart's data table.
|
||||
@@ -57,6 +60,26 @@ export function chartPointDayKey(timestamp: number): DateKey {
|
||||
return dateOnlyKey(new Date(timestamp));
|
||||
}
|
||||
|
||||
/** The tooltip's way to the day on a touch screen. */
|
||||
export function TooltipDayAction({ onOpen }: { onOpen: () => void }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="chart-tooltip-open-day"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-2.5 flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.viewWholeDay")}
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface RugPoint {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
|
||||
import { useCoarsePointer } from "./chart-day";
|
||||
import { TooltipDayAction, useCoarsePointer } from "./chart-day";
|
||||
import { openDay, useOpenDay } from "./day-layer-controller";
|
||||
import { isOpenableDay } from "./day-url";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
@@ -13,10 +13,11 @@ import { useTodayKey } from "./use-today-key";
|
||||
* The day view's doors on a calendar heatmap (mood, intake), the cell twin of
|
||||
* `useChartDayLinks`:
|
||||
*
|
||||
* - a click, or a tap, on a cell opens its day; hovering a cell on a fine
|
||||
* pointer shows its tooltip. A tap on a cell with nothing to open (no
|
||||
* entry, or a day still ahead) pins its tooltip instead, so the cell
|
||||
* still says what it is; a tap outside or Escape clears it;
|
||||
* - fine pointer: hovering a cell shows its tooltip, a click opens its day;
|
||||
* - touch: a tap pins the tooltip, and the tooltip's button ("View the
|
||||
* whole day", the same calm 44 px row every chart's tooltip carries)
|
||||
* opens it. Two steps, as on a chart: a cell is a small target. A tap
|
||||
* outside or Escape clears the tooltip;
|
||||
* - the open day's cell carries a dashed outline, the heatmap's form of
|
||||
* the dashed line a chart draws through it (`OPEN_DAY_CELL`);
|
||||
* - the keyboard and screen-reader way to a day is the heatmap's data
|
||||
@@ -39,6 +40,8 @@ interface HeatmapTooltip {
|
||||
text: string;
|
||||
/** A touch tooltip stays after the finger lifts. */
|
||||
pinned?: boolean;
|
||||
/** The day a pinned tooltip offers to open. */
|
||||
day?: DateKey;
|
||||
}
|
||||
|
||||
export interface HeatmapCellInput {
|
||||
@@ -51,6 +54,9 @@ export interface HeatmapCellInput {
|
||||
|
||||
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
|
||||
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(null);
|
||||
// The pointer type of the last press decides whether the click that
|
||||
// follows opens the day (mouse, pen) or only pinned the tooltip (touch).
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
const openKey = useOpenDay();
|
||||
const coarse = useCoarsePointer();
|
||||
@@ -104,17 +110,18 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
},
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
// A touch on a cell with a day to open opens it (the click below);
|
||||
// any other cell pins its tooltip so the tap still answers.
|
||||
if (e.pointerType !== "touch" || openable) return;
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cell.describe(),
|
||||
pinned: true,
|
||||
day: openable ? cell.dateKey : undefined,
|
||||
});
|
||||
},
|
||||
onClick: () => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (!openable) return;
|
||||
open(cell.dateKey);
|
||||
},
|
||||
@@ -129,7 +136,9 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
|
||||
const tooltipNode = tooltip ? (
|
||||
<div
|
||||
data-slot="heatmap-tooltip"
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
@@ -141,6 +150,9 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<TooltipDayAction onOpen={() => open(tooltip.day as DateKey)} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ export function MoodDimensionTrends({
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(String(row.date))}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(chartData.indexOf(row))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -469,6 +469,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
const row = payload[0]?.payload as
|
||||
Record<string, number | string> | undefined;
|
||||
const label = row?.label;
|
||||
const rowIndex = row ? data.indexOf(row) : -1;
|
||||
const napCount =
|
||||
typeof row?.napCount === "number" ? row.napCount : 0;
|
||||
// The footer's first line is the NIGHT. The nap is listed
|
||||
@@ -549,6 +550,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{chartDays.tooltipAction(
|
||||
rowIndex === -1 ? undefined : rowIndex,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -406,6 +406,7 @@ export function LabBiomarkerChart({
|
||||
active
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -378,11 +378,18 @@ export function EfficacyChart({
|
||||
color: item.color ?? "var(--chart-1)",
|
||||
};
|
||||
});
|
||||
const index =
|
||||
typeof x === "number"
|
||||
? displaySeries.findIndex((p) => p.t === x)
|
||||
: -1;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={typeof x === "number" ? fmt.date(new Date(x)) : ""}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
index === -1 ? undefined : index,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -326,6 +326,7 @@ export function DoseStrengthCurve({
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.t))}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(chartData.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -183,6 +183,7 @@ export function AssessmentHistoryChart({
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user