diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx index 03d044f0c..5099e8cd8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx @@ -3,7 +3,6 @@ import { TreeDataItem, TreeView, TreeViewMoveResult, - useResponsive, useTreeContext, } from '@gouvfr-lasuite/ui-components'; import { useRouter } from 'next/router'; @@ -13,6 +12,7 @@ import { NodeApi } from 'react-arborist'; import { Overlayer } from '@/components'; import { CLASS_DOC_TITLE } from '@/docs/doc-header'; import { Doc, useMoveDoc } from '@/docs/doc-management'; +import { useResponsiveStore } from '@/stores/useResponsiveStore'; import { isDocNode, isWithinTreeItemActions } from '../utils'; @@ -33,7 +33,7 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ rootNodeId, rootItemRef, }: DocTreeSubPagesProps) { - const { isDesktop } = useResponsive(); + const { hasCoarsePointer } = useResponsiveStore(); const treeContext = useTreeContext(); const { mutateAsync: moveDoc } = useMoveDoc(); const { query } = useRouter(); @@ -70,11 +70,11 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ ({ parentNode }: { parentNode: NodeApi> | null }) => { const parentValue = parentNode?.data.value; if (!parentValue || !isDocNode(parentValue)) { - return doc.abilities.move && isDesktop; + return doc.abilities.move && !hasCoarsePointer; } - return parentValue.abilities.move && isDesktop; + return parentValue.abilities.move && !hasCoarsePointer; }, - [doc.abilities.move, isDesktop], + [doc.abilities.move, hasCoarsePointer], ); const canDrag = useCallback( @@ -82,9 +82,9 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ if (!isDocNode(node.value)) { return false; } - return node.value.abilities.move && isDesktop; + return node.value.abilities.move && !hasCoarsePointer; }, - [isDesktop], + [hasCoarsePointer], ); const handleRowKeyDown = useCallback( diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx index fba889ed1..c4cd45ab4 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -8,7 +8,6 @@ import { } from '@dnd-kit/core'; import { getEventCoordinates } from '@dnd-kit/utilities'; import { TreeViewMoveModeEnum, useModal } from '@gouvfr-lasuite/ui-components'; -import { useMediaQuery } from '@mantine/hooks'; import dynamic from 'next/dynamic'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -17,6 +16,7 @@ import { css } from 'styled-components'; import { Card, Text } from '@/components'; import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management'; +import { useResponsiveStore } from '@/stores/useResponsiveStore'; import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; @@ -322,24 +322,18 @@ export const DraggableDocGridItem = ({ ); }; -/** - * Browser zoom shrinks the viewport below the mobile breakpoint on a desktop, - * so dnD depends on the pointer, not on the width. `any-pointer` also covers a - * mouse plugged into a touch-first computer, where the primary pointer is coarse. - */ -const FINE_POINTER_QUERY = '(any-pointer: fine)'; - export const DocGridContentList = ({ docs }: DocGridContentListProps) => { - const hasFinePointer = useMediaQuery(FINE_POINTER_QUERY); + const { hasCoarsePointer } = useResponsiveStore(); if (docs.length === 0) { return null; } - if (hasFinePointer) { + if (!hasCoarsePointer) { return ; } + // Render a non-draggable list for touch-primary devices. return docs.map((doc) => ( )); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocGridContentList.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocGridContentList.test.tsx index 4a62e7df1..16ae2fc6e 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocGridContentList.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocGridContentList.test.tsx @@ -3,6 +3,7 @@ import type { Ref } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Doc } from '@/docs/doc-management'; +import { useResponsiveStore } from '@/stores'; import { AppWrapper } from '@/tests/utils'; import { DocGridContentList } from '../DocGridContentList'; @@ -26,7 +27,7 @@ vi.mock('../DocsGridItem', () => ({ ), })); -const mockPointer = (isFine: boolean) => { +const mockPointer = (isCoarse: boolean) => { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, @@ -34,13 +35,16 @@ const mockPointer = (isFine: boolean) => { addEventListener: vi.fn(), addListener: vi.fn(), dispatchEvent: vi.fn(), - matches: query === '(any-pointer: fine)' && isFine, + matches: query === '(pointer: coarse)' && isCoarse, media: query, onchange: null, removeEventListener: vi.fn(), removeListener: vi.fn(), })), }); + + // AppWrapper does not mount the AppProvider that wires the store. + return useResponsiveStore.getState().initializeResizeListener(); }; const doc = { @@ -50,12 +54,15 @@ const doc = { } as Doc; describe('DocGridContentList', () => { + let cleanup: (() => void) | undefined; + afterEach(() => { + cleanup?.(); vi.restoreAllMocks(); }); it('lets a mouse drag a document whatever the viewport width', () => { - mockPointer(true); + cleanup = mockPointer(false); render(, { wrapper: AppWrapper }); @@ -65,8 +72,8 @@ describe('DocGridContentList', () => { ); }); - it('does not make documents draggable without a fine pointer', () => { - mockPointer(false); + it('does not make documents draggable with a coarse pointer', () => { + cleanup = mockPointer(true); render(, { wrapper: AppWrapper }); diff --git a/src/frontend/apps/impress/src/stores/useResponsiveStore.tsx b/src/frontend/apps/impress/src/stores/useResponsiveStore.tsx index 1b74e1c36..7127e5c71 100644 --- a/src/frontend/apps/impress/src/stores/useResponsiveStore.tsx +++ b/src/frontend/apps/impress/src/stores/useResponsiveStore.tsx @@ -19,6 +19,11 @@ export interface UseResponsiveStore { screenWidth: number; setScreenSize: (size: ScreenSize) => void; isDesktop: boolean; + /** + * Indicates whether the primary input device has a coarse pointer (e.g., a touchscreen) + * rather than a fine pointer (e.g., a mouse or trackpad). + */ + hasCoarsePointer: boolean; initializeResizeListener: () => () => void; } @@ -28,6 +33,7 @@ const initialState = { isSmallMobile: false, isTablet: false, isDesktop: false, + hasCoarsePointer: false, screenSize: 'desktop' as ScreenSize, screenWidth: 0, }; @@ -38,6 +44,7 @@ export const useResponsiveStore = create((set) => ({ isMobile: initialState.isMobile, isSmallMobile: initialState.isSmallMobile, isTablet: initialState.isTablet, + hasCoarsePointer: initialState.hasCoarsePointer, screenSize: initialState.screenSize, screenWidth: initialState.screenWidth, setScreenSize: (size: ScreenSize) => set(() => ({ screenSize: size })), @@ -107,9 +114,18 @@ export const useResponsiveStore = create((set) => ({ resizeHandler(); + // Listen for changes to the primary input device's pointer type (coarse vs fine). + const coarsePointerQuery = window.matchMedia('(pointer: coarse)'); + const pointerHandler = () => { + set({ hasCoarsePointer: coarsePointerQuery.matches }); + }; + pointerHandler(); + coarsePointerQuery.addEventListener('change', pointerHandler); + return () => { clearTimeout(resizeTimeout); window.removeEventListener('resize', debouncedResizeHandler); + coarsePointerQuery.removeEventListener('change', pointerHandler); }; }, }));