diff --git a/src/js/reducers/device.js b/src/js/reducers/device.js index 081e24ca..d1bd245c 100644 --- a/src/js/reducers/device.js +++ b/src/js/reducers/device.js @@ -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, @@ -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 } = {}) => { @@ -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: diff --git a/test/mocks/matchmedia.js b/test/mocks/matchmedia.js new file mode 100644 index 00000000..154f3f53 --- /dev/null +++ b/test/mocks/matchmedia.js @@ -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 }; + }), + }); +} diff --git a/test/usert-type.test.jsx b/test/user-type.test.jsx similarity index 69% rename from test/usert-type.test.jsx rename to test/user-type.test.jsx index 8b84845e..e69fe36c 100644 --- a/test/usert-type.test.jsx +++ b/test/user-type.test.jsx @@ -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(); @@ -37,6 +38,7 @@ describe('User Type', () => { afterEach(() => { server.resetHandlers() localStorage.clear(); + delete window.matchMedia; }); afterAll(() => server.close()); @@ -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(, { 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(, { 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(, { 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(, { preloadedState: getStateWithout(libraryViewState, 'device') }); + await waitForPosition(); + expect(window.document.documentElement).toHaveClass('touch'); + }); });