fix(gallery): follow the input in use, not the device's primary pointer
Closes #1275. Follow-up to #1263. `matchMedia('(hover: none) and (pointer: coarse)')` answers "what is this device's primary pointer", which on anything with both inputs is the wrong question. A touchscreen laptop reports fine+hover, so a finger tap was handled as a click: the photo opened with no reveal step and the tile's own actions needed a hover a finger cannot produce. An iPad with a trackpad reports the opposite, so a mouse click was handled as a tap and opening a photo took two of them while hovering did nothing. Pointer events carry the answer per interaction. useInputMode holds one module-level mode fed by a single window-level listener pair, so every tile agrees and the listener count does not scale with the grid. The primary-pointer query stays as the opening guess -- it is right for the two single-input cases that are most of the traffic, a phone and a desktop -- and the first real interaction corrects it on a hybrid. pointermove matters as much as pointerdown: a mouse announces itself by approaching, and the mode has to be right BEFORE the click, not as a consequence of it. A pen is grouped with touch, since it taps rather than hovers on most hardware and being wrong that way costs only a reveal step. GalleryPremiumLayout's touch rules move off the media query onto a data-input-mode attribute the layout sets, for the same reason: on a touchscreen laptop the query stayed false and a finger could never reach the checkbox or like button, and on an iPad with a trackpad it stayed true and both were stuck on permanently. The #1263 guarantee is unchanged and pinned by a test that walks all three modes: a control that cannot be seen cannot be hit, whichever input is in use. Verified in the running app under Chrome touch emulation, on a device advertising a coarse primary pointer -- the iPad-with-trackpad case. A mouse merely moving switched the grid to hover semantics and revealed the overlay, and a subsequent tap switched it back; the premium layout's attribute followed, with its checkbox reachable under touch and hidden-but-inert under mouse. The mirror case (finger on a fine-primary device) cannot be staged in Chrome, which couples touch emulation to a coarse primary pointer, so it rests on the jsdom tests. 12 tests: 8 on the store, 4 more on PhotoCard. 3 of the 4 fail without the per-interaction mode; the fourth is the #1263 no-regression guard and holds on both sides by design.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The input-mode store (#1275).
|
||||
*
|
||||
* The bug it exists to fix is that `matchMedia('(hover: none) and (pointer:
|
||||
* coarse)')` answers "what is this device's primary pointer", which on
|
||||
* anything with both inputs is the wrong question. These pin the answer it
|
||||
* gives instead: whichever input was used last, corrected before the click.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
|
||||
import { useInputMode, __inputModeTesting } from '../useInputMode';
|
||||
|
||||
/** Report a primary pointer, the way a device advertises itself. */
|
||||
function stubPrimaryPointer(coarse: boolean) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: coarse && query.includes('pointer: coarse'),
|
||||
media: query,
|
||||
addEventListener: () => {}, removeEventListener: () => {},
|
||||
addListener: () => {}, removeListener: () => {},
|
||||
onchange: null, dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
__inputModeTesting.reset();
|
||||
}
|
||||
|
||||
function pointer(type: 'pointerdown' | 'pointermove', pointerType: string) {
|
||||
act(() => {
|
||||
const event = new Event(type, { bubbles: true }) as any;
|
||||
event.pointerType = pointerType;
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
const Probe: React.FC = () => <span data-testid="mode">{useInputMode()}</span>;
|
||||
const mode = () => screen.getByTestId('mode').textContent;
|
||||
|
||||
beforeEach(() => stubPrimaryPointer(false));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useInputMode', () => {
|
||||
it('starts from the primary pointer, which is right for single-input devices', () => {
|
||||
render(<Probe />);
|
||||
expect(mode()).toBe('mouse');
|
||||
|
||||
stubPrimaryPointer(true);
|
||||
render(<Probe />);
|
||||
expect(screen.getAllByTestId('mode')[1].textContent).toBe('touch');
|
||||
});
|
||||
|
||||
it('follows the input in use, in both directions', () => {
|
||||
render(<Probe />);
|
||||
expect(mode()).toBe('mouse');
|
||||
|
||||
pointer('pointerdown', 'touch');
|
||||
expect(mode()).toBe('touch');
|
||||
|
||||
pointer('pointermove', 'mouse');
|
||||
expect(mode()).toBe('mouse');
|
||||
|
||||
pointer('pointerdown', 'touch');
|
||||
expect(mode()).toBe('touch');
|
||||
});
|
||||
|
||||
it('classifies an approaching mouse before it clicks', () => {
|
||||
// The whole point of listening to pointermove as well: a mouse announces
|
||||
// itself by moving, and the mode has to be right BEFORE the click, not as
|
||||
// a consequence of it.
|
||||
stubPrimaryPointer(true);
|
||||
render(<Probe />);
|
||||
expect(mode()).toBe('touch');
|
||||
|
||||
pointer('pointermove', 'mouse');
|
||||
expect(mode()).toBe('mouse');
|
||||
});
|
||||
|
||||
it('groups a pen with touch, since it taps rather than hovers', () => {
|
||||
render(<Probe />);
|
||||
pointer('pointerdown', 'pen');
|
||||
expect(mode()).toBe('touch');
|
||||
});
|
||||
|
||||
it('ignores a pointer type it does not recognise', () => {
|
||||
render(<Probe />);
|
||||
pointer('pointerdown', '');
|
||||
expect(mode()).toBe('mouse');
|
||||
});
|
||||
|
||||
it('shares one mode, and one listener pair, across every subscriber', () => {
|
||||
// A gallery renders this hook once per tile. The listeners must not scale
|
||||
// with the tile count, and two tiles must never disagree about the input.
|
||||
const { unmount } = render(<><Probe /><Probe /><Probe /></>);
|
||||
expect(__inputModeTesting.subscriberCount()).toBe(3);
|
||||
|
||||
pointer('pointerdown', 'touch');
|
||||
expect(screen.getAllByTestId('mode').map((n) => n.textContent))
|
||||
.toEqual(['touch', 'touch', 'touch']);
|
||||
|
||||
unmount();
|
||||
expect(__inputModeTesting.subscriberCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('stops listening once nothing is subscribed', () => {
|
||||
const add = vi.spyOn(window, 'addEventListener');
|
||||
const remove = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(<Probe />);
|
||||
const pointerAdds = add.mock.calls.filter(([type]) => String(type).startsWith('pointer'));
|
||||
expect(pointerAdds.map(([type]) => type).sort()).toEqual(['pointerdown', 'pointermove']);
|
||||
|
||||
unmount();
|
||||
const pointerRemoves = remove.mock.calls.filter(([type]) => String(type).startsWith('pointer'));
|
||||
expect(pointerRemoves.map(([type]) => type).sort()).toEqual(['pointerdown', 'pointermove']);
|
||||
});
|
||||
|
||||
it('falls back to touch signals where matchMedia is missing', () => {
|
||||
delete (window as any).matchMedia;
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 });
|
||||
__inputModeTesting.reset();
|
||||
render(<Probe />);
|
||||
expect(mode()).toBe('touch');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Which input the visitor is using RIGHT NOW — not what the device is (#1275).
|
||||
*
|
||||
* `matchMedia('(hover: none) and (pointer: coarse)')` answers a different
|
||||
* question: it describes the device's PRIMARY pointer. On anything with both
|
||||
* — a touchscreen laptop, an iPad with a trackpad, a Surface — one of the two
|
||||
* inputs is then handled as if it were the other:
|
||||
*
|
||||
* - fine-primary + finger: the tap is treated as a click, so a photo opens
|
||||
* with no tap-to-reveal and the tile's own actions need a hover that a
|
||||
* finger cannot produce.
|
||||
* - coarse-primary + mouse: the click is treated as a tap, so opening a
|
||||
* photo takes two clicks and hovering does nothing.
|
||||
*
|
||||
* Pointer events carry the answer per interaction. One window-level listener
|
||||
* pair feeds a module-level mode that every subscriber shares, so all cards
|
||||
* agree and the listener count does not scale with the number of tiles.
|
||||
*
|
||||
* `pointermove` matters as much as `pointerdown`: a mouse announces itself by
|
||||
* approaching, and the mode has to be right BEFORE the click lands, not as a
|
||||
* consequence of it.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type InputMode = 'touch' | 'mouse';
|
||||
|
||||
const COARSE_POINTER_QUERY = '(hover: none) and (pointer: coarse)';
|
||||
|
||||
/**
|
||||
* The reading to start from, before anything has been touched or moved.
|
||||
* The primary-pointer query is the best guess available at that moment, and it
|
||||
* is right for the two single-input cases that make up most traffic — a phone
|
||||
* and a desktop. On a hybrid it is a coin toss that the first real interaction
|
||||
* corrects.
|
||||
*/
|
||||
function initialMode(): InputMode {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
// No matchMedia (old embedded webviews, some test environments). A
|
||||
// touchscreen signal is the only thing left to go on.
|
||||
const hasNavigator = typeof navigator !== 'undefined';
|
||||
const touchish = (typeof window !== 'undefined' && 'ontouchstart' in window)
|
||||
|| (hasNavigator && navigator.maxTouchPoints > 0);
|
||||
return touchish ? 'touch' : 'mouse';
|
||||
}
|
||||
return window.matchMedia(COARSE_POINTER_QUERY).matches ? 'touch' : 'mouse';
|
||||
}
|
||||
|
||||
let mode: InputMode = initialMode();
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
function setMode(next: InputMode) {
|
||||
if (next === mode) return;
|
||||
mode = next;
|
||||
subscribers.forEach((notify) => notify());
|
||||
}
|
||||
|
||||
function handlePointer(event: PointerEvent) {
|
||||
// A pen is grouped with touch: it taps rather than hovers on most hardware,
|
||||
// and being wrong in that direction only costs a reveal step, where being
|
||||
// wrong the other way puts an action under a pointer that cannot see it.
|
||||
if (event.pointerType === 'touch' || event.pointerType === 'pen') setMode('touch');
|
||||
else if (event.pointerType === 'mouse') setMode('mouse');
|
||||
// Anything else (an unknown or empty pointerType) leaves the mode alone.
|
||||
}
|
||||
|
||||
function subscribe(notify: () => void) {
|
||||
if (subscribers.size === 0 && typeof window !== 'undefined') {
|
||||
// Capture phase, so the mode is settled before any component's own handler
|
||||
// for the same interaction runs. Passive: this never calls preventDefault.
|
||||
window.addEventListener('pointerdown', handlePointer, { capture: true, passive: true });
|
||||
window.addEventListener('pointermove', handlePointer, { capture: true, passive: true });
|
||||
}
|
||||
subscribers.add(notify);
|
||||
return () => {
|
||||
subscribers.delete(notify);
|
||||
if (subscribers.size === 0 && typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handlePointer, { capture: true });
|
||||
window.removeEventListener('pointermove', handlePointer, { capture: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getSnapshot = () => mode;
|
||||
// The server has no pointer; 'mouse' keeps hover markup in the initial HTML.
|
||||
const getServerSnapshot = (): InputMode => 'mouse';
|
||||
|
||||
/** The input in use right now. Re-renders the caller when it changes. */
|
||||
export function useInputMode(): InputMode {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export const __inputModeTesting = {
|
||||
/** Re-read the device and drop any interaction history. */
|
||||
reset() {
|
||||
mode = initialMode();
|
||||
subscribers.forEach((notify) => notify());
|
||||
},
|
||||
current: () => mode,
|
||||
subscriberCount: () => subscribers.size,
|
||||
};
|
||||
Reference in New Issue
Block a user