From 5999fdf35244fb426af47ef49cf7862f12e4c5dd Mon Sep 17 00:00:00 2001 From: ahamSel <77988808+ahamSel@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:44:00 -0230 Subject: [PATCH 1/3] fix(activity): keep latest tooltips within viewport (@ahamSel) --- .../__tests__/elements/test-activity.spec.ts | 52 +++++++++++++++++++ frontend/src/ts/elements/test-activity.ts | 10 +++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 frontend/__tests__/elements/test-activity.spec.ts diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts new file mode 100644 index 000000000000..58c353523e2c --- /dev/null +++ b/frontend/__tests__/elements/test-activity.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { update } from "../../src/ts/elements/test-activity"; +import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar"; + +describe("test-activity.ts", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-06T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it("aligns tooltips in the last week away from the right edge", () => { + const element = document.createElement("div"); + element.className = "testActivity"; + element.innerHTML = ` +
+
+
+
+ `; + document.body.append(element); + + const calendar = new TestActivityCalendar( + [], + new Date("2026-08-06T12:00:00Z"), + 0, + ); + + update(element, calendar); + + const days = Array.from( + element.querySelectorAll(".activity > div"), + ); + const previousWeeks = days + .slice(0, -7) + .filter((day) => day.hasAttribute("aria-label")); + const lastWeek = days + .slice(-7) + .filter((day) => day.hasAttribute("aria-label")); + + expect(previousWeeks.at(-1)).toHaveAttribute("data-balloon-pos", "up"); + expect(lastWeek).not.toHaveLength(0); + for (const day of lastWeek) { + expect(day).toHaveAttribute("data-balloon-pos", "up-right"); + } + }); +}); diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts index 7d2bae4b95de..112f2f014607 100644 --- a/frontend/src/ts/elements/test-activity.ts +++ b/frontend/src/ts/elements/test-activity.ts @@ -51,12 +51,18 @@ export function update( } } - for (const day of calendar.getDays()) { + const days = calendar.getDays(); + const lastWeekStart = days.length - 7; + + for (const [index, day] of days.entries()) { const elem = document.createElement("div"); elem.setAttribute("data-level", day.level); if (day.label !== undefined) { elem.setAttribute("aria-label", day.label); - elem.setAttribute("data-balloon-pos", "up"); + elem.setAttribute( + "data-balloon-pos", + index >= lastWeekStart ? "up-right" : "up", + ); } container.appendChild(elem); } From d9cc0188961d4c22ef95c5a2bb43b4dd1cc7754f Mon Sep 17 00:00:00 2001 From: ahamSel <77988808+ahamSel@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:28:18 -0230 Subject: [PATCH 2/3] fix(activity): keep edge tooltips within viewport (@ahamSel) --- .../__tests__/elements/test-activity.spec.ts | 59 +++++++++++--- frontend/src/styles/test-activity.scss | 23 ++++++ frontend/src/ts/elements/test-activity.ts | 79 +++++++++++++++++-- 3 files changed, 140 insertions(+), 21 deletions(-) diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts index 58c353523e2c..335f1a78ec25 100644 --- a/frontend/__tests__/elements/test-activity.spec.ts +++ b/frontend/__tests__/elements/test-activity.spec.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { update } from "../../src/ts/elements/test-activity"; +import { + getTooltipHorizontalOffset, + update, +} from "../../src/ts/elements/test-activity"; import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar"; describe("test-activity.ts", () => { @@ -14,7 +17,7 @@ describe("test-activity.ts", () => { document.body.replaceChildren(); }); - it("aligns tooltips in the last week away from the right edge", () => { + it("keeps tooltip arrows centered above every activity day", () => { const element = document.createElement("div"); element.className = "testActivity"; element.innerHTML = ` @@ -36,17 +39,47 @@ describe("test-activity.ts", () => { const days = Array.from( element.querySelectorAll(".activity > div"), ); - const previousWeeks = days - .slice(0, -7) - .filter((day) => day.hasAttribute("aria-label")); - const lastWeek = days - .slice(-7) - .filter((day) => day.hasAttribute("aria-label")); - - expect(previousWeeks.at(-1)).toHaveAttribute("data-balloon-pos", "up"); - expect(lastWeek).not.toHaveLength(0); - for (const day of lastWeek) { - expect(day).toHaveAttribute("data-balloon-pos", "up-right"); + const labelledDays = days.filter((day) => day.hasAttribute("aria-label")); + + expect(labelledDays).not.toHaveLength(0); + for (const day of labelledDays) { + expect(day).toHaveAttribute("data-balloon-pos", "up"); } }); + + it.each([ + { + description: "away from either edge", + triggerLeft: 140, + triggerWidth: 10, + tooltipWidth: 100, + viewportWidth: 300, + expected: 0, + }, + { + description: "at the left edge", + triggerLeft: 2, + triggerWidth: 10, + tooltipWidth: 100, + viewportWidth: 300, + expected: 51, + }, + { + description: "at the right edge", + triggerLeft: 288, + triggerWidth: 10, + tooltipWidth: 100, + viewportWidth: 300, + expected: -51, + }, + ])("offsets the tooltip box $description", (testCase) => { + expect( + getTooltipHorizontalOffset( + testCase.triggerLeft, + testCase.triggerWidth, + testCase.tooltipWidth, + testCase.viewportWidth, + ), + ).toBe(testCase.expected); + }); }); diff --git a/frontend/src/styles/test-activity.scss b/frontend/src/styles/test-activity.scss index 84d852c3b9c6..2bf70c4d44e6 100644 --- a/frontend/src/styles/test-activity.scss +++ b/frontend/src/styles/test-activity.scss @@ -115,6 +115,8 @@ } .activity { + --activity-tooltip-offset: 0px; + grid-area: chart; display: grid; grid-auto-flow: column; @@ -131,6 +133,27 @@ &[data-level="filler"]:hover { border: none; } + + &[aria-label][data-balloon-pos="up"]::after { + font-size: clamp(0.75rem, calc(0.55rem + 1vw), 1rem); + line-height: 1.2; + max-width: calc(100vw - 1rem); + text-align: center; + transform: translate( + calc(-50% + var(--activity-tooltip-offset)), + var(--balloon-move) + ); + white-space: normal; + width: max-content; + } + + &[aria-label][data-balloon-pos="up"]:hover::after, + &[aria-label][data-balloon-pos="up"][data-balloon-visible]::after, + &[aria-label][data-balloon-pos="up"]:not( + [data-balloon-nofocus] + ):focus::after { + transform: translate(calc(-50% + var(--activity-tooltip-offset)), 0); + } } } .legend { diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts index 112f2f014607..29733fbea296 100644 --- a/frontend/src/ts/elements/test-activity.ts +++ b/frontend/src/ts/elements/test-activity.ts @@ -3,6 +3,74 @@ import { TestActivityMonth, } from "./test-activity-calendar"; +const tooltipViewportPadding = 8; + +export function getTooltipHorizontalOffset( + triggerLeft: number, + triggerWidth: number, + tooltipWidth: number, + viewportWidth: number, +): number { + const availableWidth = Math.max( + 0, + viewportWidth - tooltipViewportPadding * 2, + ); + const renderedTooltipWidth = Math.min(tooltipWidth, availableWidth); + const triggerCenter = triggerLeft + triggerWidth / 2; + const tooltipLeft = triggerCenter - renderedTooltipWidth / 2; + const tooltipRight = triggerCenter + renderedTooltipWidth / 2; + + if (tooltipLeft < tooltipViewportPadding) { + return tooltipViewportPadding - tooltipLeft; + } + + if (tooltipRight > viewportWidth - tooltipViewportPadding) { + return viewportWidth - tooltipViewportPadding - tooltipRight; + } + + return 0; +} + +function measureTooltipWidth(element: HTMLElement): number { + const label = element.getAttribute("aria-label"); + if (label === null) { + return 0; + } + + const tooltipStyle = window.getComputedStyle(element, "::after"); + const measure = document.createElement("span"); + measure.textContent = label; + Object.assign(measure.style, { + fontFamily: tooltipStyle.fontFamily, + fontSize: tooltipStyle.fontSize, + fontStyle: tooltipStyle.fontStyle, + fontWeight: tooltipStyle.fontWeight, + letterSpacing: tooltipStyle.letterSpacing, + paddingLeft: tooltipStyle.paddingLeft, + paddingRight: tooltipStyle.paddingRight, + position: "fixed", + visibility: "hidden", + whiteSpace: "nowrap", + }); + document.body.appendChild(measure); + const width = measure.getBoundingClientRect().width; + measure.remove(); + + return width; +} + +function positionTooltip(element: HTMLElement): void { + const triggerRect = element.getBoundingClientRect(); + const offset = getTooltipHorizontalOffset( + triggerRect.left, + triggerRect.width, + measureTooltipWidth(element), + document.documentElement.clientWidth, + ); + + element.style.setProperty("--activity-tooltip-offset", `${offset}px`); +} + export function init( element: HTMLElement, calendar?: TestActivityCalendar, @@ -51,18 +119,13 @@ export function update( } } - const days = calendar.getDays(); - const lastWeekStart = days.length - 7; - - for (const [index, day] of days.entries()) { + for (const day of calendar.getDays()) { const elem = document.createElement("div"); elem.setAttribute("data-level", day.level); if (day.label !== undefined) { elem.setAttribute("aria-label", day.label); - elem.setAttribute( - "data-balloon-pos", - index >= lastWeekStart ? "up-right" : "up", - ); + elem.setAttribute("data-balloon-pos", "up"); + elem.addEventListener("pointerenter", () => positionTooltip(elem)); } container.appendChild(elem); } From 37ebbbf2c39dc6c2dfe4a337a44ab4e4b0d83b76 Mon Sep 17 00:00:00 2001 From: ahamSel <77988808+ahamSel@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:35:46 -0230 Subject: [PATCH 3/3] chore(activity): remove non-behavioral tooltip test (@ahamSel) --- .../__tests__/elements/test-activity.spec.ts | 85 ------------------- frontend/src/ts/elements/test-activity.ts | 2 +- 2 files changed, 1 insertion(+), 86 deletions(-) delete mode 100644 frontend/__tests__/elements/test-activity.spec.ts diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts deleted file mode 100644 index 335f1a78ec25..000000000000 --- a/frontend/__tests__/elements/test-activity.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - getTooltipHorizontalOffset, - update, -} from "../../src/ts/elements/test-activity"; -import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar"; - -describe("test-activity.ts", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-06T12:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - document.body.replaceChildren(); - }); - - it("keeps tooltip arrows centered above every activity day", () => { - const element = document.createElement("div"); - element.className = "testActivity"; - element.innerHTML = ` -
-
-
-
- `; - document.body.append(element); - - const calendar = new TestActivityCalendar( - [], - new Date("2026-08-06T12:00:00Z"), - 0, - ); - - update(element, calendar); - - const days = Array.from( - element.querySelectorAll(".activity > div"), - ); - const labelledDays = days.filter((day) => day.hasAttribute("aria-label")); - - expect(labelledDays).not.toHaveLength(0); - for (const day of labelledDays) { - expect(day).toHaveAttribute("data-balloon-pos", "up"); - } - }); - - it.each([ - { - description: "away from either edge", - triggerLeft: 140, - triggerWidth: 10, - tooltipWidth: 100, - viewportWidth: 300, - expected: 0, - }, - { - description: "at the left edge", - triggerLeft: 2, - triggerWidth: 10, - tooltipWidth: 100, - viewportWidth: 300, - expected: 51, - }, - { - description: "at the right edge", - triggerLeft: 288, - triggerWidth: 10, - tooltipWidth: 100, - viewportWidth: 300, - expected: -51, - }, - ])("offsets the tooltip box $description", (testCase) => { - expect( - getTooltipHorizontalOffset( - testCase.triggerLeft, - testCase.triggerWidth, - testCase.tooltipWidth, - testCase.viewportWidth, - ), - ).toBe(testCase.expected); - }); -}); diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts index 29733fbea296..f3e52eaf69c5 100644 --- a/frontend/src/ts/elements/test-activity.ts +++ b/frontend/src/ts/elements/test-activity.ts @@ -5,7 +5,7 @@ import { const tooltipViewportPadding = 8; -export function getTooltipHorizontalOffset( +function getTooltipHorizontalOffset( triggerLeft: number, triggerWidth: number, tooltipWidth: number,