diff --git a/__tests__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts index 3dffe50545..5bc0e2f54e 100644 --- a/__tests__/hooks/useIsTouchDevice.test.ts +++ b/__tests__/hooks/useIsTouchDevice.test.ts @@ -5,8 +5,10 @@ describe("useIsTouchDevice", () => { let addEventListenerSpy: jest.SpyInstance; let removeEventListenerSpy: jest.SpyInstance; let touchStartHandler: EventListener | null = null; + let originalMaxTouchPoints: number | undefined; beforeEach(() => { + originalMaxTouchPoints = (globalThis.navigator as Navigator | undefined)?.maxTouchPoints; addEventListenerSpy = jest.spyOn(globalThis, "addEventListener").mockImplementation((event, handler) => { if (event === "touchstart") { touchStartHandler = handler as EventListener; @@ -19,6 +21,20 @@ describe("useIsTouchDevice", () => { addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); touchStartHandler = null; + if (typeof originalMaxTouchPoints === "number") { + Object.defineProperty(globalThis.navigator, "maxTouchPoints", { + value: originalMaxTouchPoints, + configurable: true, + }); + } else { + // Ensure tests don't leak touch points across cases. + try { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis.navigator as any).maxTouchPoints; + } catch { + // ignore + } + } jest.restoreAllMocks(); }); @@ -46,6 +62,32 @@ describe("useIsTouchDevice", () => { expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); }); + it("returns true initially when maxTouchPoints > 0 and no fine pointer", () => { + Object.defineProperty(globalThis.navigator, "maxTouchPoints", { value: 10, configurable: true }); + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: jest.fn(() => ({ matches: false })), + }); + + const { result } = renderHook(() => useIsTouchDevice()); + expect(result.current).toBe(true); + expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); + }); + + it("returns false when a fine pointer exists even if maxTouchPoints > 0", () => { + Object.defineProperty(globalThis.navigator, "maxTouchPoints", { value: 10, configurable: true }); + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: jest.fn((query: string) => ({ + matches: query === "(any-pointer: fine)", + })), + }); + + const { result } = renderHook(() => useIsTouchDevice()); + expect(result.current).toBe(false); + expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); + }); + it("returns false initially but switches to true after touchstart when no fine pointer", () => { Object.defineProperty(globalThis, "matchMedia", { writable: true, diff --git a/components/waves/drops/WaveDropActions.tsx b/components/waves/drops/WaveDropActions.tsx index c08df2a5bc..4f9fa27d2a 100644 --- a/components/waves/drops/WaveDropActions.tsx +++ b/components/waves/drops/WaveDropActions.tsx @@ -55,7 +55,7 @@ export default function WaveDropActions({
diff --git a/hooks/useIsTouchDevice.ts b/hooks/useIsTouchDevice.ts index f875bc757f..158d7812c4 100644 --- a/hooks/useIsTouchDevice.ts +++ b/hooks/useIsTouchDevice.ts @@ -14,12 +14,31 @@ export default function useIsTouchDevice(): boolean { matchMedia?: (query: string) => MediaQueryList; }; - const hasFinePointer = win.matchMedia?.("(pointer: fine)")?.matches; - if (hasFinePointer) { + const nav = globalThis.navigator as Navigator | undefined; + const maxTouchPoints = nav?.maxTouchPoints ?? 0; + + // Prefer "any-*" media queries so hybrid devices (touchscreen + trackpad/mouse) + // aren't misclassified as touch-only when the primary pointer is coarse. + const hasAnyFinePointer = win.matchMedia?.("(any-pointer: fine)")?.matches ?? false; + const hasPrimaryFinePointer = win.matchMedia?.("(pointer: fine)")?.matches ?? false; + const hasFinePointer = hasAnyFinePointer || hasPrimaryFinePointer; + + const hasAnyHover = win.matchMedia?.("(any-hover: hover)")?.matches ?? false; + const hasPrimaryHover = win.matchMedia?.("(hover: hover)")?.matches ?? false; + const hasHover = hasAnyHover || hasPrimaryHover; + + if (hasFinePointer || hasHover) { setIsTouchDevice(false); return; } + // If there's no fine pointer and the device advertises touch points, treat it + // as touch (important for first-touch interactions like long-press menus). + if (maxTouchPoints > 0) { + setIsTouchDevice(true); + return; + } + const onTouchStart = () => { setIsTouchDevice(true); globalThis.removeEventListener("touchstart", onTouchStart); diff --git a/styles/globals.scss b/styles/globals.scss index 32d6dd80bc..30480cda07 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -95,7 +95,9 @@ /* Prevent text selection on touch devices only */ .touch-select-none { - @media (pointer: coarse) { + // Use any-* queries so hybrid devices (touchscreen + trackpad/mouse) don't + // get treated as touch-only when the primary pointer is coarse. + @media (any-hover: none) and (any-pointer: coarse) { user-select: none; -webkit-user-select: none; -moz-user-select: none; @@ -724,7 +726,7 @@ h5:not(.tailwind-scope h5) { } } -@media (hover: none) and (pointer: coarse) { +@media (any-hover: none) and (any-pointer: coarse) { .touch-visible { display: block !important; } diff --git a/tailwind.config.ts b/tailwind.config.ts index 5f53e92e09..16b5b24e60 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -173,7 +173,9 @@ export default { scrollbar({ nocompatible: true }), containerQueries, plugin(({ addVariant }) => { - addVariant("desktop-hover", "@media (hover: hover) and (pointer: fine)"); + // Use any-* queries so hybrid devices (touchscreen + trackpad/mouse) still + // get hover styles even if the primary pointer is coarse. + addVariant("desktop-hover", "@media (any-hover: hover)"); }), ], } satisfies Config;