Skip to content

Commit 5bb089e

Browse files
committed
🐛(frontend) fix drag-and-drop move disabled by viewport breakpoint
Drag-and-drop to move a document was disabled below a viewport width breakpoint. Browser zoom shrinks window.innerWidth like a narrower window does, so zooming in on a desktop with a mouse silently disabled moving documents, in the docs grid and in the doc tree. A fix was made on the doc grid, but not in the tree, we updated useResponsiveStore to tell us if the window has coarse pointer or not (hasCoarsePointer). Closes #2599
1 parent 7123186 commit 5bb089e

4 files changed

Lines changed: 39 additions & 22 deletions

File tree

‎src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
TreeDataItem,
44
TreeView,
55
TreeViewMoveResult,
6-
useResponsive,
76
useTreeContext,
87
} from '@gouvfr-lasuite/ui-components';
98
import { useRouter } from 'next/router';
@@ -13,6 +12,7 @@ import { NodeApi } from 'react-arborist';
1312
import { Overlayer } from '@/components';
1413
import { Doc, useMoveDoc } from '@/docs/doc-management';
1514
import { focusMainContentStart } from '@/layouts/utils';
15+
import { useResponsiveStore } from '@/stores/useResponsiveStore';
1616

1717
import { isDocNode, isWithinTreeItemActions } from '../utils';
1818

@@ -35,7 +35,7 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({
3535
rootNodeId,
3636
rootItemRef,
3737
}: DocTreeSubPagesProps) {
38-
const { isDesktop } = useResponsive();
38+
const { hasCoarsePointer } = useResponsiveStore();
3939
const treeContext = useTreeContext<Doc | null>();
4040
const { mutateAsync: moveDoc } = useMoveDoc();
4141
const { query } = useRouter();
@@ -97,21 +97,21 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({
9797
({ parentNode }: { parentNode: NodeApi<TreeDataItem<Doc>> | null }) => {
9898
const parentValue = parentNode?.data.value;
9999
if (!parentValue || !isDocNode(parentValue)) {
100-
return canMoveInto && isDesktop;
100+
return canMoveInto && !hasCoarsePointer;
101101
}
102-
return parentValue.abilities.move && isDesktop;
102+
return parentValue.abilities.move && !hasCoarsePointer;
103103
},
104-
[canMoveInto, isDesktop],
104+
[canMoveInto, hasCoarsePointer],
105105
);
106106

107107
const canDrag = useCallback(
108108
(node: TreeDataItem<Doc>) => {
109109
if (!isDocNode(node.value)) {
110110
return false;
111111
}
112-
return node.value.abilities.move && isDesktop;
112+
return node.value.abilities.move && !hasCoarsePointer;
113113
},
114-
[isDesktop],
114+
[hasCoarsePointer],
115115
);
116116

117117
const handleRowKeyDown = useCallback(

‎src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
} from '@dnd-kit/core';
99
import { getEventCoordinates } from '@dnd-kit/utilities';
1010
import { TreeViewMoveModeEnum, useModal } from '@gouvfr-lasuite/ui-components';
11-
import { useMediaQuery } from '@mantine/hooks';
1211
import dynamic from 'next/dynamic';
1312
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1413
import { createPortal } from 'react-dom';
@@ -17,6 +16,7 @@ import { css } from 'styled-components';
1716

1817
import { Card, Text } from '@/components';
1918
import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
19+
import { useResponsiveStore } from '@/stores/useResponsiveStore';
2020

2121
import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop';
2222

@@ -348,24 +348,18 @@ export const DraggableDocGridItem = ({
348348
);
349349
};
350350

351-
/**
352-
* Browser zoom shrinks the viewport below the mobile breakpoint on a desktop,
353-
* so dnD depends on the pointer, not on the width. `any-pointer` also covers a
354-
* mouse plugged into a touch-first computer, where the primary pointer is coarse.
355-
*/
356-
const FINE_POINTER_QUERY = '(any-pointer: fine)';
357-
358351
export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
359-
const hasFinePointer = useMediaQuery(FINE_POINTER_QUERY);
352+
const { hasCoarsePointer } = useResponsiveStore();
360353

361354
if (docs.length === 0) {
362355
return null;
363356
}
364357

365-
if (hasFinePointer) {
358+
if (!hasCoarsePointer) {
366359
return <DraggableDocGridContentList docs={docs} />;
367360
}
368361

362+
// Render a non-draggable list for touch-primary devices.
369363
return docs.map((doc) => (
370364
<DocsGridItem key={doc.id} dragMode={false} doc={doc} />
371365
));

‎src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocGridContentList.test.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Ref } from 'react';
33
import { afterEach, describe, expect, it, vi } from 'vitest';
44

55
import { Doc } from '@/docs/doc-management';
6+
import { useResponsiveStore } from '@/stores';
67
import { AppWrapper } from '@/tests/utils';
78

89
import { DocGridContentList } from '../DocGridContentList';
@@ -26,21 +27,24 @@ vi.mock('../DocsGridItem', () => ({
2627
),
2728
}));
2829

29-
const mockPointer = (isFine: boolean) => {
30+
const mockPointer = (isCoarse: boolean) => {
3031
Object.defineProperty(window, 'matchMedia', {
3132
configurable: true,
3233
writable: true,
3334
value: vi.fn().mockImplementation((query: string) => ({
3435
addEventListener: vi.fn(),
3536
addListener: vi.fn(),
3637
dispatchEvent: vi.fn(),
37-
matches: query === '(any-pointer: fine)' && isFine,
38+
matches: query === '(pointer: coarse)' && isCoarse,
3839
media: query,
3940
onchange: null,
4041
removeEventListener: vi.fn(),
4142
removeListener: vi.fn(),
4243
})),
4344
});
45+
46+
// AppWrapper does not mount the AppProvider that wires the store.
47+
return useResponsiveStore.getState().initializeResizeListener();
4448
};
4549

4650
const doc = {
@@ -50,12 +54,15 @@ const doc = {
5054
} as Doc;
5155

5256
describe('DocGridContentList', () => {
57+
let cleanup: (() => void) | undefined;
58+
5359
afterEach(() => {
60+
cleanup?.();
5461
vi.restoreAllMocks();
5562
});
5663

5764
it('lets a mouse drag a document whatever the viewport width', () => {
58-
mockPointer(true);
65+
cleanup = mockPointer(false);
5966

6067
render(<DocGridContentList docs={[doc]} />, { wrapper: AppWrapper });
6168

@@ -65,8 +72,8 @@ describe('DocGridContentList', () => {
6572
);
6673
});
6774

68-
it('does not make documents draggable without a fine pointer', () => {
69-
mockPointer(false);
75+
it('does not make documents draggable with a coarse pointer', () => {
76+
cleanup = mockPointer(true);
7077

7178
render(<DocGridContentList docs={[doc]} />, { wrapper: AppWrapper });
7279

‎src/frontend/apps/impress/src/stores/useResponsiveStore.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface UseResponsiveStore {
1919
screenWidth: number;
2020
setScreenSize: (size: ScreenSize) => void;
2121
isDesktop: boolean;
22+
/**
23+
* Indicates whether the primary input device has a coarse pointer (e.g., a touchscreen)
24+
* rather than a fine pointer (e.g., a mouse or trackpad).
25+
*/
26+
hasCoarsePointer: boolean;
2227
initializeResizeListener: () => () => void;
2328
}
2429

@@ -28,6 +33,7 @@ const initialState = {
2833
isSmallMobile: false,
2934
isTablet: false,
3035
isDesktop: false,
36+
hasCoarsePointer: false,
3137
screenSize: 'desktop' as ScreenSize,
3238
screenWidth: 0,
3339
};
@@ -38,6 +44,7 @@ export const useResponsiveStore = create<UseResponsiveStore>((set) => ({
3844
isMobile: initialState.isMobile,
3945
isSmallMobile: initialState.isSmallMobile,
4046
isTablet: initialState.isTablet,
47+
hasCoarsePointer: initialState.hasCoarsePointer,
4148
screenSize: initialState.screenSize,
4249
screenWidth: initialState.screenWidth,
4350
setScreenSize: (size: ScreenSize) => set(() => ({ screenSize: size })),
@@ -107,9 +114,18 @@ export const useResponsiveStore = create<UseResponsiveStore>((set) => ({
107114

108115
resizeHandler();
109116

117+
// Listen for changes to the primary input device's pointer type (coarse vs fine).
118+
const coarsePointerQuery = window.matchMedia('(pointer: coarse)');
119+
const pointerHandler = () => {
120+
set({ hasCoarsePointer: coarsePointerQuery.matches });
121+
};
122+
pointerHandler();
123+
coarsePointerQuery.addEventListener('change', pointerHandler);
124+
110125
return () => {
111126
clearTimeout(resizeTimeout);
112127
window.removeEventListener('resize', debouncedResizeHandler);
128+
coarsePointerQuery.removeEventListener('change', pointerHandler);
113129
};
114130
},
115131
}));

0 commit comments

Comments
 (0)