Browse Source

Drag n Drop (native) (#2585)

* dnd with pragmatic (atlassian)

* with native drag and drop

* fix expandable test

* fix test

* fix test

* rename to dndData and doc

* doc

* params to data

* source & target Data

* source & target Data

* naming

* fab's comments

* tests

* doc

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 6 days ago
parent
commit
c2eb910c05

File diff suppressed because it is too large
+ 433 - 200
frontend/taipy-gui/package-lock.json


+ 5 - 4
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -107,10 +107,11 @@ describe("Chat Component", () => {
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
     });
-    it("renders markdown by default", async () => {
-        render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} />);
-        const elt = document.querySelector(".taipy-chat .taipy-chat-received .MuiPaper-root");
-        await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
+    xit("renders markdown by default", async () => {
+        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} />);
+        await waitFor(() => getByText(searchMsg));
+        const elt = getByText(searchMsg);
+        expect(elt.parentElement).toHaveClass("taipy-chat-markdown");
     });
     it("can render pre", async () => {
         const { getByText } = render(

+ 1 - 1
frontend/taipy-gui/src/components/Taipy/Expandable.spec.tsx

@@ -24,7 +24,7 @@ describe("Expandable Component", () => {
     it("renders", async () => {
         const { getByText } = render(<Expandable title="foo">bar</Expandable>);
         const elt = getByText("foo");
-        expect(elt.tagName).toBe("DIV");
+        expect(elt.tagName).toBe("SPAN");
     });
     it("displays the right info for string", async () => {
         const { getByText } = render(

+ 6 - 6
frontend/taipy-gui/src/components/Taipy/Menu.tsx

@@ -39,8 +39,8 @@ const Menu = (props: MenuProps) => {
     const { label, onAction = "", lov, width, inactiveIds = emptyArray, active = true, expanded = false } = props;
     const [selectedValue, setSelectedValue] = useState<string>("");
     const [opened, setOpened] = useState(expanded);
-    useEffect(() => { 
-        setOpened(expanded); 
+    useEffect(() => {
+        setOpened(expanded);
     }, [expanded]);
     const dispatch = useDispatch();
     const theme = useTheme();
@@ -74,7 +74,7 @@ const Menu = (props: MenuProps) => {
         return selected;
     }, [props.selected, selectedValue]);
 
-    const [drawerSx, titleProps] = useMemo(() => {
+    const [drawerSx, slotTitleProps] = useMemo(() => {
         const drawerWidth = opened ? width : `calc(${theme.spacing(9)} + 1px)`;
         const titleWidth = opened ? `calc(${width} - ${theme.spacing(10)})` : undefined;
         return [
@@ -88,7 +88,7 @@ const Menu = (props: MenuProps) => {
                 },
                 transition: "width 0.3s",
             },
-            { ...baseTitleProps, width: titleWidth },
+            {title: { ...baseTitleProps, width: titleWidth }},
         ];
     }, [opened, width, theme]);
 
@@ -113,7 +113,7 @@ const Menu = (props: MenuProps) => {
                                     </Tooltip>
                                 }
                                 title={label}
-                                titleTypographyProps={titleProps}
+                                slotProps={slotTitleProps}
                             />
                         </ListItemAvatar>
                     </ListItemButton>
@@ -126,7 +126,7 @@ const Menu = (props: MenuProps) => {
                             clickHandler={clickHandler}
                             disabled={!active || inactiveIds.includes(elt.id)}
                             withAvatar={true}
-                            titleTypographyProps={titleProps}
+                            slotProps={slotTitleProps}
                         />
                     ))}
                 </List>

+ 112 - 11
frontend/taipy-gui/src/components/Taipy/Part.spec.tsx

@@ -12,37 +12,138 @@
  */
 
 import React from "react";
-import {render} from "@testing-library/react";
+import { render, fireEvent } from "@testing-library/react";
 import "@testing-library/jest-dom";
+import userEvent from "@testing-library/user-event";
 
-import Part from './Part';
+import Part from "./Part";
+import { INITIAL_STATE, TaipyState } from "../../context/taipyReducers";
+import { TaipyContext } from "../../context/taipyContext";
 
 describe("Part Component", () => {
     it("renders", async () => {
-        const {getByText} = render(<Part>bar</Part>);
+        const { getByText } = render(<Part>bar</Part>);
         const elt = getByText("bar");
         expect(elt.tagName).toBe("DIV");
-        expect(elt).toHaveClass("MuiBox-root")
-    })
+        expect(elt).toHaveClass("MuiBox-root");
+    });
     it("displays the right info for string", async () => {
-        const {getByText} = render(<Part className="taipy-part">bar</Part>);
+        const { getByText } = render(<Part className="taipy-part">bar</Part>);
         const elt = getByText("bar");
         expect(elt).toHaveClass("taipy-part");
-    })
+    });
     it("displays with width=70%", async () => {
         const { getByText } = render(<Part width="70%">bar</Part>);
         const elt = getByText("bar");
-        expect(elt).toHaveStyle('width: 70%');
+        expect(elt).toHaveStyle("width: 70%");
     });
     it("displays with width=500", async () => {
         const { getByText } = render(<Part width={500}>bar</Part>);
         const elt = getByText("bar");
-        expect(elt).toHaveStyle('width: 500px');
+        expect(elt).toHaveStyle("width: 500px");
     });
     it("renders an iframe", async () => {
-        const {getByText} = render(<Part className="taipy-part" page="http://taipy.io">bar</Part>);
+        const { getByText } = render(
+            <Part className="taipy-part" page="http://taipy.io">
+                bar
+            </Part>
+        );
         const elt = getByText("bar");
         expect(elt.parentElement?.firstElementChild?.tagName).toBe("DIV");
         expect(elt.parentElement?.firstElementChild?.firstElementChild?.tagName).toBe("IFRAME");
-    })
+    });
+    describe("Drag n Drop", () => {
+        it("is not draggable if not dragType", async () => {
+            const { getByText } = render(<Part dragType="">bar</Part>);
+            const elt = getByText("bar");
+            expect(elt.draggable).toBe(false);
+        });
+        it("is draggable if dragType", async () => {
+            const { getByText } = render(<Part dragType="drag_type">bar</Part>);
+            const elt = getByText("bar");
+            expect(elt.draggable).toBe(true);
+        });
+        it("does not send a message if drag_type is not in drop_types", async () => {
+            const dispatch = jest.fn();
+            const state: TaipyState = INITIAL_STATE;
+            const { getByText } = render(
+                <TaipyContext.Provider value={{ state, dispatch }}>
+                    <Part dragType="drag_type">bar</Part>
+                    <Part allowedDragTypes={'"drop_type"'}>foo</Part>
+                </TaipyContext.Provider>
+            );
+            const sourceElt = getByText("bar");
+            const targetElt = getByText("foo");
+            fireEvent.drop(targetElt, {
+                dataTransfer: {
+                    getData: (type: string) =>
+                        type.endsWith("-done")
+                            ? undefined
+                            : JSON.stringify({
+                                  type: "drag_type",
+                                  itemId: "itemId",
+                                  varName: "varName",
+                                  sourceId: "sourceId",
+                                  dragData: { par: "par" },
+                              }),
+                    setData: jest.fn(),
+                },
+            });
+            fireEvent.dragEnd(sourceElt);
+            expect(dispatch).not.toHaveBeenCalled();
+        });
+        it("sends a message if drag_type is in drop_types", async () => {
+            const dispatch = jest.fn();
+            const state: TaipyState = INITIAL_STATE;
+            const { getByText } = render(
+                <TaipyContext.Provider value={{ state, dispatch }}>
+                    <Part dragType="drag_type">bar</Part>
+                    <Part
+                        allowedDragTypes={JSON.stringify(["drop_type", "drag_type"])}
+                        defaultDropData={JSON.stringify({ drop: "drop" })}
+                    >
+                        foo
+                    </Part>
+                </TaipyContext.Provider>
+            );
+            const sourceElt = getByText("bar");
+            const targetElt = getByText("foo");
+            fireEvent.dragStart(sourceElt, { dataTransfer: { setData: jest.fn() } });
+            fireEvent.dragOver(targetElt);
+            fireEvent.drop(targetElt, {
+                dataTransfer: {
+                    getData: (type: string) =>
+                        type.endsWith("-done")
+                            ? undefined
+                            : JSON.stringify({
+                                  type: "drag_type",
+                                  itemId: "itemId",
+                                  varName: "varName",
+                                  sourceId: "sourceId",
+                                  sourceData: { par: "par" },
+                              }),
+                    setData: jest.fn(),
+                },
+            });
+            fireEvent.dragEnd(sourceElt);
+            expect(dispatch).toHaveBeenCalledWith({
+                context: undefined,
+                name: "",
+                payload: {
+                    args: [],
+                    reason: "drop",
+                    source_id: "sourceId",
+                    source_item_id: "itemId",
+                    source_data: {
+                        par: "par",
+                    },
+                    source_var_name: "varName",
+                    target_id: undefined,
+                    target_item_id: undefined,
+                    target_data: { drop: "drop" },
+                },
+                type: "SEND_ACTION_ACTION",
+            });
+        });
+    });
 });

+ 74 - 7
frontend/taipy-gui/src/components/Taipy/Part.tsx

@@ -11,16 +11,18 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { ReactNode, useContext, useMemo } from "react";
+import React, { ReactNode, useCallback, useContext, useMemo, useRef } from "react";
 import Box from "@mui/material/Box";
 
-import { useClassNames, useDynamicProperty } from "../../utils/hooks";
+import { useClassNames, useDynamicJsonProperty, useDynamicProperty, useModule } from "../../utils/hooks";
 import TaipyRendered from "../pages/TaipyRendered";
 import { expandSx, getCssSize, TaipyBaseProps } from "./utils";
 import { TaipyContext } from "../../context/taipyContext";
 import { getComponentClassName } from "./TaipyStyle";
+import { DndProps, draggedSx, droppableSx, useDrag, useDrop } from "./dndUtils";
+import { createSendActionNameAction } from "../../context/taipyReducers";
 
-interface PartProps extends TaipyBaseProps {
+interface PartProps extends TaipyBaseProps, DndProps {
     render?: boolean;
     defaultRender?: boolean;
     page?: string;
@@ -39,8 +41,9 @@ const IframeStyle = {
 };
 
 const Part = (props: PartProps) => {
-    const { id, partial, defaultPartial } = props;
-    const { state } = useContext(TaipyContext);
+    const { id, partial, defaultPartial, dragType } = props;
+    const { state, dispatch } = useContext(TaipyContext);
+    const module = useModule();
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const render = useDynamicProperty(props.render, props.defaultRender, true);
@@ -57,9 +60,73 @@ const Part = (props: PartProps) => {
         return false;
     }, [state.locations, page, defaultPartial]);
 
-    const boxSx = useMemo(() => expandSx(height ? { height: height } : undefined, props.width ? {width: getCssSize(props.width)}: undefined), [height, props.width]);
+    const itemRef = useRef<HTMLDivElement>(null);
+
+    const dragData = useDynamicJsonProperty(
+        props.dragData,
+        props.defaultDragData || "",
+        undefined as Record<string, unknown> | undefined
+    );
+    const dropData = useDynamicJsonProperty(
+        props.dropData,
+        props.defaultDropData || "",
+        undefined as Record<string, unknown> | undefined
+    );
+    const dropTypes = useMemo(() => {
+        if (props.allowedDragTypes) {
+            try {
+                const drops = JSON.parse(props.allowedDragTypes);
+                if (Array.isArray(drops) && drops.length) {
+                    return drops as string[];
+                }
+                if (typeof drops === "string" && drops.length) {
+                    return [drops];
+                }
+            } catch (e) {
+                console.error("Error parsing dropTypes: ", e);
+            }
+        }
+        return undefined;
+    }, [props.allowedDragTypes]);
+    const dropHandler = useCallback(
+        (
+            sourceId?: string,
+            sourceItemId?: string,
+            sourceData?: Record<string, unknown>,
+            sourceVarName?: string,
+            targetItemId?: string
+        ) => {
+            dispatch(
+                createSendActionNameAction(props.onAction, module, {
+                    reason: "drop",
+                    source_id: sourceId,
+                    source_item_id: sourceItemId,
+                    source_data: sourceData,
+                    source_var_name: sourceVarName,
+                    target_id: id,
+                    target_item_id: targetItemId,
+                    target_data: dropData,
+                })
+            );
+        },
+        [props.onAction, dispatch, module, id, dropData]
+    );
+
+    const [isDragging] = useDrag(itemRef, dragType, dragData, undefined, undefined, id);
+    const [isDraggedOver] = useDrop(itemRef, dropTypes, undefined, dropHandler);
+
+    const boxSx = useMemo(
+        () =>
+            expandSx(
+                height ? { height: height } : undefined,
+                props.width ? { width: getCssSize(props.width) } : undefined,
+                isDragging ? draggedSx : undefined,
+                isDraggedOver ? droppableSx : undefined
+            ),
+        [height, props.width, isDragging, isDraggedOver]
+    );
     return render ? (
-        <Box id={id} className={`${className} ${getComponentClassName(props.children)}`} sx={boxSx}>
+        <Box id={id} className={`${className} ${getComponentClassName(props.children)}`} sx={boxSx} ref={itemRef}>
             {iFrame ? (
                 <iframe src={page} style={IframeStyle} />
             ) : page ? (

+ 122 - 18
frontend/taipy-gui/src/components/Taipy/Selector.spec.tsx

@@ -12,7 +12,7 @@
  */
 
 import React from "react";
-import { render } from "@testing-library/react";
+import { render, fireEvent } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 
@@ -28,6 +28,12 @@ const lov: LoV = [
     ["id3", "Item 3"],
     ["id4", "Item 4"],
 ];
+const lov2: LoV = [
+    ["id21", "Item 21"],
+    ["id22", "Item 22"],
+    ["id23", "Item 23"],
+    ["id24", "Item 24"],
+];
 const defLov = '[["id10","Default Item"]]';
 
 const imageItem: [string, stringIcon] = ["ii1", { path: "/img/fred.png", text: "Image" }];
@@ -74,18 +80,18 @@ describe("Selector Component", () => {
     });
     it("is disabled", async () => {
         const { getAllByRole } = render(<Selector lov={lov} active={false} />);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
+        const elements = getAllByRole("button");
+        elements.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
     });
     it("is enabled by default", async () => {
         const { getAllByRole } = render(<Selector lov={lov} />);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const elements = getAllByRole("button");
+        elements.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
     it("is enabled by active", async () => {
         const { getAllByRole } = render(<Selector lov={lov} active={true} />);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const elements = getAllByRole("button");
+        elements.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
     it("dispatch a well formed message", async () => {
         const dispatch = jest.fn();
@@ -215,7 +221,9 @@ describe("Selector Component", () => {
             expect(queryAllByRole("listbox")).toHaveLength(0);
         });
         it("renders selectionMessage if defined", async () => {
-            const { getByText, getByRole } = render(<Selector lov={lov} dropdown={true} selectionMessage="a selection message" />);
+            const { getByText, getByRole } = render(
+                <Selector lov={lov} dropdown={true} selectionMessage="a selection message" />
+            );
             const butElt = getByRole("combobox");
             expect(butElt).toBeInTheDocument();
             await userEvent.click(butElt);
@@ -226,7 +234,9 @@ describe("Selector Component", () => {
             expect(msg).toBeInTheDocument();
         });
         it("renders showSelectAll in dropdown if True", async () => {
-            const { getByText, getByRole } = render(<Selector lov={lov} dropdown={true} multiple={true} showSelectAll={true} />);
+            const { getByText, getByRole } = render(
+                <Selector lov={lov} dropdown={true} multiple={true} showSelectAll={true} />
+            );
             const checkElt = getByRole("checkbox");
             expect(checkElt).toBeInTheDocument();
             expect(checkElt).not.toBeChecked();
@@ -249,7 +259,7 @@ describe("Selector Component", () => {
             const elt = getByText("Item 2");
             await userEvent.click(elt);
             expect(checkElement?.parentElement).toHaveClass("MuiCheckbox-indeterminate");
-            checkElement && await userEvent.click(checkElement);
+            checkElement && (await userEvent.click(checkElement));
             expect(checkElement).toBeChecked();
         });
     });
@@ -334,7 +344,7 @@ describe("Selector Component", () => {
             const selector = getByRole("radiogroup");
             const style = window.getComputedStyle(selector);
             expect(style.maxHeight).toBe(height);
-          });
+        });
     });
 
     describe("Selector Component check mode", () => {
@@ -372,12 +382,106 @@ describe("Selector Component", () => {
             expect(elt2.parentElement?.querySelector("span.Mui-checked")).not.toBeNull();
             expect(elt3.parentElement?.querySelector("span.Mui-checked")).not.toBeNull();
         });
+        it("sets the correct height for the check mode", async () => {
+            const height = "200px";
+            const { container } = render(<Selector lov={lov} mode="check" height={height} />);
+            const selector = container.querySelector(".MuiFormGroup-root");
+            const style = window.getComputedStyle(selector!);
+            expect(style.maxHeight).toBe(height);
+        });
+    });
+    describe("Drag n Drop", () => {
+        it("is not draggable if not dragType", async () => {
+            const { getByText } = render(<Selector dragType="" lov={lov} />);
+            const elt = getByText("Item 1");
+            expect(elt.parentElement?.parentElement?.draggable).toBe(false);
+        });
+        it("is draggable if dragType", async () => {
+            const { getByText } = render(<Selector dragType="drag_type" lov={lov} />);
+            const elt = getByText("Item 1");
+            expect(elt.parentElement?.parentElement?.draggable).toBe(true);
+        });
+        it("does not send a message if drag_type is not in drop_types", async () => {
+            const dispatch = jest.fn();
+            const state: TaipyState = INITIAL_STATE;
+            const { getByText } = render(
+                <TaipyContext.Provider value={{ state, dispatch }}>
+                    <Selector dragType="drag_type" lov={lov} />
+                    <Selector allowedDragTypes={'"drop_type"'} lov={lov2} />
+                </TaipyContext.Provider>
+            );
+            const sourceElt = getByText("Item 1");
+            const targetElt = getByText("Item 21");
+            fireEvent.drop(targetElt, {
+                dataTransfer: {
+                    getData: (type: string) =>
+                        type.endsWith("-done")
+                            ? undefined
+                            : JSON.stringify({
+                                  type: "drag_type",
+                                  itemId: "itemId",
+                                  varName: "varName",
+                                  sourceId: "sourceId",
+                                  dragData: { par: "par" },
+                              }),
+                    setData: jest.fn(),
+                },
+            });
+            fireEvent.dragEnd(sourceElt);
+            expect(dispatch).not.toHaveBeenCalled();
+        });
+        it("sends a message if drag_type is in drop_types", async () => {
+            const dispatch = jest.fn();
+            const state: TaipyState = INITIAL_STATE;
+            const { getByText } = render(
+                <TaipyContext.Provider value={{ state, dispatch }}>
+                    <Selector dragType="drag_type" lov={lov} />
+                    <Selector
+                        lov={lov2}
+                        allowedDragTypes={JSON.stringify(["drop_type", "drag_type"])}
+                        defaultDropData={JSON.stringify({ drop: "drop" })}
+                    />
+                </TaipyContext.Provider>
+            );
+            const sourceElt = getByText("Item 1");
+            const targetElt = getByText("Item 21");
+            fireEvent.dragStart(sourceElt, { dataTransfer: { setData: jest.fn() } });
+            fireEvent.dragOver(targetElt);
+            fireEvent.drop(targetElt, {
+                dataTransfer: {
+                    getData: (type: string) =>
+                        type.endsWith("-done")
+                            ? undefined
+                            : JSON.stringify({
+                                  type: "drag_type",
+                                  itemId: "itemId",
+                                  varName: "varName",
+                                  sourceId: "sourceId",
+                                  sourceData: { par: "par" },
+                              }),
+                    setData: jest.fn(),
+                },
+            });
+            fireEvent.dragEnd(sourceElt);
+            expect(dispatch).toHaveBeenCalledWith({
+                context: undefined,
+                name: "",
+                payload: {
+                    args: [],
+                    reason: "drop",
+                    source_id: "sourceId",
+                    source_item_id: "itemId",
+                    source_data: {
+                        par: "par",
+                    },
+                    source_var_name: "varName",
+                    target_id: undefined,
+                    target_item_id: "id21",
+                    target_data: { drop: "drop" },
+                    target_var_name: "",
+                },
+                type: "SEND_ACTION_ACTION",
+            });
+        });
     });
-    it("sets the correct height for the check mode", async () => {
-        const height = "200px";
-        const { container } = render(<Selector lov={lov} mode="check" height={height} />);
-        const selector = container.querySelector(".MuiFormGroup-root");
-        const style = window.getComputedStyle(selector!);
-        expect(style.maxHeight).toBe(height);
-      });
 });

+ 139 - 47
frontend/taipy-gui/src/components/Taipy/Selector.tsx

@@ -12,15 +12,16 @@
  */
 
 import React, {
-    useState,
-    useCallback,
-    useEffect,
-    useMemo,
+    ChangeEvent,
     CSSProperties,
+    HTMLAttributes,
     MouseEvent,
-    ChangeEvent,
     SyntheticEvent,
-    HTMLAttributes,
+    useCallback,
+    useEffect,
+    useMemo,
+    useRef,
+    useState,
 } from "react";
 import Autocomplete from "@mui/material/Autocomplete";
 import Avatar from "@mui/material/Avatar";
@@ -47,40 +48,66 @@ import Select, { SelectChangeEvent } from "@mui/material/Select";
 import TextField from "@mui/material/TextField";
 import { Theme, useTheme } from "@mui/material";
 
-import { doNotPropagateEvent, getSuffixedClassNames, getUpdateVar } from "./utils";
-import { createSendUpdateAction } from "../../context/taipyReducers";
+import { doNotPropagateEvent, expandSx, getSuffixedClassNames, getUpdateVar } from "./utils";
+import { createSendActionNameAction, createSendUpdateAction } from "../../context/taipyReducers";
 import { ItemProps, LovImage, paperBaseSx, SelTreeProps, showItem, SingleItem, useLovListMemo } from "./lovUtils";
 import {
     useClassNames,
     useDispatch,
     useDispatchRequestUpdateOnFirstRender,
+    useDynamicJsonProperty,
     useDynamicProperty,
     useModule,
 } from "../../utils/hooks";
 import { Icon } from "../../utils/icon";
 import { LovItem } from "../../utils/lov";
 import { getComponentClassName } from "./TaipyStyle";
+import { draggedSx, droppableSx, useDrag, useDrop } from "./dndUtils";
 
-const MultipleItem = ({ value, clickHandler, selectedValue, item, disabled }: ItemProps) => (
-    <ListItemButton onClick={clickHandler} data-id={value} dense disabled={disabled}>
-        <ListItemIcon>
-            <Checkbox
-                disabled={disabled}
-                edge="start"
-                checked={selectedValue.includes(value)}
-                tabIndex={-1}
-                disableRipple
-            />
-        </ListItemIcon>
-        {typeof item === "string" ? (
-            <ListItemText primary={item} />
-        ) : (
-            <ListItemAvatar>
-                <LovImage item={item} />
-            </ListItemAvatar>
-        )}
-    </ListItemButton>
-);
+const MultipleItem = ({
+    value,
+    clickHandler,
+    selectedValue,
+    item,
+    disabled,
+    dragType,
+    dragVarName,
+    sourceId,
+    onDrop,
+    dropTypes,
+    draggedData: dragData,
+}: ItemProps) => {
+    const itemRef = useRef<HTMLDivElement>(null);
+    const [isDragging] = useDrag(itemRef, dragType, dragData, value, dragVarName, sourceId);
+    const [isDraggedOver] = useDrop(itemRef, dropTypes, value, onDrop);
+
+    return (
+        <ListItemButton
+            onClick={clickHandler}
+            data-id={value}
+            dense
+            disabled={disabled}
+            sx={isDragging ? draggedSx : isDraggedOver ? droppableSx : undefined}
+        >
+            <ListItemIcon>
+                <Checkbox
+                    disabled={disabled}
+                    edge="start"
+                    checked={selectedValue.includes(value)}
+                    tabIndex={-1}
+                    disableRipple
+                />
+            </ListItemIcon>
+            {typeof item === "string" ? (
+                <ListItemText primary={item} />
+            ) : (
+                <ListItemAvatar>
+                    <LovImage item={item} />
+                </ListItemAvatar>
+            )}
+        </ListItemButton>
+    );
+};
 
 const ITEM_HEIGHT = 48;
 const ITEM_PADDING_TOP = 8;
@@ -155,6 +182,7 @@ const Selector = (props: SelectorProps) => {
     const dispatch = useDispatch();
     const module = useModule();
     const theme = useTheme();
+    const listRef = useRef<HTMLUListElement>(null);
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
@@ -169,14 +197,66 @@ const Selector = (props: SelectorProps) => {
     const multiple = isCheck ? true : isRadio || props.multiple === undefined ? false : props.multiple;
 
     const lovList = useLovListMemo(lov, defaultLov);
+    const lovVarName = useMemo(() => getUpdateVar(updateVars, "lov"), [updateVars]);
+
+    const dragData = useDynamicJsonProperty(
+        props.dragData,
+        props.defaultDragData || "",
+        undefined as Record<string, unknown> | undefined
+    );
+    const dropData = useDynamicJsonProperty(
+        props.dropData,
+        props.defaultDropData || "",
+        undefined as Record<string, unknown> | undefined
+    );
+    const dropTypes = useMemo(() => {
+        if (props.allowedDragTypes) {
+            try {
+                const drops = JSON.parse(props.allowedDragTypes);
+                if (Array.isArray(drops) && drops.length) {
+                    return drops as string[];
+                }
+            } catch (e) {
+                console.error("Error parsing dropTypes: ", e);
+            }
+        }
+        return undefined;
+    }, [props.allowedDragTypes]);
+
+    const dropHandler = useCallback(
+        (
+            sourceId?: string,
+            sourceItemId?: string,
+            sourceData?: Record<string, unknown>,
+            sourceVarName?: string,
+            targetItemId?: string
+        ) => {
+            dispatch(
+                createSendActionNameAction(props.onAction, module, {
+                    reason: "drop",
+                    source_id: sourceId,
+                    source_item_id: sourceItemId,
+                    source_data: sourceData,
+                    source_var_name: sourceVarName,
+                    target_id: id,
+                    target_item_id: targetItemId,
+                    target_data: dropData,
+                    target_var_name: lovVarName,
+                })
+            );
+        },
+        [props.onAction, dispatch, module, id, lovVarName, dropData]
+    );
+
+    const [isDraggedOver] = useDrop(listRef, dropTypes, undefined, dropHandler);
+
     const listSx = useMemo(
-        () => ({
-            bgcolor: "transparent",
-            overflowY: "auto",
-            width: "100%",
-            maxWidth: width,
-        }),
-        [width]
+        () =>
+            expandSx(
+                { bgcolor: "transparent", overflowY: "auto", width: "100%", maxWidth: width },
+                isDraggedOver ? droppableSx : undefined
+            ),
+        [width, isDraggedOver]
     );
     const heightSx = useMemo(() => {
         if (!height) {
@@ -246,7 +326,7 @@ const Selector = (props: SelectorProps) => {
                             module,
                             props.onChange,
                             propagate,
-                            valueById ? undefined : getUpdateVar(updateVars, "lov")
+                            valueById ? undefined : lovVarName
                         )
                     );
                     return newKeys;
@@ -258,14 +338,14 @@ const Selector = (props: SelectorProps) => {
                             module,
                             props.onChange,
                             propagate,
-                            valueById ? undefined : getUpdateVar(updateVars, "lov")
+                            valueById ? undefined : lovVarName
                         )
                     );
                     return [key];
                 }
             });
         },
-        [updateVarName, dispatch, multiple, propagate, updateVars, valueById, props.onChange, module]
+        [updateVarName, dispatch, multiple, propagate, lovVarName, valueById, props.onChange, module]
     );
 
     const clickHandler = useCallback(
@@ -301,11 +381,11 @@ const Selector = (props: SelectorProps) => {
                     module,
                     props.onChange,
                     propagate,
-                    valueById ? undefined : getUpdateVar(updateVars, "lov")
+                    valueById ? undefined : lovVarName
                 )
             );
         },
-        [dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
+        [dispatch, updateVarName, propagate, lovVarName, valueById, props.onChange, module]
     );
 
     const handleCheckAllChange = useCallback(
@@ -319,11 +399,11 @@ const Selector = (props: SelectorProps) => {
                     module,
                     props.onChange,
                     propagate,
-                    valueById ? undefined : getUpdateVar(updateVars, "lov")
+                    valueById ? undefined : lovVarName
                 )
             );
         },
-        [lovList, dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
+        [lovList, dispatch, updateVarName, propagate, lovVarName, valueById, props.onChange, module]
     );
 
     const [autoValue, setAutoValue] = useState<LovItem | LovItem[] | null>(() => (multiple ? [] : null));
@@ -338,11 +418,11 @@ const Selector = (props: SelectorProps) => {
                     module,
                     props.onChange,
                     propagate,
-                    valueById ? undefined : getUpdateVar(updateVars, "lov")
+                    valueById ? undefined : lovVarName
                 )
             );
         },
-        [dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
+        [dispatch, updateVarName, propagate, lovVarName, valueById, props.onChange, module]
     );
 
     const handleDelete = useCallback(
@@ -358,13 +438,13 @@ const Selector = (props: SelectorProps) => {
                             module,
                             props.onChange,
                             propagate,
-                            valueById ? undefined : getUpdateVar(updateVars, "lov")
+                            valueById ? undefined : lovVarName
                         )
                     );
                     return keys;
                 });
         },
-        [updateVarName, propagate, dispatch, updateVars, valueById, props.onChange, module]
+        [updateVarName, propagate, dispatch, lovVarName, valueById, props.onChange, module]
     );
 
     const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setSearchValue(e.target.value), []);
@@ -602,7 +682,7 @@ const Selector = (props: SelectorProps) => {
                                     />
                                 </Box>
                             ) : null}
-                            <List sx={listSx} id={id}>
+                            <List sx={listSx} id={id} ref={listRef}>
                                 {lovList
                                     .filter((elt) => showItem(elt, searchValue))
                                     .map((elt) =>
@@ -614,6 +694,12 @@ const Selector = (props: SelectorProps) => {
                                                 selectedValue={selectedValue}
                                                 clickHandler={clickHandler}
                                                 disabled={!active}
+                                                dragType={props.dragType}
+                                                dropTypes={dropTypes}
+                                                dragVarName={lovVarName}
+                                                sourceId={props.id}
+                                                onDrop={dropHandler}
+                                                draggedData={dragData}
                                             />
                                         ) : (
                                             <SingleItem
@@ -623,6 +709,12 @@ const Selector = (props: SelectorProps) => {
                                                 selectedValue={selectedValue}
                                                 clickHandler={clickHandler}
                                                 disabled={!active}
+                                                dragType={props.dragType}
+                                                dropTypes={dropTypes}
+                                                dragVarName={lovVarName}
+                                                sourceId={props.id}
+                                                onDrop={dropHandler}
+                                                draggedData={dragData}
                                             />
                                         )
                                     )}

+ 126 - 0
frontend/taipy-gui/src/components/Taipy/dndUtils.ts

@@ -0,0 +1,126 @@
+import { useEffect, useState, RefObject } from "react";
+
+export interface dropHandlerInterface {
+    (
+        sourceId?: string,
+        draggedItemId?: string,
+        draggedData?: Record<string, unknown>,
+        sourceVarName?: string,
+        droppedItemId?: string
+    ): void;
+}
+
+export interface DndProps {
+    dragType?: string;
+    dragData?: string;
+    defaultDragData?: string;
+    dropData?: string;
+    defaultDropData?: string;
+    allowedDragTypes?: string;
+    onAction?: string;
+}
+export interface DndInternalProps {
+    dragType?: string;
+    dragVarName?: string;
+    sourceId?: string;
+    onDrop?: dropHandlerInterface;
+    draggedData?: Record<string, unknown>;
+    droppedData?: Record<string, unknown>;
+    dropTypes?: string[];
+}
+export const draggedSx = { opacity: 0.5 };
+export const droppableSx = { color: "red" };
+
+const dndDataType = "application/taipy-dnd";
+export const useDrag = (
+    eltRef: RefObject<HTMLElement>,
+    dragType?: string,
+    sourceData?: Record<string, unknown>,
+    itemId?: string,
+    varName?: string,
+    sourceId?: string
+) => {
+    const [isDragging, setDragging] = useState(false);
+    useEffect(() => {
+        const elt = eltRef.current;
+        if (!elt || !dragType) {
+            return;
+        }
+        const dragStartHandler = (e: DragEvent) => {
+            setDragging(true);
+            e.dataTransfer?.setData(
+                dndDataType,
+                JSON.stringify({ type: dragType, itemId, varName, sourceId, sourceData })
+            );
+        };
+        const dragEndHandler = () => {
+            setDragging(false);
+        };
+
+        elt.addEventListener("dragstart", dragStartHandler);
+        elt.addEventListener("dragend", dragEndHandler);
+        elt.draggable = true;
+        return () => {
+            elt.removeEventListener("dragstart", dragStartHandler);
+            elt.removeEventListener("dragend", dragEndHandler);
+        };
+    }, [dragType, itemId, varName, sourceId, sourceData, eltRef]);
+    return [isDragging];
+};
+
+export const useDrop = (
+    eltRef: RefObject<HTMLElement>,
+    dropTypes?: string[],
+    targetItemId?: string,
+    onDrop?: dropHandlerInterface
+) => {
+    const [isDraggedOver, setIsDraggedOver] = useState(false);
+    useEffect(() => {
+        const elt = eltRef.current;
+        if (!elt || !dropTypes) {
+            return;
+        }
+
+        const dragEnterHandler = () => {
+            setIsDraggedOver(true);
+        };
+        const dragLeaveHandler = () => {
+            setIsDraggedOver(false);
+        };
+        const dragOverHandler = (e: DragEvent) => {
+            const data = e.dataTransfer?.getData(dndDataType);
+            if (data) {
+            }
+            e.preventDefault();
+        };
+        const dropHandler = (e: DragEvent) => {
+            e.preventDefault();
+            e.stopPropagation();
+            setIsDraggedOver(false);
+            const data = e.dataTransfer?.getData(dndDataType);
+            if (data && onDrop && !e.dataTransfer?.getData(dndDataType + "-done")) {
+                try {
+                    const { type, itemId, varName, sourceId, sourceData } = JSON.parse(data);
+                    if (dropTypes && dropTypes.includes(type)) {
+                        e.dataTransfer?.setData(dndDataType + "-done", "done");
+                        onDrop(sourceId, itemId, sourceData, varName, targetItemId);
+                    }
+                } catch (e) {
+                    console.error("Error parsing data: ", e);
+                }
+            }
+        };
+        elt.addEventListener("dragenter", dragEnterHandler);
+        elt.addEventListener("dragleave", dragLeaveHandler);
+        elt.addEventListener("dragover", dragOverHandler);
+        elt.addEventListener("drop", dropHandler);
+        return () => {
+            elt.removeEventListener("dragenter", dragEnterHandler);
+            elt.removeEventListener("dragleave", dragLeaveHandler);
+            elt.removeEventListener("dragover", dragOverHandler);
+            elt.removeEventListener("drop", dropHandler);
+        };
+    }, [dropTypes, targetItemId, onDrop, eltRef]);
+
+    return [isDraggedOver];
+};

+ 53 - 38
frontend/taipy-gui/src/components/Taipy/lovUtils.tsx

@@ -11,22 +11,22 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { CSSProperties, useMemo, MouseEvent } from "react";
+import React, { ComponentProps, CSSProperties, useMemo, MouseEvent, useRef } from "react";
 import Avatar from "@mui/material/Avatar";
 import CardHeader from "@mui/material/CardHeader";
 import ListItemButton from "@mui/material/ListItemButton";
 import ListItemText from "@mui/material/ListItemText";
 import ListItemAvatar from "@mui/material/ListItemAvatar";
 import Tooltip from "@mui/material/Tooltip";
-import { TypographyProps } from "@mui/material";
 import { SxProps } from "@mui/system";
 
 import { TaipyActiveProps, TaipyChangeProps, TaipyLabelProps } from "./utils";
 import { getInitials } from "../../utils";
 import { LovItem } from "../../utils/lov";
 import { stringIcon, Icon, IconAvatar, avatarSx } from "../../utils/icon";
+import { DndInternalProps, DndProps, draggedSx, droppableSx, useDrag, useDrop } from "./dndUtils";
 
-export interface SelTreeProps extends LovProps, TaipyLabelProps {
+export interface SelTreeProps extends LovProps, TaipyLabelProps, DndProps {
     filter?: boolean;
     multiple?: boolean;
     width?: string | number;
@@ -105,12 +105,12 @@ export const LovImage = ({
     item,
     disableTypo,
     height,
-    titleTypographyProps,
+    slotProps,
 }: {
     item: Icon;
     disableTypo?: boolean;
     height?: string;
-    titleTypographyProps?: TypographyProps<"span", { component?: "span" }>;
+    slotProps?: ComponentProps<typeof CardHeader>["slotProps"];
 }) => {
     const sx = useMemo(
         () => (height ? { height: height, "& .MuiAvatar-img": { objectFit: "contain" } } : undefined) as SxProps,
@@ -122,7 +122,7 @@ export const LovImage = ({
             avatar={<IconAvatar img={item} sx={sx} />}
             title={item.text}
             disableTypography={disableTypo}
-            titleTypographyProps={titleTypographyProps}
+            slotProps={slotProps}
         />
     );
 };
@@ -136,14 +136,14 @@ export const showItem = (elt: LovItem, searchValue: string) => {
     );
 };
 
-export interface ItemProps {
+export interface ItemProps extends DndInternalProps {
     value: string;
     clickHandler: (evt: MouseEvent<HTMLElement>) => void;
     selectedValue: string[] | string;
     item: stringIcon;
     disabled: boolean;
     withAvatar?: boolean;
-    titleTypographyProps?: TypographyProps<"span", { component?: "span" }>;
+    slotProps?: ComponentProps<typeof CardHeader>["slotProps"];
 }
 
 export const SingleItem = ({
@@ -153,38 +153,53 @@ export const SingleItem = ({
     item,
     disabled,
     withAvatar = false,
-    titleTypographyProps,
-}: ItemProps) => (
-    <ListItemButton
-        onClick={clickHandler}
-        data-id={value}
-        selected={Array.isArray(selectedValue) ? selectedValue.indexOf(value) !== -1 : selectedValue === value}
-        disabled={disabled}
-    >
-        {typeof item === "string" ? (
-            withAvatar ? (
+    slotProps,
+    dragType,
+    dragVarName,
+    sourceId,
+    dropTypes,
+    onDrop,
+    draggedData: dragData,
+}: ItemProps) => {
+    const itemRef = useRef<HTMLDivElement>(null);
+
+    const [isDragging] = useDrag(itemRef, dragType, dragData, value, dragVarName, sourceId);
+    const [isDraggedOver] = useDrop(itemRef, dropTypes, value, onDrop);
+
+    return (
+        <ListItemButton
+            onClick={clickHandler}
+            data-id={value}
+            selected={Array.isArray(selectedValue) ? selectedValue.indexOf(value) !== -1 : selectedValue === value}
+            disabled={disabled}
+            ref={itemRef}
+            sx={isDragging ? draggedSx : isDraggedOver ? droppableSx : undefined}
+        >
+            {typeof item === "string" ? (
+                withAvatar ? (
+                    <ListItemAvatar>
+                        <CardHeader
+                            sx={cardSx}
+                            avatar={
+                                <Tooltip title={item}>
+                                    <Avatar sx={avatarSx}>{getInitials(item)}</Avatar>
+                                </Tooltip>
+                            }
+                            title={item}
+                            slotProps={slotProps}
+                        />
+                    </ListItemAvatar>
+                ) : (
+                    <ListItemText primary={item} />
+                )
+            ) : (
                 <ListItemAvatar>
-                    <CardHeader
-                        sx={cardSx}
-                        avatar={
-                            <Tooltip title={item}>
-                                <Avatar sx={avatarSx}>{getInitials(item)}</Avatar>
-                            </Tooltip>
-                        }
-                        title={item}
-                        titleTypographyProps={titleTypographyProps}
-                    />
+                    <LovImage item={item} slotProps={slotProps} />
                 </ListItemAvatar>
-            ) : (
-                <ListItemText primary={item} />
-            )
-        ) : (
-            <ListItemAvatar>
-                <LovImage item={item} titleTypographyProps={titleTypographyProps} />
-            </ListItemAvatar>
-        )}
-    </ListItemButton>
-);
+            )}
+        </ListItemButton>
+    );
+};
 
 export const isLovParent = (lov: LovItem[] | undefined, id: string, childId: string, path: string[] = []): boolean => {
     if (!lov) {

+ 4 - 3
taipy/gui/_renderers/builder.py

@@ -373,11 +373,12 @@ class _Builder:
         if not hash and isinstance(value, str):
             value = [elt_type(t.strip()) for t in value.split(";")]
         if isinstance(value, list):
+            var_name = _to_camel_case(name)
             if hash and dynamic:
-                self.__set_react_attribute(name, hash)
-                return [f"{name}={hash}"]
+                self.__set_react_attribute(var_name, hash)
+                return [f"{var_name}={hash}"]
             else:
-                self.__set_json_attribute(name, value)
+                self.__set_json_attribute(var_name, value)
         elif value is not None:
             _warn(f"{self.__element_name}: {name} should be a list of {elt_type}.")
         return []

+ 17 - 1
taipy/gui/_renderers/factory.py

@@ -63,6 +63,14 @@ class _Factory:
         "tree": "value",
     }
 
+    __DRAG_N_DROP_ATTRIBUTES = [
+        ("drag_type", PropertyType.string),
+        ("allowed_drag_types", PropertyType.string_list),
+        ("on_action", PropertyType.function),
+        ("drag_data", PropertyType.dynamic_dict),
+        ("drop_data", PropertyType.dynamic_dict),
+    ]
+
     _TEXT_ATTRIBUTES = ["format", "id", "hover_text", "raw"]
 
     __TEXT_ANCHORS = ["bottom", "top", "left", "right"]
@@ -373,7 +381,7 @@ class _Factory:
                 ("hover_text", PropertyType.dynamic_string),
                 ("width",),
                 ("width[mobile]",),
-                ("expanded",PropertyType.boolean, False),
+                ("expanded", PropertyType.boolean, False),
             ]
         )
         ._set_propagate(),
@@ -476,6 +484,7 @@ class _Factory:
                 ("content", PropertyType.toHtmlContent),
                 ("width", PropertyType.string_or_number),
             ]
+            + _Factory.__DRAG_N_DROP_ATTRIBUTES
         ),
         "progress": lambda gui, control_type, attrs: _Builder(
             gui=gui,
@@ -515,6 +524,7 @@ class _Factory:
                 ("selection_message", PropertyType.dynamic_string),
                 ("show_select_all", PropertyType.boolean),
             ]
+            + _Factory.__DRAG_N_DROP_ATTRIBUTES
         )
         ._set_propagate(),
         "slider": lambda gui, control_type, attrs: _Builder(
@@ -670,6 +680,12 @@ class _Factory:
                 ("select_leafs_only", PropertyType.boolean),
                 ("row_height", PropertyType.string),
                 ("lov", PropertyType.lov),
+                ("drag_type", PropertyType.string),
+                ("drop_types", PropertyType.string_list),
+                ("on_action", PropertyType.function),
+                ("drag_type", PropertyType.string),
+                ("drop_types", PropertyType.string_list),
+                ("dnd_parameters", PropertyType.dynamic_dict),
             ]
         )
         ._set_propagate(),

+ 51 - 2
taipy/gui/viselements.json

@@ -1103,7 +1103,8 @@
             {
                 "inherits": [
                     "lovComp",
-                    "propagate"
+                    "propagate",
+                    "dndComp"
                 ],
                 "properties": [
                     {
@@ -1930,7 +1931,8 @@
             {
                 "inherits": [
                     "partial",
-                    "shared"
+                    "shared",
+                    "dndComp"
                 ],
                 "properties": [
                     {
@@ -2199,6 +2201,53 @@
                 ]
             }
         ],
+
+        [
+            "dndComp",
+            {
+                "properties": [
+                    {
+                        "name": "drag_type",
+                        "type": "str",
+                        "doc": "The type of source element that the user can drag.<br/>If empty, the element is not draggable."
+                    },
+                    {
+                        "name": "allowed_drag_types",
+                        "type": "list[str]",
+                        "doc": "The list of source elements types supported by this component. ie to allow a dragged source element to be dropped on this drop target.<br/>If empty, the element does not support drops."
+                    },
+                    {
+                        "name": "drag_data",
+                        "type": "dynamic[dict[str, Any]]",
+                        "doc": "A dict containing data that will be associated with the drag source."
+                    },
+                    {
+                        "name": "drop_data",
+                        "type": "dynamic[dict[str, Any]]",
+                        "doc": "A dict containing data that will be associated with the drop target."
+                    },
+                    {
+                        "name": "on_action",
+                        "type": "Union[str, Callable]",
+                        "doc": "A function or the name of a function that is triggered when a source element is dropped on a target element.<br/>This function is invoked with the following parameters:\n<ul><li><i>state</i> (<code>State^</code>): the state instance.</li><li><i>payload</i> (dict): a dictionary that contains the following keys <ul><li>action</li><li>reason: \"drop\"</li><li>source_id,</li><li>source_item_id</li><li>source_data</li><li>source_var_name</li><li>target_id</li><li>target_item_id</li><li>target_data</li><li>target_var_name</li></ul>.</li></ul>",
+                        "signature": [
+                            [
+                                "state",
+                                "State"
+                            ],
+                            [
+                                "id",
+                                "str"
+                            ],
+                            [
+                                "payload",
+                                "dict"
+                            ]
+                        ]
+                    }
+                ]
+            }
+        ],
         [
             "on_change",
             {

Some files were not shown because too many files changed in this diff