You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/02/11 07:43:37 UTC

(superset) branch refactor/explore-controls-dnd-kit created (now bf1b10a2d03)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a change to branch refactor/explore-controls-dnd-kit
in repository https://gitbox.apache.org/repos/asf/superset.git


      at bf1b10a2d03 refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit

This branch includes the following new commits:

     new bf1b10a2d03 refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit

Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch refactor/explore-controls-dnd-kit
in repository https://gitbox.apache.org/repos/asf/superset.git

commit bf1b10a2d03a6a4bf06ede13eff8bc429d11343a
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Tue Feb 10 23:43:21 2026 -0800

    refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit
    
    Migrate the Explore Controls drag-and-drop functionality from react-dnd
    to @dnd-kit for React 18 compatibility. This includes:
    
    - OptionWrapper: sortable column/metric options
    - OptionControlLabel: sortable metric labels
    - DndSelectLabel: SortableContext wrapper for animations
    - DatasourcePanelDragOption: datasource panel drag source
    - ExploreDndContext: new DndContext wrapper with drag handlers
    
    Also updates test helpers to support @dnd-kit via `useDndKit: true` option
    and updates all related test files.
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 superset-frontend/spec/helpers/testing-library.tsx |   7 +
 .../DatasourcePanelDragOption/index.tsx            |  41 +++-
 .../ExploreContainer/ExploreContainer.test.tsx     |  54 +----
 .../ExploreContainer/ExploreDndContext.tsx         | 255 +++++++++++++++++++++
 .../explore/components/ExploreContainer/index.tsx  |  80 ++-----
 .../components/controls/ContourControl/index.tsx   |   2 +
 .../DndColumnSelectControl/DndColumnSelect.tsx     |   5 +
 .../DndColumnSelectControl/DndFilterSelect.tsx     |   2 +
 .../DndColumnSelectControl/DndMetricSelect.tsx     |   5 +
 .../DndColumnSelectControl/DndSelectLabel.test.tsx |   9 +-
 .../DndColumnSelectControl/DndSelectLabel.tsx      |  84 +++++--
 .../DndColumnSelectControl/OptionWrapper.test.tsx  |  55 ++++-
 .../DndColumnSelectControl/OptionWrapper.tsx       |  90 +++-----
 .../OptionControls/OptionControls.test.tsx         |  21 +-
 .../components/controls/OptionControls/index.tsx   | 109 ++++-----
 15 files changed, 515 insertions(+), 304 deletions(-)

diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx
index 21abd6a44d0..a83590edbdd 100644
--- a/superset-frontend/spec/helpers/testing-library.tsx
+++ b/superset-frontend/spec/helpers/testing-library.tsx
@@ -37,6 +37,7 @@ import { BrowserRouter } from 'react-router-dom';
 import { Provider } from 'react-redux';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
+import { DndContext } from '@dnd-kit/core';
 import reducerIndex from 'spec/helpers/reducerIndex';
 import { QueryParamProvider } from 'use-query-params';
 import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -48,6 +49,7 @@ import { ExtensionsProvider } from 'src/extensions/ExtensionsContext';
 type Options = Omit<RenderOptions, 'queries'> & {
   useRedux?: boolean;
   useDnd?: boolean;
+  useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
   useQueryParams?: boolean;
   useRouter?: boolean;
   useTheme?: boolean;
@@ -75,6 +77,7 @@ export const defaultStore = createStore();
 export function createWrapper(options?: Options) {
   const {
     useDnd,
+    useDndKit,
     useRedux,
     useQueryParams,
     useRouter,
@@ -99,6 +102,10 @@ export function createWrapper(options?: Options) {
       );
     }
 
+    if (useDndKit) {
+      result = <DndContext>{result}</DndContext>;
+    }
+
     if (useDnd) {
       result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
     }
diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
index 7540b235513..22a1c2dc384 100644
--- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
+++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
@@ -16,8 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { RefObject } from 'react';
-import { useDrag } from 'react-dnd';
+import { RefObject, useMemo } from 'react';
+import { useDraggable } from '@dnd-kit/core';
 import { Metric } from '@superset-ui/core';
 import { css, styled, useTheme } from '@apache-superset/core/ui';
 import { ColumnMeta } from '@superset-ui/chart-controls';
@@ -30,8 +30,8 @@ import { Icons } from '@superset-ui/core/components/Icons';
 
 import { DatasourcePanelDndItem } from '../types';
 
-const DatasourceItemContainer = styled.div`
-  ${({ theme }) => css`
+const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
+  ${({ theme, isDragging }) => css`
     display: flex;
     align-items: center;
     justify-content: space-between;
@@ -44,6 +44,8 @@ const DatasourceItemContainer = styled.div`
     color: ${theme.colorText};
     background-color: ${theme.colorBgLayout};
     border-radius: 4px;
+    cursor: ${isDragging ? 'grabbing' : 'grab'};
+    opacity: ${isDragging ? 0.5 : 1};
 
     &:hover {
       background-color: ${theme.colorPrimaryBgHover};
@@ -70,14 +72,23 @@ export default function DatasourcePanelDragOption(
 ) {
   const { labelRef, showTooltip, type, value } = props;
   const theme = useTheme();
-  const [{ isDragging }, drag] = useDrag({
-    item: {
-      value: props.value,
-      type: props.type,
+
+  // Create a unique ID for this draggable item
+  const draggableId = useMemo(() => {
+    if (type === DndItemType.Column) {
+      const col = value as ColumnMeta;
+      return `datasource-${type}-${col.column_name || col.verbose_name}`;
+    }
+    const metric = value as MetricOption;
+    return `datasource-${type}-${metric.metric_name || metric.label}`;
+  }, [type, value]);
+
+  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
+    id: draggableId,
+    data: {
+      type,
+      value,
     },
-    collect: monitor => ({
-      isDragging: monitor.isDragging(),
-    }),
   });
 
   const optionProps = {
@@ -87,7 +98,13 @@ export default function DatasourcePanelDragOption(
   };
 
   return (
-    <DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
+    <DatasourceItemContainer
+      data-test="DatasourcePanelDragOption"
+      ref={setNodeRef}
+      isDragging={isDragging}
+      {...attributes}
+      {...listeners}
+    >
       {type === DndItemType.Column ? (
         <StyledColumnOption column={value as ColumnMeta} {...optionProps} />
       ) : (
diff --git a/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx
index c8bf92caa41..c42d18a1d7c 100644
--- a/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx
@@ -18,12 +18,8 @@
  */
 import { useContext } from 'react';
 import { fireEvent, render } from 'spec/helpers/testing-library';
-import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
 
 import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
-import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
-import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
-import { DndItemType } from '../DndItemType';
 
 const MockChildren = () => {
   const dragging = useContext(DraggingContext);
@@ -57,58 +53,21 @@ test('should render children', () => {
     <ExploreContainer>
       <MockChildren />
     </ExploreContainer>,
-    { useRedux: true, useDnd: true },
+    { useRedux: true },
   );
   expect(getByTestId('mock-children')).toBeInTheDocument();
   expect(getByText('not dragging')).toBeInTheDocument();
 });
 
-test('should only propagate dragging state when dragging the panel option', () => {
-  const defaultProps = {
-    label: <span>Test label</span>,
-    tooltipTitle: 'This is a tooltip title',
-    onRemove: jest.fn(),
-    onMoveLabel: jest.fn(),
-    onDropLabel: jest.fn(),
-    type: 'test',
-    index: 0,
-  };
+test('should initially have dragging set to false', () => {
   const { container, getByText } = render(
     <ExploreContainer>
-      <DatasourcePanelDragOption
-        value={{ metric_name: 'panel option', uuid: '1' }}
-        type={DndItemType.Metric}
-      />
-      <OptionControlLabel
-        {...defaultProps}
-        index={1}
-        label={<span>Metric item</span>}
-      />
-      <OptionWrapper
-        {...defaultProps}
-        index={2}
-        label="Column item"
-        clickClose={() => {}}
-        onShiftOptions={() => {}}
-      />
       <MockChildren />
     </ExploreContainer>,
-    {
-      useRedux: true,
-      useDnd: true,
-    },
+    { useRedux: true },
   );
   expect(container.getElementsByClassName('dragging')).toHaveLength(0);
-  fireEvent.dragStart(getByText('panel option'));
-  expect(container.getElementsByClassName('dragging')).toHaveLength(1);
-  fireEvent.dragEnd(getByText('panel option'));
-  fireEvent.dragStart(getByText('Metric item'));
-  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
-  fireEvent.dragEnd(getByText('Metric item'));
-  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
-  // don't show dragging state for the sorting item
-  fireEvent.dragStart(getByText('Column item'));
-  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
+  expect(getByText('not dragging')).toBeInTheDocument();
 });
 
 test('should manage the dropValidators', () => {
@@ -116,10 +75,7 @@ test('should manage the dropValidators', () => {
     <ExploreContainer>
       <MockChildren2 />
     </ExploreContainer>,
-    {
-      useRedux: true,
-      useDnd: true,
-    },
+    { useRedux: true },
   );
 
   expect(queryByText('test_item_1')).not.toBeInTheDocument();
diff --git a/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
new file mode 100644
index 00000000000..7be0db68f5a
--- /dev/null
+++ b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
@@ -0,0 +1,255 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  FC,
+  Dispatch,
+  useReducer,
+} from 'react';
+import {
+  DndContext,
+  useSensor,
+  useSensors,
+  PointerSensor,
+  DragStartEvent,
+  DragEndEvent,
+  UniqueIdentifier,
+} from '@dnd-kit/core';
+import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
+
+/**
+ * Type for the active drag item data
+ */
+export interface ActiveDragData {
+  type: string;
+  value?: unknown;
+  dragIndex?: number;
+  // For sortable items - callback to handle reorder
+  onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
+  onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
+  onDropLabel?: () => void;
+}
+
+/**
+ * Context to track if something is being dragged (for visual feedback)
+ */
+export const DraggingContext = createContext(false);
+
+/**
+ * Context to access the currently active drag item
+ */
+export const ActiveDragContext = createContext<ActiveDragData | null>(null);
+
+/**
+ * Dropzone validation - used by controls to register what they can accept
+ */
+type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
+type DropzoneSet = Record<string, CanDropValidator>;
+type Action = { key: string; canDrop?: CanDropValidator };
+
+export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
+  {},
+  () => {},
+]);
+
+const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
+  if (action.canDrop) {
+    return {
+      ...state,
+      [action.key]: action.canDrop,
+    };
+  }
+  if (action.key) {
+    const newState = { ...state };
+    delete newState[action.key];
+    return newState;
+  }
+  return state;
+};
+
+/**
+ * Context for handling drag end events - controls register their onDrop handlers
+ */
+type DropHandler = (
+  activeId: UniqueIdentifier,
+  overId: UniqueIdentifier,
+  activeData: ActiveDragData,
+) => void;
+type DropHandlerSet = Record<string, DropHandler>;
+
+export const DropHandlersContext = createContext<{
+  register: (id: string, handler: DropHandler) => void;
+  unregister: (id: string) => void;
+}>({
+  register: () => {},
+  unregister: () => {},
+});
+
+interface ExploreDndContextProps {
+  children: React.ReactNode;
+}
+
+/**
+ * DnD context provider for the Explore view.
+ * Wraps @dnd-kit/core's DndContext and provides:
+ * - Dragging state tracking (for visual feedback)
+ * - Dropzone registration (for validation)
+ * - Drop handler registration (for handling drops)
+ */
+export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
+  children,
+}) => {
+  const [isDragging, setIsDragging] = useState(false);
+  const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
+  const [dropHandlers, setDropHandlers] = useState<DropHandlerSet>({});
+
+  const dropzoneValue = useReducer(dropzoneReducer, {});
+
+  // Configure sensors for drag detection
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 5, // 5px movement required before drag starts
+      },
+    }),
+  );
+
+  const handleDragStart = useCallback((event: DragStartEvent) => {
+    const { active } = event;
+    const data = active.data.current as ActiveDragData | undefined;
+
+    // Don't set dragging state for reordering within a list
+    if (data && 'dragIndex' in data) {
+      return;
+    }
+
+    setIsDragging(true);
+    setActiveData(data || null);
+  }, []);
+
+  const handleDragEnd = useCallback(
+    (event: DragEndEvent) => {
+      const { active, over } = event;
+
+      setIsDragging(false);
+      setActiveData(null);
+
+      if (over && active.id !== over.id) {
+        const activeDataCurrent = active.data.current as
+          | ActiveDragData
+          | undefined;
+        const overDataCurrent = over.data.current as ActiveDragData | undefined;
+
+        // Check if this is a sortable reorder operation
+        // Both items need dragIndex and the same type
+        if (
+          activeDataCurrent &&
+          overDataCurrent &&
+          typeof activeDataCurrent.dragIndex === 'number' &&
+          typeof overDataCurrent.dragIndex === 'number' &&
+          activeDataCurrent.type === overDataCurrent.type
+        ) {
+          const { dragIndex } = activeDataCurrent;
+          const hoverIndex = overDataCurrent.dragIndex;
+
+          // Call the appropriate reorder callback
+          const reorderCallback =
+            activeDataCurrent.onShiftOptions || activeDataCurrent.onMoveLabel;
+          if (reorderCallback) {
+            reorderCallback(dragIndex, hoverIndex);
+          }
+
+          // Call onDropLabel if provided (for finalization after reorder)
+          activeDataCurrent.onDropLabel?.();
+          return;
+        }
+
+        // Handle external drop (from DatasourcePanel to dropzone)
+        const overId = String(over.id);
+        const handler = dropHandlers[overId];
+
+        if (handler && activeDataCurrent) {
+          handler(active.id, over.id, activeDataCurrent);
+        }
+      }
+    },
+    [dropHandlers],
+  );
+
+  const handleDragCancel = useCallback(() => {
+    setIsDragging(false);
+    setActiveData(null);
+  }, []);
+
+  const registerDropHandler = useCallback(
+    (id: string, handler: DropHandler) => {
+      setDropHandlers(prev => ({ ...prev, [id]: handler }));
+    },
+    [],
+  );
+
+  const unregisterDropHandler = useCallback((id: string) => {
+    setDropHandlers(prev => {
+      const newHandlers = { ...prev };
+      delete newHandlers[id];
+      return newHandlers;
+    });
+  }, []);
+
+  const dropHandlersContextValue = useMemo(
+    () => ({
+      register: registerDropHandler,
+      unregister: unregisterDropHandler,
+    }),
+    [registerDropHandler, unregisterDropHandler],
+  );
+
+  return (
+    <DndContext
+      sensors={sensors}
+      onDragStart={handleDragStart}
+      onDragEnd={handleDragEnd}
+      onDragCancel={handleDragCancel}
+    >
+      <DropzoneContext.Provider value={dropzoneValue}>
+        <DropHandlersContext.Provider value={dropHandlersContextValue}>
+          <DraggingContext.Provider value={isDragging}>
+            <ActiveDragContext.Provider value={activeData}>
+              {children}
+            </ActiveDragContext.Provider>
+          </DraggingContext.Provider>
+        </DropHandlersContext.Provider>
+      </DropzoneContext.Provider>
+    </DndContext>
+  );
+};
+
+/**
+ * Hook to check if something is currently being dragged
+ */
+export const useIsDragging = () => useContext(DraggingContext);
+
+/**
+ * Hook to get the active drag data
+ */
+export const useActiveDrag = () => useContext(ActiveDragContext);
diff --git a/superset-frontend/src/explore/components/ExploreContainer/index.tsx b/superset-frontend/src/explore/components/ExploreContainer/index.tsx
index 5cf714ea740..69f37b9532e 100644
--- a/superset-frontend/src/explore/components/ExploreContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreContainer/index.tsx
@@ -16,28 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import {
-  createContext,
-  useEffect,
-  useState,
-  Dispatch,
-  FC,
-  useReducer,
-} from 'react';
-
+import { FC } from 'react';
 import { styled } from '@apache-superset/core/ui';
-import { useDragDropManager } from 'react-dnd';
-import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
+import {
+  ExploreDndContextProvider,
+  DraggingContext,
+  DropzoneContext,
+} from './ExploreDndContext';
 
-type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
-type DropzoneSet = Record<string, CanDropValidator>;
-type Action = { key: string; canDrop?: CanDropValidator };
+// Re-export contexts for backward compatibility
+export { DraggingContext, DropzoneContext };
 
-export const DraggingContext = createContext(false);
-export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
-  {},
-  () => {},
-]);
 const StyledDiv = styled.div`
   display: flex;
   flex-direction: column;
@@ -45,53 +34,10 @@ const StyledDiv = styled.div`
   min-height: 0;
 `;
 
-const reducer = (state: DropzoneSet = {}, action: Action) => {
-  if (action.canDrop) {
-    return {
-      ...state,
-      [action.key]: action.canDrop,
-    };
-  }
-  if (action.key) {
-    const newState = { ...state };
-    delete newState[action.key];
-    return newState;
-  }
-  return state;
-};
-
-const ExploreContainer: FC<{}> = ({ children }) => {
-  const dragDropManager = useDragDropManager();
-  const [dragging, setDragging] = useState(
-    dragDropManager.getMonitor().isDragging(),
-  );
-
-  useEffect(() => {
-    const monitor = dragDropManager.getMonitor();
-    const unsub = monitor.subscribeToStateChange(() => {
-      const item = monitor.getItem() || {};
-      // don't show dragging state for the sorting item
-      if ('dragIndex' in item) {
-        return;
-      }
-      const isDragging = monitor.isDragging();
-      setDragging(isDragging);
-    });
-
-    return () => {
-      unsub();
-    };
-  }, [dragDropManager]);
-
-  const dropzoneValue = useReducer(reducer, {});
-
-  return (
-    <DropzoneContext.Provider value={dropzoneValue}>
-      <DraggingContext.Provider value={dragging}>
-        <StyledDiv>{children}</StyledDiv>
-      </DraggingContext.Provider>
-    </DropzoneContext.Provider>
-  );
-};
+const ExploreContainer: FC<{}> = ({ children }) => (
+  <ExploreDndContextProvider>
+    <StyledDiv>{children}</StyledDiv>
+  </ExploreDndContextProvider>
+);
 
 export default ExploreContainer;
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/index.tsx b/superset-frontend/src/explore/components/controls/ContourControl/index.tsx
index c8148071c91..16f28b3f8b4 100644
--- a/superset-frontend/src/explore/components/controls/ContourControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/ContourControl/index.tsx
@@ -127,6 +127,8 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
         accept={[]}
         ghostButtonText={ghostButtonText}
         onClickGhostButton={handleClickGhostButton}
+        sortableType="ContourOption"
+        itemCount={contours.length}
         {...props}
       />
       <ContourPopoverTrigger
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
index 6f560d1ff19..4fc6b4c2beb 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
@@ -185,6 +185,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
     [ghostButtonText, multi],
   );
 
+  // Generate sortable type that matches OptionWrapper's type
+  const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
+
   return (
     <div>
       <DndSelectLabel
@@ -195,6 +198,8 @@ function DndColumnSelect(props: DndColumnSelectProps) {
         displayGhostButton={multi || optionSelector.values.length === 0}
         ghostButtonText={labelGhostButtonText}
         onClickGhostButton={openPopover}
+        sortableType={sortableType}
+        itemCount={optionSelector.values.length}
         {...props}
       />
       <ColumnSelectPopoverTrigger
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx
index 9b1a9a371df..b3447ffd817 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx
@@ -452,6 +452,8 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
         accept={DND_ACCEPTED_TYPES}
         ghostButtonText={t('Drop columns/metrics here or click')}
         onClickGhostButton={handleClickGhostButton}
+        sortableType={DndItemType.FilterOption}
+        itemCount={values.length}
         {...props}
       />
       <AdhocFilterPopoverTrigger
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
index aefdc781cdf..8d0db502482 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
@@ -377,6 +377,9 @@ const DndMetricSelect = (props: any) => {
     multi ? 2 : 1,
   );
 
+  // Generate sortable type that matches MetricDefinitionValue's type
+  const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
+
   return (
     <div className="metrics-select">
       <DndSelectLabel
@@ -387,6 +390,8 @@ const DndMetricSelect = (props: any) => {
         ghostButtonText={ghostButtonText}
         displayGhostButton={multi || value.length === 0}
         onClickGhostButton={handleClickGhostButton}
+        sortableType={sortableType}
+        itemCount={value.length}
         {...props}
       />
       <AdhocMetricPopoverTrigger
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
index 5df594ccb71..515c3e9ea33 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
@@ -52,7 +52,7 @@ const MockChildren = () => {
 };
 
 test('renders with default props', () => {
-  render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
+  render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
   expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
 });
 
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
   const ghostButtonText = 'Ghost button text';
   render(
     <DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
-    { useDnd: true },
+    { useDndKit: true },
   );
   expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
 });
@@ -69,13 +69,13 @@ test('renders values', () => {
   const values = 'Values';
   const valuesRenderer = () => <span>{values}</span>;
   render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
-    useDnd: true,
+    useDndKit: true,
   });
   expect(screen.getByText(values)).toBeInTheDocument();
 });
 
 test('Handles ghost button click', () => {
-  render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
+  render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
   userEvent.click(screen.getByText('Drop columns here or click'));
   expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
 });
@@ -86,7 +86,6 @@ test('updates dropValidator on changes', () => {
       <DndSelectLabel {...defaultProps} />
       <MockChildren />
     </ExploreContainer>,
-    { useDnd: true },
   );
   expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
     'false',
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
index 153d97d6384..fcdd4f227b1 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
@@ -17,7 +17,11 @@
  * under the License.
  */
 import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
-import { useDrop } from 'react-dnd';
+import { useDroppable } from '@dnd-kit/core';
+import {
+  SortableContext,
+  verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
 import { t } from '@apache-superset/core';
 import ControlHeader from 'src/explore/components/ControlHeader';
 import {
@@ -45,6 +49,9 @@ export type DndSelectLabelProps = {
   displayGhostButton?: boolean;
   onClickGhostButton: () => void;
   isLoading?: boolean;
+  // For sortable items - the type string and count to generate sortable IDs
+  sortableType?: string;
+  itemCount?: number;
 };
 
 export default function DndSelectLabel({
@@ -52,34 +59,45 @@ export default function DndSelectLabel({
   accept,
   valuesRenderer,
   isLoading,
+  sortableType,
+  itemCount = 0,
   ...props
 }: DndSelectLabelProps) {
   const canDropProp = props.canDrop;
   const canDropValueProp = props.canDropValue;
 
+  const acceptTypes = useMemo(
+    () => (Array.isArray(accept) ? accept : [accept]),
+    [accept],
+  );
+
   const dropValidator = useCallback(
     (item: DatasourcePanelDndItem) =>
       canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
     [canDropProp, canDropValueProp],
   );
 
-  const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
-    accept: isLoading ? [] : accept,
-
-    drop: (item: DatasourcePanelDndItem) => {
-      props.onDrop(item);
-      props.onDropValue?.(item.value);
+  const { setNodeRef, isOver, active } = useDroppable({
+    id: `dropzone-${props.name}`,
+    disabled: isLoading,
+    data: {
+      accept: acceptTypes,
+      onDrop: props.onDrop,
+      onDropValue: props.onDropValue,
     },
-
-    canDrop: dropValidator,
-
-    collect: monitor => ({
-      isOver: monitor.isOver(),
-      canDrop: monitor.canDrop(),
-      type: monitor.getItemType(),
-    }),
   });
 
+  // Check if the active dragged item can be dropped here
+  const canDrop = useMemo(() => {
+    if (!active?.data.current) return false;
+    const activeData = active.data.current as { type: string; value: unknown };
+    if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
+    return dropValidator({
+      type: activeData.type as DndItemType,
+      value: activeData.value as DndItemValue,
+    });
+  }, [active, acceptTypes, dropValidator]);
+
   const dispatch = useContext(DropzoneContext)[1];
 
   useEffect(() => {
@@ -93,6 +111,15 @@ export default function DndSelectLabel({
 
   const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
 
+  // Generate sortable item IDs for SortableContext
+  const sortableItemIds = useMemo(() => {
+    if (!sortableType || itemCount === 0) return [];
+    return Array.from(
+      { length: itemCount },
+      (_, i) => `sortable-${sortableType}-${i}`,
+    );
+  }, [sortableType, itemCount]);
+
   function renderGhostButton() {
     return (
       <AddControlLabel
@@ -105,8 +132,31 @@ export default function DndSelectLabel({
     );
   }
 
+  // Handle drop events from dnd-kit
+  useEffect(() => {
+    if (isOver && active?.data.current && canDrop) {
+      // The actual drop is handled in ExploreDndContext's onDragEnd
+      // This effect is for any side effects needed during hover
+    }
+  }, [isOver, active, canDrop]);
+
+  // Wrap values in SortableContext if sortable
+  const renderSortableValues = () => {
+    if (sortableItemIds.length > 0) {
+      return (
+        <SortableContext
+          items={sortableItemIds}
+          strategy={verticalListSortingStrategy}
+        >
+          {values}
+        </SortableContext>
+      );
+    }
+    return values;
+  };
+
   return (
-    <div ref={datasourcePanelDrop}>
+    <div ref={setNodeRef}>
       <HeaderContainer>
         <ControlHeader {...props} />
       </HeaderContainer>
@@ -117,7 +167,7 @@ export default function DndSelectLabel({
         isDragging={isDragging}
         isLoading={isLoading}
       >
-        {values}
+        {renderSortableValues()}
         {displayGhostButton && renderGhostButton()}
       </DndLabelsContainer>
     </div>
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx
index 3d1a5a9d785..519cf3e5741 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render, screen, fireEvent } from 'spec/helpers/testing-library';
+import { render, screen } from 'spec/helpers/testing-library';
 import { DndItemType } from 'src/explore/components/DndItemType';
 import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
 
@@ -29,35 +29,66 @@ test('renders with default props', async () => {
       onShiftOptions={jest.fn()}
       label="Option"
     />,
-    { useDnd: true },
+    { useDndKit: true },
   );
   expect(container).toBeInTheDocument();
   expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
 });
 
-test('triggers onShiftOptions on drop', async () => {
-  const onShiftOptions = jest.fn();
+test('renders label correctly', async () => {
+  render(
+    <OptionWrapper
+      index={1}
+      clickClose={jest.fn()}
+      type={'Column' as DndItemType}
+      onShiftOptions={jest.fn()}
+      label="Test Label"
+    />,
+    { useDndKit: true },
+  );
+
+  expect(await screen.findByText('Test Label')).toBeInTheDocument();
+});
+
+test('renders multiple options', async () => {
   render(
     <>
       <OptionWrapper
-        index={1}
+        index={0}
         clickClose={jest.fn()}
         type={'Column' as DndItemType}
-        onShiftOptions={onShiftOptions}
+        onShiftOptions={jest.fn()}
         label="Option 1"
       />
       <OptionWrapper
-        index={2}
+        index={1}
         clickClose={jest.fn()}
         type={'Column' as DndItemType}
-        onShiftOptions={onShiftOptions}
+        onShiftOptions={jest.fn()}
         label="Option 2"
       />
     </>,
-    { useDnd: true },
+    { useDndKit: true },
+  );
+
+  expect(await screen.findByText('Option 1')).toBeInTheDocument();
+  expect(await screen.findByText('Option 2')).toBeInTheDocument();
+});
+
+test('calls clickClose when close button is clicked', async () => {
+  const clickClose = jest.fn();
+  render(
+    <OptionWrapper
+      index={1}
+      clickClose={clickClose}
+      type={'Column' as DndItemType}
+      onShiftOptions={jest.fn()}
+      label="Option"
+    />,
+    { useDndKit: true },
   );
 
-  fireEvent.dragStart(await screen.findByText('Option 1'));
-  fireEvent.drop(await screen.findByText('Option 2'));
-  expect(onShiftOptions).toHaveBeenCalled();
+  const closeButton = await screen.findByRole('img', { name: 'close' });
+  closeButton.click();
+  expect(clickClose).toHaveBeenCalledWith(1);
 });
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
index a34046f9d5e..fd06b2105d7 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
@@ -16,13 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useRef } from 'react';
-import {
-  useDrag,
-  useDrop,
-  DropTargetMonitor,
-  DragSourceMonitor,
-} from 'react-dnd';
+import { useRef, useMemo } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import { DragContainer } from 'src/explore/components/controls/OptionControls';
 import {
   OptionProps,
@@ -64,62 +60,32 @@ export default function OptionWrapper(
     multiValueWarningMessage,
     ...rest
   } = props;
-  const ref = useRef<HTMLDivElement>(null);
   const labelRef = useRef<HTMLDivElement>(null);
 
-  const [{ isDragging }, drag] = useDrag({
-    item: {
+  // Create a unique sortable ID for this item
+  const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
+
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id: sortableId,
+    data: {
       type,
       dragIndex: index,
-    },
-    collect: (monitor: DragSourceMonitor) => ({
-      isDragging: monitor.isDragging(),
-    }),
+      onShiftOptions,
+    } as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
   });
 
-  const [, drop] = useDrop({
-    accept: type,
-
-    hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
-      if (!ref.current) {
-        return;
-      }
-      const { dragIndex } = item;
-      const hoverIndex = index;
-
-      // Don't replace items with themselves
-      if (dragIndex === hoverIndex) {
-        return;
-      }
-      // Determine rectangle on screen
-      const hoverBoundingRect = ref.current?.getBoundingClientRect();
-      // Get vertical middle
-      const hoverMiddleY =
-        (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
-      // Determine mouse position
-      const clientOffset = monitor.getClientOffset();
-      // Get pixels to the top
-      const hoverClientY = clientOffset
-        ? clientOffset.y - hoverBoundingRect.top
-        : 0;
-      // Only perform the move when the mouse has crossed half of the items height
-      // When dragging downwards, only move when the cursor is below 50%
-      // When dragging upwards, only move when the cursor is above 50%
-      // Dragging downwards
-      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
-        return;
-      }
-      // Dragging upwards
-      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
-        return;
-      }
-
-      // Time to actually perform the action
-      onShiftOptions(dragIndex, hoverIndex);
-      // eslint-disable-next-line no-param-reassign
-      item.dragIndex = hoverIndex;
-    },
-  });
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  };
 
   const shouldShowTooltip =
     (!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
@@ -179,10 +145,14 @@ export default function OptionWrapper(
     return null;
   };
 
-  drag(drop(ref));
-
   return (
-    <DragContainer ref={ref} {...rest}>
+    <DragContainer
+      ref={setNodeRef}
+      style={style}
+      {...attributes}
+      {...listeners}
+      {...rest}
+    >
       <Option
         index={index}
         clickClose={clickClose}
diff --git a/superset-frontend/src/explore/components/controls/OptionControls/OptionControls.test.tsx b/superset-frontend/src/explore/components/controls/OptionControls/OptionControls.test.tsx
index 2f42a8c9d5b..119409c3beb 100644
--- a/superset-frontend/src/explore/components/controls/OptionControls/OptionControls.test.tsx
+++ b/superset-frontend/src/explore/components/controls/OptionControls/OptionControls.test.tsx
@@ -16,12 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import {
-  render,
-  screen,
-  fireEvent,
-  waitFor,
-} from 'spec/helpers/testing-library';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
 import {
   OptionControlLabel,
   DragContainer,
@@ -48,7 +43,7 @@ const defaultProps = {
 
 const setup = (overrides?: Record<string, any>) =>
   render(<OptionControlLabel {...defaultProps} {...overrides} />, {
-    useDnd: true,
+    useDndKit: true,
   });
 
 test('should render', async () => {
@@ -88,7 +83,7 @@ test('should display a certification icon if saved metric is certified', async (
   );
 });
 
-test('triggers onMoveLabel on drop', async () => {
+test('renders multiple labels', async () => {
   render(
     <>
       <OptionControlLabel
@@ -101,15 +96,11 @@ test('triggers onMoveLabel on drop', async () => {
         index={2}
         label={<span>Label 2</span>}
       />
-      ,
     </>,
-    { useDnd: true },
+    { useDndKit: true },
   );
-  await waitFor(() => {
-    fireEvent.dragStart(screen.getByText('Label 1'));
-    fireEvent.drop(screen.getByText('Label 2'));
-    expect(defaultProps.onMoveLabel).toHaveBeenCalled();
-  });
+  expect(await screen.findByText('Label 1')).toBeInTheDocument();
+  expect(await screen.findByText('Label 2')).toBeInTheDocument();
 });
 
 test('renders DragContainer', () => {
diff --git a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
index 03c6a3d30a9..12ced26352b 100644
--- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
+++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
@@ -16,9 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useRef, ReactNode } from 'react';
-
-import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
+import { useRef, ReactNode, useMemo } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import { t } from '@apache-superset/core';
 import { styled, useTheme, css, keyframes } from '@apache-superset/core/ui';
 import { InfoTooltip, Icons, Tooltip } from '@superset-ui/core/components';
@@ -233,9 +233,12 @@ export const AddIconButton = styled.button`
   }
 `;
 
-interface DragItem {
-  dragIndex: number;
+export interface SortableItemData {
   type: string;
+  dragIndex: number;
+  onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
+  onDropLabel?: () => void;
+  value?: savedMetricType | AdhocMetric;
 }
 
 export const OptionControlLabel = ({
@@ -272,73 +275,37 @@ export const OptionControlLabel = ({
   multi?: boolean;
 }) => {
   const theme = useTheme();
-  const ref = useRef<HTMLDivElement>(null);
   const labelRef = useRef<HTMLDivElement>(null);
   const hasMetricName = savedMetric?.metric_name;
-  const [, drop] = useDrop({
-    accept: type,
-    drop() {
-      if (!multi) {
-        return;
-      }
-      onDropLabel?.();
-    },
-    hover(item: DragItem, monitor: DropTargetMonitor) {
-      if (!multi) {
-        return;
-      }
-      if (!ref.current) {
-        return;
-      }
-      const { dragIndex } = item;
-      const hoverIndex = index;
-      // Don't replace items with themselves
-      if (dragIndex === hoverIndex) {
-        return;
-      }
-      // Determine rectangle on screen
-      const hoverBoundingRect = ref.current?.getBoundingClientRect();
-      // Get vertical middle
-      const hoverMiddleY =
-        (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
-      // Determine mouse position
-      const clientOffset = monitor.getClientOffset();
-      // Get pixels to the top
-      const hoverClientY = clientOffset?.y
-        ? clientOffset?.y - hoverBoundingRect.top
-        : 0;
-      // Only perform the move when the mouse has crossed half of the items height
-      // When dragging downwards, only move when the cursor is below 50%
-      // When dragging upwards, only move when the cursor is above 50%
-      // Dragging downwards
-      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
-        return;
-      }
-      // Dragging upwards
-      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
-        return;
-      }
-      // Time to actually perform the action
-      onMoveLabel?.(dragIndex, hoverIndex);
-      // Note: we're mutating the monitor item here!
-      // Generally it's better to avoid mutations,
-      // but it's good here for the sake of performance
-      // to avoid expensive index searches.
-      // eslint-disable-next-line no-param-reassign
-      item.dragIndex = hoverIndex;
-    },
-  });
-  const [{ isDragging }, drag] = useDrag({
-    item: {
+
+  // Create a unique sortable ID for this item
+  const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
+
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id: sortableId,
+    disabled: !multi,
+    data: {
       type,
       dragIndex: index,
+      onMoveLabel,
+      onDropLabel,
       value: savedMetric?.metric_name ? savedMetric : adhocMetric,
-    },
-    collect: monitor => ({
-      isDragging: monitor.isDragging(),
-    }),
+    } as SortableItemData,
   });
 
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  };
+
   const getLabelContent = () => {
     const shouldShowTooltip =
       (!isDragging &&
@@ -423,6 +390,14 @@ export const OptionControlLabel = ({
     </OptionControlContainer>
   );
 
-  drag(drop(ref));
-  return <DragContainer ref={ref}>{getOptionControlContent()}</DragContainer>;
+  return (
+    <DragContainer
+      ref={setNodeRef}
+      style={style}
+      {...attributes}
+      {...listeners}
+    >
+      {getOptionControlContent()}
+    </DragContainer>
+  );
 };