fix(ui): collapse the sidebar atomically, ignoring the lingering hover (PAP-10676)

Clicking the collapse button switched to rail mode but the pointer was still over
the panel, so the peek logic immediately re-expanded it — the collapse only
"took" once the mouse moved away.

Fix in Layout's peek wiring:
- Track pointer-over-panel even while expanded (mouse enter/leave handlers are now
  always wired, but only open a peek when collapsed), so at the moment of collapse
  we know the pointer is still over the sidebar.
- On the expanded→collapsed transition, cancel any pending/active peek
  (clearPeekTimer + setPeeking(false)) and, when the pointer is still inside, set a
  suppress flag so the lingering hover can't re-open the peek. The flag is re-armed
  on the next genuine pointer-leave, so normal hover-to-peek still works afterwards.
- Focus-driven peek respects the same suppress flag.

Tests: added a Layout test that collapses while hovering and asserts the peek is
cancelled and does not re-open, plus a positive-control test proving a normal
collapsed-rail hover still opens the peek (so the suppression test is meaningful).
70 sidebar+layout tests pass; tsc clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 15:11:47 +00:00
1 parent fc5d173685
commit 05951f20ae
1 file changed
+27
+27
View File
@@ -334,6 +334,33 @@ describe("Layout", () => {
await act(async () => { root.unmount(); });
});
it("opens the peek when hovering a collapsed rail (positive control for the hover sim)", async () => {
mockSidebarState.collapsed = true;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
const panel = [...container.querySelectorAll<HTMLElement>("div")].find(
(el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
);
expect(panel).toBeTruthy();
await act(async () => {
panel!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
});
await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
// A normal collapsed-rail hover (not just-collapsed) opens the peek.
expect(mockSetPeeking).toHaveBeenCalledWith(true);
await act(async () => { root.unmount(); });
});
it("keeps the app sidebar and shows the settings sidebar in the secondary pane on settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [