🐛(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
This commit is contained in:
Anthony LC
2026-10-06 11:52:35 +02:00
parent 6e5dee38e9
commit 65ac9316eb
4 changed files with 39 additions and 22 deletions
@@ -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<Doc | null>();
const { mutateAsync: moveDoc } = useMoveDoc();
const { query } = useRouter();
@@ -70,11 +70,11 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({
({ parentNode }: { parentNode: NodeApi<TreeDataItem<Doc>> | 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(
@@ -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 <DraggableDocGridContentList docs={docs} />;
}
// Render a non-draggable list for touch-primary devices.
return docs.map((doc) => (
<DocsGridItem key={doc.id} dragMode={false} doc={doc} />
));
@@ -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(<DocGridContentList docs={[doc]} />, { 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(<DocGridContentList docs={[doc]} />, { wrapper: AppWrapper });
@@ -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<UseResponsiveStore>((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<UseResponsiveStore>((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);
};
},
}));