Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions __tests__/hooks/useIsTouchDevice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
});

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion components/waves/drops/WaveDropActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function WaveDropActions({
<div
className={`tw-absolute tw-right-2 tw-z-20 ${
compact ? "-tw-top-4" : "tw-top-0"
} tw-opacity-0 tw-transition-opacity tw-duration-200 tw-ease-in-out group-hover:tw-opacity-100`}
} tw-opacity-0 tw-transition-opacity tw-duration-200 tw-ease-in-out desktop-hover:group-hover:tw-opacity-100 focus-within:tw-opacity-100`}
>
<div className="tw-flex tw-items-center tw-gap-x-2">
<div className="tw-flex tw-h-8 tw-items-center tw-rounded-lg tw-bg-iron-950 tw-shadow tw-ring-1 tw-ring-inset tw-ring-iron-800">
Expand Down
23 changes: 21 additions & 2 deletions hooks/useIsTouchDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions styles/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;