Skip to content

Commit

Permalink
Assume 2-in-1 devices running Windows prefer mouse UI. Resolve #575.
Browse files Browse the repository at this point in the history
  • Loading branch information
tnajdek committed Nov 25, 2024
1 parent 19fe3c8 commit c71652d
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 19 deletions.
37 changes: 20 additions & 17 deletions src/js/reducers/device.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { TRIGGER_USER_TYPE_CHANGE, TRIGGER_RESIZE_VIEWPORT, PREFERENCE_CHANGE } from '../constants/actions.js';
import { getScrollbarWidth, pick } from 'web-common/utils';

const isInitiallyMouse = typeof(matchMedia) === 'function' ? matchMedia('(pointer:fine)').matches : null;
const isInitiallyTouch = typeof(matchMedia) === 'function' ? matchMedia('(pointer:coarse)').matches : null;

const getViewport = ({ width }) => {
return {
xxs: width < 480,
Expand All @@ -14,19 +11,25 @@ const getViewport = ({ width }) => {
};
};

const defaultState = {
isKeyboardUser: false,
isMouseUser: isInitiallyMouse,
isSingleColumn: false,
isTouchOrSmall: false,
isTouchUser: isInitiallyTouch,
scrollbarWidth: getScrollbarWidth(),
shouldUseEditMode: false,
shouldUseModalCreatorField: false,
shouldUseSidebar: false,
shouldUseTabs: false,
userType: isInitiallyTouch ? 'touch' : 'mouse',
...getViewport(process.env.NODE_ENV === 'test' ? {} : window.innerWidth)
const getDefaultState = () => {
const isWindows = navigator.userAgent.indexOf("Windows") >= 0;
const isInitiallyMouse = typeof (window.matchMedia) === 'function' ? (window.matchMedia('(pointer:fine)').matches || (isWindows && window.matchMedia('(any-pointer:fine)'))) : null;
const isInitiallyTouch = !isInitiallyMouse && (typeof(window.matchMedia) === 'function' ? window.matchMedia('(pointer:coarse)').matches : null);

return {
isKeyboardUser: false,
isMouseUser: isInitiallyMouse,
isSingleColumn: false,
isTouchOrSmall: false,
isTouchUser: isInitiallyTouch,
scrollbarWidth: getScrollbarWidth(),
shouldUseEditMode: false,
shouldUseModalCreatorField: false,
shouldUseSidebar: false,
shouldUseTabs: false,
userType: isInitiallyTouch ? 'touch' : 'mouse',
...getViewport(process.env.NODE_ENV === 'test' ? {} : window.innerWidth)
}
};

const getDevice = (userType, viewport, { isEmbedded } = {}) => {
Expand Down Expand Up @@ -65,7 +68,7 @@ const getUserTypeBooleans = (state, action, userType) => {
};
}

const device = (state = defaultState, action, { config, preferences } = {}) => {
const device = (state = getDefaultState(), action, { config, preferences } = {}) => {
switch(action.type) {
case PREFERENCE_CHANGE:
case TRIGGER_RESIZE_VIEWPORT:
Expand Down
34 changes: 34 additions & 0 deletions test/mocks/matchmedia.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export function mockMatchMedia({ isTouch = false, isMouse = true, isPrimaryTouch = null, isPrimaryMouse = null }) {
isPrimaryTouch = isPrimaryTouch ?? isTouch;
isPrimaryMouse = isPrimaryMouse ?? isMouse;

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => {
const queryResult = {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
};

if (query === '(pointer:fine)') {
return { ...queryResult, matches: isPrimaryMouse };
}
if (query === '(any-pointer:fine)') {
return { ...queryResult, matches: isMouse };
}
if (query === '(pointer:coarse)') {
return { ...queryResult, matches: isPrimaryTouch };
}
if (query === '(any-pointer:coarse)') {
return { ...queryResult, matches: isTouch };
}
return { ...queryResult, matches: false };
}),
});
}
36 changes: 34 additions & 2 deletions test/usert-type.test.jsx → test/user-type.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import { act, fireEvent, getByRole, screen, waitFor } from '@testing-library/rea
import userEvent from '@testing-library/user-event'

import { renderWithProviders } from './utils/render';
import { JSONtoState } from './utils/state';
import { JSONtoState, getStateWithout } from './utils/state';
import { MainZotero } from '../src/js/component/main';
import { applyAdditionalJestTweaks } from './utils/common';
import { applyAdditionalJestTweaks, waitForPosition } from './utils/common';
import stateLibraryView from './fixtures/state/desktop-test-user-library-view.json';
import { mockMatchMedia } from './mocks/matchmedia';

const libraryViewState = JSONtoState(stateLibraryView);
applyAdditionalJestTweaks();
Expand All @@ -37,6 +38,7 @@ describe('User Type', () => {
afterEach(() => {
server.resetHandlers()
localStorage.clear();
delete window.matchMedia;
});

afterAll(() => server.close());
Expand Down Expand Up @@ -124,4 +126,34 @@ describe('User Type', () => {
await userEvent.selectOptions(densityComboBox, 'Mouse');
expect(window.document.documentElement).toHaveClass('mouse');
});

test("It should opt for touch interface on device that reports touch pointer", async () => {
mockMatchMedia({ isTouch: true, isMouse: false });
renderWithProviders(<MainZotero />, { preloadedState: getStateWithout(libraryViewState, 'device') });
await waitForPosition();
expect(window.document.documentElement).toHaveClass('touch');
});

test("It should opt for mouse interface on device that reports mouse pointer", async () => {
mockMatchMedia({ isTouch: false, isMouse: true });
renderWithProviders(<MainZotero />, { preloadedState: getStateWithout(libraryViewState, 'device') });
await waitForPosition();
expect(window.document.documentElement).toHaveClass('mouse');
});

test("It should opt for mouse interface on Windows device that reports both touch and mouse pointers", async () => {
jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3');
mockMatchMedia({ isTouch: true, isMouse: true, isPrimaryMouse: false, isPrimaryTouch: true });
renderWithProviders(<MainZotero />, { preloadedState: getStateWithout(libraryViewState, 'device') });
await waitForPosition();
expect(window.document.documentElement).toHaveClass('mouse');
});

test("It should opt for touch interface on iOS device that reports both touch and mouse pointers", async () => {
jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1');
mockMatchMedia({ isTouch: true, isMouse: true, isPrimaryMouse: false, isPrimaryTouch: true });
renderWithProviders(<MainZotero />, { preloadedState: getStateWithout(libraryViewState, 'device') });
await waitForPosition();
expect(window.document.documentElement).toHaveClass('touch');
});
});

0 comments on commit c71652d

Please sign in to comment.