fix(ui): trigger sidebar peek on iPad Safari with a trackpad

iPadOS Safari never flips the `(hover: hover) and (pointer: fine)` media
query when a trackpad/mouse is attached, so `pointerCanPeek` stayed false
and the collapsed-sidebar hover peek never opened even though a real cursor
was driving the UI (PAP-10725).

Detect a fine pointer at runtime: a genuine mouse/trackpad emits pointer
events with `pointerType: "mouse"` (touch → "touch", Pencil → "pen"), so
listening for the first such event unlocks peek without false-positives on
touch-only input. Peek capability is a one-way latch for the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-16 10:02:03 +00:00
1 parent 1b89a8e3b8
commit 8b99cee9c5
2 files changed
+62 -1

No files matched your search

+36
View File
@@ -248,6 +248,42 @@ describe("SidebarContext", () => {
act(() => capturedValue?.setPeeking(true));
expect(capturedValue?.peeking).toBe(false);
});
// iPadOS Safari keeps the hover/pointer media query false even with a
// trackpad attached (PAP-10725); a real cursor still emits "mouse" pointer
// events, which must unlock peek at runtime.
it("peeks once a mouse-type pointer event is seen (iPad + trackpad)", () => {
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
setViewport({ mobile: false, hoverFine: false });
active = renderProvider();
expect(capturedValue?.collapsed).toBe(true);
// No cursor seen yet → peek stays gated despite the media query.
act(() => capturedValue?.setPeeking(true));
expect(capturedValue?.peeking).toBe(false);
// A trackpad/mouse moves: a "mouse" pointer event unlocks peek.
act(() => {
const e = new Event("pointermove");
(e as unknown as { pointerType: string }).pointerType = "mouse";
window.dispatchEvent(e);
});
expect(capturedValue?.peeking).toBe(true);
});
it("ignores touch pointer events (touch-only stays gated)", () => {
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
setViewport({ mobile: false, hoverFine: false });
active = renderProvider();
act(() => capturedValue?.setPeeking(true));
act(() => {
const e = new Event("pointerover");
(e as unknown as { pointerType: string }).pointerType = "touch";
window.dispatchEvent(e);
});
expect(capturedValue?.peeking).toBe(false);
});
});
describe("back-compat", () => {
+26 -1
View File
@@ -112,11 +112,36 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const mql = window.matchMedia(PEEK_POINTER_QUERY);
const onChange = (e: MediaQueryListEvent) => setPointerCanPeek(e.matches);
// Latch on only — see the runtime detection below for why this never flips
// back to false.
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setPointerCanPeek(true);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
// iPadOS Safari does not flip the `(hover: hover) and (pointer: fine)` media
// query when a trackpad/mouse is attached, so the query above stays false even
// though a real cursor is driving the UI — and hover-peek never triggers
// (PAP-10725). Detect a fine pointer at runtime instead: a genuine
// mouse/trackpad emits pointer events with `pointerType: "mouse"`, whereas
// touch reports "touch" and the Pencil reports "pen", so this never enables on
// touch-only input. Treat peek capability as a one-way latch — once a cursor
// has been seen we keep peek available for the session.
useEffect(() => {
if (pointerCanPeek || typeof window.PointerEvent !== "function") return;
const onPointer = (e: PointerEvent) => {
if (e.pointerType === "mouse") setPointerCanPeek(true);
};
window.addEventListener("pointerover", onPointer, { passive: true });
window.addEventListener("pointermove", onPointer, { passive: true });
return () => {
window.removeEventListener("pointerover", onPointer);
window.removeEventListener("pointermove", onPointer);
};
}, [pointerCanPeek]);
// Precedence (highest wins): forced (active secondary sidebar) > explicit user
// pin > route request > default expanded. The force is ephemeral and never
// touches the persisted pin, so dropping it restores the user's preference.