Repository: vercel/next.js
Pull request: [devtools] proper draggable for header and footer only

Previously, the entire dialog was draggable, which conflicted with the default user select. Therefore, only the header and the footer were specified to be draggable, which does not include the children, e.g., a button inside the header.

### Before

https://github.com/user-attachments/assets/c5867462-5cdd-486e-b84c-e6bbd47a4c34

### After

https://github.com/user-attachments/assets/4906898a-5442-4526-b0ce-e31ac86c1322

Closes NEXT-4547

Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel-footer.tsx
patch: @@ -51,6 +51,15 @@ export const DEVTOOLS_PANEL_FOOTER_STYLES = css`
     align-items: center;
     border-top: 1px solid var(--color-gray-400);
     border-radius: 0 0 var(--rounded-xl) var(--rounded-xl);
+
+    /* For draggable */
+    cursor: move;
+    user-select: none;
+    & > * {
+      cursor: auto;
+      /* user-select: auto; follows the parent (parent none -> child none), so reset the direct child to text */
+      user-select: text;
+    }
   }
 
   [data-nextjs-devtools-panel-footer-tab-group] {

filename: packages/next/src/next-devtools/dev-overlay/components/devtools-panel/devtools-panel.tsx
patch: @@ -97,6 +97,7 @@ export function DevToolsPanel({
             devToolsPosition: p,
           })
         }}
+        dragHandleSelector="[data-nextjs-devtools-panel-header], [data-nextjs-devtools-panel-footer]"
       >
         <>
           <Dialog
@@ -257,6 +258,15 @@ export const DEVTOOLS_PANEL_STYLES = css`
     justify-content: space-between;
     align-items: center;
     border-bottom: 1px solid var(--color-gray-400);
+
+    /* For draggable */
+    cursor: move;
+    user-select: none;
+    & > * {
+      cursor: auto;
+      /* user-select: auto; follows the parent (parent none -> child none), so reset the direct child to text */
+      user-select: text;
+    }
   }
 
   [data-nextjs-devtools-panel-header-tab-group] {

filename: packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/draggable.tsx
patch: @@ -17,19 +17,22 @@ export function Draggable({
   position: currentCorner,
   setPosition: setCurrentCorner,
   onDragStart,
+  dragHandleSelector,
   ...props
 }: {
   children: React.ReactElement
   position: Corners
   padding: number
   setPosition: (position: Corners) => void
   onDragStart?: () => void
+  dragHandleSelector?: string
 }) {
   const { ref, animate, ...drag } = useDrag({
     threshold: 5,
     onDragStart,
     onDragEnd,
     onAnimationEnd,
+    dragHandleSelector,
   })
 
   function onDragEnd(translation: Point, velocity: Point) {
@@ -134,6 +137,7 @@ interface UseDragOptions {
   onDragEnd?: (translation: Point, velocity: Point) => void
   onAnimationEnd?: (corner: Corner) => void
   threshold: number // Minimum movement before drag starts
+  dragHandleSelector?: string
 }
 
 interface Velocity {
@@ -185,10 +189,31 @@ export function useDrag(options: UseDragOptions) {
     }
   }
 
+  function isValidDragHandle(target: EventTarget | null): boolean {
+    if (!options.dragHandleSelector || !ref.current || !target) {
+      return true // If no selector provided, entire element is draggable
+    }
+
+    const element = target as Element
+    if (!element.matches) {
+      return false
+    }
+
+    // Check if the target element directly matches the drag handle selector
+    // This excludes children elements, only allowing drag from the exact element
+    return element.matches(options.dragHandleSelector)
+  }
+
   function onPointerDown(e: React.PointerEvent) {
     if (e.button !== 0) {
       return // ignore right click
     }
+
+    // Check if the pointer down event is on a valid drag handle
+    if (!isValidDragHandle(e.target)) {
+      return
+    }
+
     origin.current = { x: e.clientX, y: e.clientY }
     state.current = 'press'
     window.addEventListener('pointermove', onPointerMove)