Why would I need this?

I am currently working on a shiny app that is an interactive to-do list. In the app, want to reorder items by dragging and dropping them. There are many packages (e.g., shinyDND, RStudio's sortable) and JavaScript libraries available, however I wanted to learn how to build my own. Since there is a browser ready drag and drop API, it is fairly straightforward to create your own events.

In this tutorial, I will cover the basic elements for creating draggable elements in shiny. I will focus on creating a draggable element and the corresponding JavaScript events, as well as the CSS classes that are added when an element is dragged. I will try to keep the concepts simple and provide links for further reading where applicable. If you have any questions or if something is not clear, feel free to open a new issue.

For more information, please see the drag and drop API documentation, as well as the section What do I need to know before I integrate this into my app? for implementation notes.

How does this app work?

We will build an app that allows users to reorder a series cards in any order.

drag and drop demonstration

In the GIF, users can reorder a series of cards. Each card a group assignment (i.e., letters A through E) and a random value (i.e., 1 through 50). Users can reorder the cards by name, by value or any method they choose. Cards can be moved by clicking and then dragging the card up or down the page. When the user drops the card, the card will be inserted into that space (either before or after).

In this tutorial, I will cover the following items.

  1. Creating a draggable functional component
  2. Developing the UI and integrating the draggable component
  3. Defining styles for drag events
  4. Writing the drag and drop events

I am using the app.R format and have created the JavaScript and CSS files. Your project directory should look like this. I also saved the draggable component in a separate file.

> drag-and-drop/
    + app.R
    + draggable_card.R
    + www/
       - styles.CSS
       - index.js

Creating a draggable functional component

The most important aspect to creating a draggable component is the attribute draggable. When this attribute is set to true, the element can be dragged. If you do not want it to be dragged, then use draggable="false" or remove the attribute altogether.

In HTML, the draggable attribute is added to an element like so.

<div draggable="true">
    <p>Hello, world!</p>
</div>

In shiny, it would like this.

# ui.R
tags$div(
    draggable = "true",
    tags$p("Hello, world!")
)

Before I begin developing the app, I will start off by developing a draggable functional component. This component will display a title, some text, and an icon that indicates that a card can be dragged. I will also add a logical argument that disables drag-ability for a card. I will call this component draggable_card.

The function runs in the following order.

  1. Validate input arguments (by default, all items are draggable)
  2. Create the SVG icon: The icon is a plus sign with a filled circular background.
  3. Create the card element with all elements (title, text, and icon)
  4. If !draggable, then remove the SVG icon.
  5. Return the element.

Here is the whole function as it appears in the file draggable_card.R.

draggable_card <- function(id, title, text, draggable = TRUE) {

    # validate args
    stopifnot(!is.null(id))
    stopifnot(!is.null(title))
    stopifnot(!is.null(text))
    stopifnot(is.logical(draggable))

    # create <svg> icon
    fill_color <- "#09BC8A"
    line_color <- "#ffffff"
    svg <- tag(
        "svg",
        list(
            "width" = "25",
            "height" = "25",
            "viewBox" = "0 0 25 25",
            "class" = "card-icon",
            # <circle>
            tag(
                "circle",
                list(
                    "cx" = "12.5",
                    "cy" = "12.5",
                    "r" = "12.5",
                    "fill" = fill_color
                )
            ),
            # vertical: <line>
            tag(
                "line",
                list(
                    "x1" = "12.5",
                    "y1" = "5",
                    "x2" = "12.5",
                    "y2" = "20",
                    "stroke" = line_color,
                    "stroke-width" = "2.5",
                    "stroke-linecap" = "butt"
                )
            ),
            # horizontal: <line>
            tag(
                "line",
                list(
                    "x1" = "5",
                    "y1" = "12.5",
                    "x2" = "20",
                    "y2" = "12.5",
                    "stroke" = line_color,
                    "stroke-width" = "2.5",
                    "stroke-linecap" = "butt"
                )
            )
        )
    )

    # build parent element: <div class="card">
    el <- tags$div(
        id = paste0("card-", id),
        class = "card",
        draggable = tolower(draggable),
        `data-value` = title,
        tags$h2(class = "card-title", title),
        svg,
        tags$p(class = "card-message", text)
    )

    # remove <svg> element if draggable = FALSE
    if (!draggable) el$children[[2]] <- NULL

    # return
    return(el)
}

The function returns the HTML markup for the draggable component which is rendered in browser. Here is a sample output.

draggable_card(id = "example", title = "Test", text = "This is a test")            
# <div id="card-example" class="card" draggable="true" data-value="Test">
#  <h2 class="card-title">Test</h2>
#  <svg width="25" height="25" viewBox="0 0 25 25" class="card-icon">
#    <circle cx="12.5" cy="12.5" r="12.5" fill="#09BC8A"></circle>
#    <line x1="12.5" y1="5" x2="12.5" y2="20" stroke="#ffffff" stroke-width="2.5" stroke-linecap="butt"></line>
#     <line x1="5" y1="12.5" x2="20" y2="12.5" stroke="#ffffff" stroke-width="2.5" stroke-linecap="butt"></line>
#   </svg>
#   <p class="card-message">This is a test</p>
# </div>

If the option draggable was set to false, the function would return the following markup.

draggable_card(id = "example", title = "Test", text = "This is a test", draggable = FALSE)            
# <div id="card-example" class="card" data-value="Test">
#  <h2 class="card-title">Test</h2>
#   <p class="card-message">This is a test</p>
# </div>

Developing the UI and integrating the draggable component

Now that the component is working, it can be integrated into a shiny app. In the example app, I am using the tags approach for building the UI. Using the draggable card function, I will create a few cards using example data. I have wrapped the cards in a container and have included an extra drop area. The purpose of the extra drop area is that if an element is dropped into this area, then it will be placed last. I have also created a button is used to display the order of the cards.

ui <- tagList(

    # <head>: link CSS
    tags$head(
        tags$link(rel = "stylesheet", href = "styles.CSS")
    ),

    # <main>: main content
    tags$main(
        tags$h2("Moveable Elements"),
        tags$p(
            "Order the cards by the number of cases or by group.",
            "Drag and drop a card into the drop zone or on top of",
            "another card. Press 'done' when you are finished."
        ),

        # container for dragged elements
        tags$div(
            class = "dragarea",

            # primary elements
            draggable_card("groupA", "Group A", "14 cases"),
            draggable_card("groupB", "Group B", "3 cases"),
            draggable_card("groupC", "Group C", "33 cases"),
            draggable_card("groupD", "Group D", "7 cases"),
            draggable_card("groupE", "Group E", "21 cases"),

            # extra drop zone
            tags$div(
                class = "droparea",
                tags$p("Drop here")
            )
        ),

        # submit button that shows results
        tags$button(
            id = "submit",
            type = "submit",
            class = "shiny-bound-input action-button",
            "Done"
        )
    ),

    # link js file
    tags$script(src = "index.js")
)

# server
server <- function(input, output, session) { }

# app
shinyApp(ui, server)

Defining styles for drag events

There are a number of styles in the CSS file, but I would like focus on a few of them: highlighting, drag, and focus. The classes highlighting and focus are used for highlighting a potential drop area by adding a bright green border to the target element. Focus is only used for the blank drop zone. The class drag is used for adding a shadow to the element when it is dragged.

/* for highlighting potential drop */
.highlighting {
    border-color: #09BC8A;
}

/* add shadow to dragged element */
.drag {
    -webkit-box-shadow: 0 0 9px 3px hsla(0, 0%, 0%, 0.3);
    box-shadow: 0 0 9px 3px hsla(0, 0%, 0%, 0.3);
}

/* for highlighting dropzone element */
.droparea.focus {
    background-color: White;
    border-color: #09BC8A;
}

These classes will be added and removed in the following js events.

Writing the drag and drop events

There are a number of drag related events that you can choose from. At a minimum, you should use dragstart and drop. In this example, I will cover dragstart, dragend, dragover, dragenter, dragleave, and drop. The list below provides a summary of the events that I used in the example app and what each event does.

  1. dagstart/dragend: When an element is dragged, I would like to add a CSS class that makes the dragged element visually noticeable. I also want to remove the class when dragging is stopped.
  2. dragover: When the element is hovered over a potential drop area, I would like to add a CSS class to the drop area.
  3. dragenter/dragleave: If the dragged element is hovered over the extra dropzone, I would like to add a CSS class that focuses the dropzone. I also want to remove this class if the dropzone is no longer focused.
  4. drop: I would also like add some logic that determines where the element should be added. If I drag an element down the page, I am assuming that I want the element to be further down the page. Therefore, I would like the element to be placed after the droparea. If I drag an element up the page, I am assuming that I want the element to appear higher on the page. Therefore, I would like the element to be placed before the droparea.

Defining variables and a helper function

First, I will create a few variables that will be used across all events. The element dragged will receive the HTML element that I dragged. The variable startingY saves the starting y position of the dragged element. This will be used to determine if the element is moved up or down the page. The last variable I have defined is used to select the blank drop element.

let dragged, startingY, dropzone = document.querySelector(".droparea");

In some events that I will write, I will be adding the CSS class .highlighting a potential droparea. However, I will need a function that removes all this class when events are finished.

function remove_highlighting() {
    return document.querySelectorAll(".card").forEach(elem => elem.classList.remove("highlighting"));
}

Event for dragstart

The first event is dragstart. This event runs as soon as an element is dragged. In this example, I initialized a few things.

  1. dataTransfer: this property manages the type of drag (i.e., copy or move), the content that is being dragged (i.e., HTML, images, text, etc.), and other data that you want to use
  2. classList.add: Add the .drag CSS class to the element is, at the moment, being dragged
  3. update variables: Update the dragged and startingY variables (defined in the previous section).
document.addEventListener("dragstart", function (event) {

    // set event data transfer
    event.dataTransfer.dropEffect = "move";
    event.dataTransfer.setData("text/HTML", event.target.outerHTML);

    // add class to dragged item
    event.target.classList.add("drag");

    // update variables
    dragged = event.target;
    startingY = event.pageY;

}, false)

Event for dragend

The dragend event is used for running a function when dragging stops. In this example, when the user stops dragging an element, I want to remove the CSS class drag—that was added in the dragstart event—from the dragged element.

document.addEventListener("dragend", function (event) {
    event.target.classList.remove("drag");
}, false)

Event for dragover

The event dragover is run when an element is dragged over a potential drop area. In this example, this would be another card (i.e., <div class="card">...</div>) and the extra dropzone (which I will explain in next section). When the user drags an element over another draggable card, I want to add the .highlighting CSS class to the other card. The value of event target is tied to the location of the cursor, which would return other elements besides the card element. Therefore, we need to find the nearest draggable card (i.e., div with the CSS class card).

document.addEventListener("dragover", function (event) {

    // prevent any default actions (e.g., link clicks, etc.)
    event.preventDefault();

    // if the closet <div> to the target has the class of "card",
    // add highlighting to it
    if (event.target.closest("div").className === "card") {
        event.target.closest("div.card").classList.add("highlighting");
    }

}, false)

NOTE: If the code above is unclear, add a console log inside the event. Add console.log(event.target) anywhere in this event. Open the browser console and restart the app. Take note of the cursor position and the output element.

Event for dragenter

The event dragenter is similar to dragover as they both can be used for highlighting potential drop areas. In this example, I am using dragenter for highlighting the extra dropzone. The variable dropzone was defined in the section Defining variables and a helper function.

document.addEventListener("dragenter", function (event) {

    // if the target element is the "droparea"
    if (event.target.className === "droparea") {
        dropzone.classList.add("focus");
    }

}, false)

Event for dragleave

The opposite event of dragenter is dragleave. In this example, if a card is dragged into the extra drop zone and then dragged elsewhere, I want to remove all highlighting classes.

document.addEventListener("dragleave", function (event) {

    // if the target element is the "droparea"
    if (event.target.className === "droparea") {
        dropzone.classList.remove("focus");
    }

    // remove all highlighting
    remove_highlighting();

}, false)

Event for drop

The last event is the drop event. This event will run when the dragged element is dropped into a new, valid position. In this example, I structured the drop event in two ways: 1) if the element is dropped in the blank dropzone and 2) if the element is dropped on top of another card. Both methods will remove the dragged element and insert it into a valid target location. Should the target location is not valid, then the dragged element will return to the starting position.

If the element is dropped on top of another card I want to add it before or after the element. Using the variable startingY, we can determine if the element is dragged up or down the page. I will use the event property event.pageY and compare it with startingY. If the value of pageY—at the time of drop‐is less than startingY, then the element has been moved up the page. If the pageY value is greater than startingY, then the element has been moved down the page.

Depending on the comparison of these values, the dragged element will either be added before or after the target element (i.e., drop area). Using the function insertAdjacentHTML, you can specify the insert position (i.e., beforebegin, afterbegin, beforeend, afterend). In this example, I am using beforebegin and afterend as I want the dragged card to appear before or after another card.

document.addEventListener("drop", function (event) {

    // remove any remaining highlighting
    remove_highlighting();
    dropzone.classList.remove("focus");

    // when dropped in droparea
    if (event.target.className === "droparea") {

        // remove styles dragged element
        dragged.classList.remove("drag");

        // remove dragged element starting point and add before dropzone
        // we want to keep the dropzone for future use.
        dragged.parentNode.removeChild(dragged);
        event.target.insertAdjacentHTML("beforebegin", dragged.outerHTML);
    }

    // when "replacing" card
    if (event.target.closest("div").className == "card") {

        // remove element from document and add to drop position
        dragged.classList.remove("drag");
        dragged.parentNode.removeChild(dragged);

        // if item is moved up, insert the element before the target
        if (event.pageY < startingY) {
            event.target.closest("div.card").insertAdjacentHTML("beforebegin", dragged.outerHTML);
        }

        // if item is moved down, insert the element after the target
        if (event.pageY >= startingY) {
            event.target.closest("div.card").insertAdjacentHTML("afterend", dragged.outerHTML);
        }
    }

}, false)

That is it!

NOTE: In this index.js file, the final event runs when the button done is clicked. This event triggers an alert that displays the new order of the cards.

What do I need to know before I integrate this into my app?

To summarize this tutorial, here are the basic elements that you need.

  1. An element with the attribute draggable="true" (e.g., <div draggable="true">...</div>)
  2. An event that initializes the data transfer when an element is dragged, as well as saves the HTML element for re-adding the dragged element into the document.
// "save" dragged element
let dragged; 

// listener
document.addEventListener("dragstart", function(event) {
    event.dataTransfer.dropEffect = "move";
    event.dataTransfer.setData("text/HTML", event.target.outerHTML);
    dragged = event.target;
})
  1. An event that removes and adds the dragged element when it is dropped.
document.addEventListener("drop", function(event) {

    // remove dragged element
    dragged.parentNode.removeChild(dragged);

    // add the dragged element - or "afterend", "beforeend", etc.
    event.target.insertAdjacentHTML("beforebegin", dragged.outerHTML);
})

There are other drag events that may be useful for your app that I did not cover in this tutorial. See the drag and drop API documentation for more information and examples.

In this example, I wanted to insert the dragged element before or after an element depending on if the element was dragged up or down the page. Therefore, I used the function insertAdjacentHTML. You may want to use another HTML insert function depending on purpose of your app (e.g., appendChild, etc.).

More reading and testing is needed to ensure the draggable elements are web accessible. I will be investigating this and will update the tutorial. Stay tuned!

How do I run the example?

The simplest way to run the example is directly from the R console using the function shiny::runGitHub.

install.packages("shiny")
shiny::runGitHub(repo = "shinyAppTutorials", username = "davidruvolo51", subdir = "drag-and-drop")

Alternatively, you can clone the repository and run locally.