mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
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:
1 parent
1b89a8e3b8
commit
8b99cee9c5
2 files changed
+62
-1
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user