mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-19 14:31:49 +02:00
🐛(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:
@@ -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(
|
||||
|
||||
+4
-10
@@ -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} />
|
||||
));
|
||||
|
||||
+12
-5
@@ -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);
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user