-
Notifications
You must be signed in to change notification settings - Fork 9
feat(frontend): read a chart day over its bar, and expand the zone rail on hover #2441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dzarlax
merged 4 commits into
constructorfabric:main
from
dzarlax:feat/rail-and-chart-hover
Aug 11, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
988d735
fix(frontend): read a day out over its own bar, not in the caption below
f13359d
feat(frontend): the zone rail expands on hover, ported from the lite …
c70bacb
fix(frontend): open the rail on a timer, and stop the readout coverin…
89566eb
Merge remote-tracking branch 'upstream/main' into feat/rail-and-chart…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| // @vitest-environment jsdom | ||
| /** | ||
| * The rail's open state. | ||
| * | ||
| * Every case here is about the interaction rather than the look, because the | ||
| * look is the easy half. The one that matters is the click: a click navigates | ||
| * and leaves the pointer sitting on the rail, so without an explicit dismissal | ||
| * the rail reopens on top of the pane the click was aimed at. That failed | ||
| * silently once already when the state was expressed as CSS variants — the | ||
| * rules simply never matched and nothing said so. | ||
| */ | ||
| import { act, render, screen } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| layout: "wide" as "phone" | "narrow" | "wide", | ||
| selected: [] as string[], | ||
| })); | ||
|
|
||
| vi.mock("@/lib/portal/use-shell-layout", () => ({ | ||
| useShellLayout: () => mocks.layout, | ||
| })); | ||
| vi.mock("@/lib/portal/use-zone-nav", () => ({ | ||
| useZoneNav: () => ({ | ||
| zones: [ | ||
| { id: "overview", label: "Overview", icon: () => null }, | ||
| { id: "people", label: "People", icon: () => null }, | ||
| ], | ||
| activeZone: "overview", | ||
| selectZone: (z: { id: string }) => mocks.selected.push(z.id), | ||
| }), | ||
| })); | ||
| vi.mock("@/components/app-sidebar-footer", () => ({ | ||
| AppSidebarFooter: () => null, | ||
| })); | ||
|
|
||
| import { SidebarProvider } from "@/components/ui/sidebar"; | ||
| import { LensRail } from "./lens-rail"; | ||
|
|
||
| /** The rail opens on a timer, so a hover only counts once the wait is over. */ | ||
| const settle = () => act(() => { vi.advanceTimersByTime(400); }); | ||
|
|
||
| const rail = () => | ||
| render( | ||
| <SidebarProvider> | ||
| <LensRail /> | ||
| </SidebarProvider>, | ||
| ); | ||
|
|
||
| /** The label is present either way; what changes is whether it can be seen. */ | ||
| const labelOf = (name: string) => | ||
| screen.getByRole("button", { name }).querySelector("span:not(.sr-only)"); | ||
|
|
||
| beforeEach(() => { | ||
| vi.useFakeTimers({ shouldAdvanceTime: true }); | ||
| mocks.layout = "wide"; | ||
| mocks.selected = []; | ||
| window.matchMedia ??= ((query: string) => ({ | ||
| matches: false, | ||
| media: query, | ||
| onchange: null, | ||
| addEventListener: () => {}, | ||
| removeEventListener: () => {}, | ||
| addListener: () => {}, | ||
| removeListener: () => {}, | ||
| dispatchEvent: () => false, | ||
| })) as unknown as typeof window.matchMedia; | ||
| }); | ||
|
|
||
| describe("LensRail", () => { | ||
| it("shows labels while the pointer is on it", async () => { | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-0"); | ||
|
|
||
| await user.hover(screen.getByTestId("lens-rail")); | ||
| settle(); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-100"); | ||
| }); | ||
|
|
||
| it("collapses on a click and stays collapsed under the pointer", async () => { | ||
| // The whole reason this state exists. The click navigates; the pointer has | ||
| // not moved; reopening here would cover the pane that was just asked for. | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| await user.hover(screen.getByTestId("lens-rail")); | ||
| settle(); | ||
| expect(labelOf("People")).toHaveClass("opacity-100"); | ||
|
|
||
| await user.click(screen.getByRole("button", { name: "People" })); | ||
| expect(mocks.selected).toEqual(["people"]); | ||
| expect(labelOf("People")).toHaveClass("opacity-0"); | ||
| }); | ||
|
|
||
| it("expands again once the pointer has left and come back", async () => { | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| const el = screen.getByTestId("lens-rail"); | ||
|
|
||
| await user.hover(el); | ||
| settle(); | ||
| await user.click(screen.getByRole("button", { name: "People" })); | ||
| expect(labelOf("People")).toHaveClass("opacity-0"); | ||
|
|
||
| await user.unhover(el); | ||
| await user.hover(el); | ||
| settle(); | ||
| expect(labelOf("People")).toHaveClass("opacity-100"); | ||
| }); | ||
|
|
||
| it("renders nothing on a phone", () => { | ||
| // 56px of rail plus a 256px pane left a phone with almost no content; the | ||
| // zones live in the context pane's drawer there instead. | ||
| mocks.layout = "phone"; | ||
| rail(); | ||
| expect(screen.queryByTestId("lens-rail")).not.toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| describe("LensRail state that only breaks in a particular order", () => { | ||
| it("does not strand itself when a zone is chosen from the keyboard", async () => { | ||
| // Enter on a focused button produces a click, and a click used to mean | ||
| // "the pointer is resting on me, stay shut until it leaves". There is no | ||
| // pointer in this story, so nothing would ever clear that — the rail was | ||
| // dead to the mouse from then on. | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| await user.tab(); | ||
| await user.tab(); | ||
| await user.keyboard("{Enter}"); | ||
| expect(mocks.selected).toEqual(["people"]); | ||
|
|
||
| await user.hover(screen.getByTestId("lens-rail")); | ||
| settle(); | ||
| expect(labelOf("People")).toHaveClass("opacity-100"); | ||
| }); | ||
|
|
||
| it("shows the labels to a keyboard user at all", async () => { | ||
| // Eight identical icons and the text at zero opacity is not navigable by | ||
| // anyone who can see but is not using a pointer. | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| await user.tab(); | ||
| await user.tab(); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-100"); | ||
| }); | ||
|
|
||
| it("comes back shut after the rail is unmounted under the pointer", async () => { | ||
| // A width change unmounts the rail without a pointer-leave, so the state | ||
| // it left behind used to survive: widen again and it was already open, | ||
| // with the pointer nowhere near it. | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| const { rerender } = rail(); | ||
| await user.hover(screen.getByTestId("lens-rail")); | ||
| settle(); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-100"); | ||
|
|
||
| mocks.layout = "phone"; | ||
| rerender(<SidebarProvider><LensRail /></SidebarProvider>); | ||
| mocks.layout = "wide"; | ||
| rerender(<SidebarProvider><LensRail /></SidebarProvider>); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-0"); | ||
| }); | ||
|
|
||
| it("stays shut for a pointer that is only passing through", async () => { | ||
| // The wait is the whole guard. Before it was a timer, the panel became | ||
| // clickable at once and merely being over it counted as staying, so a | ||
| // crossing pointer opened the rail anyway — over the row it was heading | ||
| // for, having swallowed any click on the way. | ||
| const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); | ||
| rail(); | ||
| const el = screen.getByTestId("lens-rail"); | ||
| await user.hover(el); | ||
| await user.unhover(el); | ||
| settle(); | ||
| expect(labelOf("Overview")).toHaveClass("opacity-0"); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Vitest useFakeTimers shouldAdvanceTime with user-event advanceTimers option conflict💡 Result:
When using Vitest fake timers with the Testing Library
user-eventpackage, historical compatibility issues often lead to tests hanging or timing out [1][2]. These issues typically arise becauseuser-eventexpects a specific timer advancement interface, historically modeled after Jest, which Vitest does not provide by default in the global scope [3][2]. There is no conflict betweenvi.useFakeTimers({ shouldAdvanceTime: true })and theuser-eventadvanceTimersoption in terms of logical contradictions, but they serve different purposes and can be confusing when used together [4][5]. 1. Understanding the Options: -vi.useFakeTimers({ shouldAdvanceTime: true }): This tells Vitest to automatically increment the mocked clock based on the real system time passage [4]. It is rarely the recommended solution for standard component interaction tests, as it can introduce flakiness by tying your test execution speed to the real system clock [6]. -userEvent.setup({ advanceTimers: ... }): This allowsuser-eventto trigger time advancement explicitly when it needs to wait for interaction delays [7][5]. 2. Resolution and Best Practices: - Modern versions of@testing-library/user-event(v14.4.0+) have built-in support to auto-detect Vitest'sviglobal [3]. This eliminates the need for manual configuration or global stubs in most cases [3]. - If you encounter issues, ensure you are using the latest version ofuser-event[3]. - If you must configure it manually, explicitly pass Vitest's advancement function [5]: const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); - AvoidshouldAdvanceTime: trueunless you have a specific requirement to simulate real-time passage within a fake timer environment, as it often defeats the purpose of deterministic testing [6]. If you still experience timeouts, verify that you are awaiting theuser-eventcalls (e.g.,await user.click(...)) and that your test setup does not inadvertently leak timers by failing to callvi.useRealTimers()orvi.restoreAllMocks()in anafterEachblock [8].Citations:
userEvent.click()fails when used withvi.useFakeTimers(), all available solutions are not working testing-library/user-event#1115🏁 Script executed:
Repository: constructorfabric/insight
Length of output: 19980
🏁 Script executed:
Repository: constructorfabric/insight
Length of output: 1335
🏁 Script executed:
Repository: constructorfabric/insight
Length of output: 50381
🌐 Web query:
site:vitest.dev/config/faketimers shouldAdvanceTime advanceTimersByTime real time💡 Result:
shouldAdvanceTime: truemakes Vitest advance fake time automatically according to real elapsed time. By default, it advances in20 msincrements; configure this withadvanceTimeDelta. [1]vi.advanceTimersByTime(ms)is manual: it immediately advances mocked time by exactlyms, without waiting for real time. [2]So, use
shouldAdvanceTimewhen timers should track wall-clock time; useadvanceTimersByTimefor deterministic, instant test control. [1][2]🌐 Web query:
site:testing-library.com/docs/user-event/options advanceTimers fake timers💡 Result:
In testing environments, particularly when using testing-library's user-event, advanceTimers is a configuration option that allows the library to coordinate with your test runner's fake timers [1][2]. When user-event simulates interactions (such as typing or clicking), it often introduces artificial delays between events to mimic real user behavior [1]. When tests use fake timers (a feature provided by libraries like Jest or Vitest to mock time), these internal delays can conflict with the test runner, often causing tests to hang or time out if the fake clock is not advanced correctly [1][2]. To resolve this, you pass the test runner's time advancement function (e.g., jest.advanceTimersByTime or vitest.advanceTimersByTime) to the advanceTimers option in the userEvent.setup configuration [1][2]. This enables user-event to programmatically advance the fake clock by the necessary delay duration, allowing the simulated interactions to proceed without stalling the test [1]. Example usage with Jest: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); This approach is recommended over setting delay: null, as the latter can lead to unexpected behavior during test execution [1].
Citations:
🌐 Web query:
site:github.com/testing-library/user-event 14.6.1 advanceTimers pointer hover unhover delay💡 Result:
In
@testing-library/user-eventv14.6.1, the advanceTimers option and the delay configuration are used to manage asynchronous timing, particularly when working with fake timers in testing frameworks like Jest [1][2]. The advanceTimers option allows you to integrate your test runner's timer-advancing function (e.g., jest.advanceTimersByTime) into the user-event workflow [1][2]. This ensures that internal delays used by the library—which are intended to move events to the next macro task and allow asynchronous code to process—do not cause tests to hang or timeout when fake timers are active [3][1][2]. Usage typically involves configuring it during the setup phase: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); Regarding pointer interactions (hover/unhover) and delays: 1. The delay option dictates the pause duration between sequential user-event actions [1]. 2. By default, user-event introduces these small delays to better simulate real-user behavior and prevent race conditions with asynchronous state updates [1][2]. 3. If these internal delays conflict with your test environment (especially when using fake timers), you must either provide the advanceTimers callback to "fast-forward" through these delays [1] or, in specific scenarios where the overhead is not desired, set delay to null [1][2]. Version 14.6.1 included a fix to correct the documentation/description for the delay option [4], ensuring developers better understand how this configuration affects event execution timing.Citations:
advanceTimersoption testing-library/user-event#907🏁 Script executed:
Repository: constructorfabric/insight
Length of output: 1632
Remove
shouldAdvanceTimefrom the fake-timer setup.Real elapsed time can advance the mocked clock past the 200 ms opening timer before
unhover. Keepvi.useFakeTimers()and the explicitadvanceTimerscallback for deterministic control.🤖 Prompt for AI Agents