isaacHagoel / svelte-dnd-action

An action based drag and drop container for Svelte
MIT License
1.83k stars 104 forks source link

SVELTE DND ACTION Known Vulnerabilities

This is a feature-complete implementation of drag and drop for Svelte using a custom action. It supports almost every imaginable drag and drop use-case, any input device and is fully accessible.
It requires very minimal configuration, while offering a rich set of primitives that allow overriding basically any of its default behaviours (using the handler functions).

See full features list below.

dnd_demo2

Play with this example in the REPL.

Current Status

The library is production ready, and I am in the process of integrating it into several production systems that will be used at scale. It is being actively maintained. I am doing my best to avoid breaking-changes and keep the API stable.

Features

Why a svelte action rather than a higher order component?

A custom action allows for a much more elegant API (no slot props thanks god) as well as more control.
If you prefer a generic dnd list component that accepts different child components as your abstraction, you can very easily wrap this library with one (see here).

Installation

Pre-requisites: svelte-3 (>=3.23.0)

yarn add -D svelte-dnd-action

or

npm install --save-dev svelte-dnd-action

Usage

<div use:dndzone="{{items: myItems, ...otherOptions}}" on:consider="{handler}" on:finalize="{handler}">
    {#each myItems as item(item.id)}
    <div>this is now a draggable div that can be dropped in other dnd zones</div>
    {/each}
</div>
Basic Example:
<script>
    import {flip} from "svelte/animate";
    import {dndzone} from "svelte-dnd-action";
    let items = [
        {id: 1, name: "item1"},
        {id: 2, name: "item2"},
        {id: 3, name: "item3"},
        {id: 4, name: "item4"}
    ];
    const flipDurationMs = 300;
    function handleDndConsider(e) {
        items = e.detail.items;
    }
    function handleDndFinalize(e) {
        items = e.detail.items;
    }
</script>

<style>
    section {
        width: 50%;
        padding: 0.3em;
        border: 1px solid black;
        /* this will allow the dragged element to scroll the list although starting in version 0.9.41 the lib would detect any scrollable parent*/
        overflow: scroll;
        height: 200px;
    }
    div {
        width: 50%;
        padding: 0.2em;
        border: 1px solid blue;
        margin: 0.15em 0;
    }
</style>
<section use:dndzone="{{items, flipDurationMs}}" on:consider="{handleDndConsider}" on:finalize="{handleDndFinalize}">
    {#each items as item(item.id)}
    <div animate:flip="{{duration: flipDurationMs}}">{item.name}</div>
    {/each}
</section>
Input:
An options-object with the following attributes: Name Type Required? Default Value Description
items Array<Object> Yes. Each object in the array has to have an id property (key name can be overridden globally) with a unique value (within all dnd-zones of the same type) N/A The data array that is used to produce the list with the draggable items (the same thing you run your #each block on). The dndzone should not have children that don't originate in items
flipDurationMs Number No 0 The same value you give the flip animation on the items (to make them animated as they "make space" for the dragged item). Set to zero if you dont want animations, if unset it defaults to 100ms
type String No Internal dnd-zones that share the same type can have elements from one dragged into another. By default, all dnd-zones have the same type
dragDisabled Boolean No false Setting it to true will make it impossible to drag elements out of the dnd-zone. You can change it at any time, and the zone will adjust on the fly
morphDisabled Boolean No false By default, when dragging over a zone, the dragged element is morphed to look like it would if dropped. You can prevent it by setting this option.
dropFromOthersDisabled Boolean No false Setting it to true will make it impossible to drop elements from other dnd-zones of the same type. Can be useful if you want to limit the max number of items for example. You can change it at any time, and the zone will adjust on the fly
zoneTabIndex Number No 0 Allow user to set custom tabindex to the list container when not dragging. Can be useful if you want to make the screen reader to skip the list container. You can change it at any time.
zoneItemTabIndex Number No 0 Allow user to set custom tabindex to the list container items when not dragging. Can be useful if you use Drag handles. You can change it at any time.
dropTargetStyle Object<String> No {outline: 'rgba(255, 255, 102, 0.7) solid 2px'} An object of styles to apply to the dnd-zone when items can be dragged into it. Note: the styles override any inline styles applied to the dnd-zone. When the styles are removed, any original inline styles will be lost
dropTargetClasses Array<String> No [] A list of classes to apply to the dnd-zone when items can be dragged into it. Note: make sure the classes you use are global.
transformDraggedElement Function No () => {} A function that is invoked when the draggable element enters the dnd-zone or hover overs a new index in the current dnd-zone.
Signature:
function(element, data, index) {}
element: The dragged element.
data: The data of the item from the items array.
index: The index the dragged element will become in the new dnd-zone.

This allows you to override properties on the dragged element, such as innerHTML to change how it displays. If what you are after is altering styles, do it to the children, not to the dragged element itself
autoAriaDisabled Boolean No false Setting it to true will disable all the automatically added aria attributes and aria alerts (for example when the user starts/ stops dragging using the keyboard).
Use it only if you intend to implement your own custom instructions, roles and alerts. In such a case, you might find the exported function alertToScreenReader(string) useful.
centreDraggedOnCursor Boolean No false Setting it to true will cause elements from this dnd-zone to position their center on the cursor on drag start, effectively turning the cursor to the focal point that triggers all the dnd events (ex: entering another zone). Useful for dnd-zones with large items that can be dragged over small items.
dropAnimationDisabled Boolean No false Setting it to true will disable the animation of the dropped element to its final place.
Output:

The action dispatches two custom events:

The expectation is the same for both event handlers - update the list of items. In both cases the payload (within e.detail) is the same: an object with two attributes: items and info.

You have to listen for both events and update the list of items in order for this library to work correctly.

For advanced use-cases (ex: custom styling for the placeholder element) you might also need to import SHADOW_ITEM_MARKER_PROPERTY_NAME, which marks the placeholder element that is temporarily added to the list the dragged element hovers over. For use cases that have recursively nested zones (ex: crazy nesting), you might want to import SHADOW_PLACEHOLDER_ITEM_ID in order to filter the placeholder out when passing the items in to the nested component. If you need to manipulate the dragged element either dynamically (and don't want to use the transformDraggedElement option), or statically targeting it or its children with CSS, you can import and use DRAGGED_ELEMENT_ID;

Accessibility (beta)

If you want screen-readers to tell the user which item is being dragged and which container it interacts with, please add aria-label on the container and on every draggable item. The library will take care of the rest. For example:

<h2>{listName}</h2>
<section aria-label="{listName}" use:dndzone="{{items, flipDurationMs}}" on:consider="{handleDndConsider}" on:finalize="{handleDndFinalize}">
    {#each items as item(item.id)}
    <div aria-label="{item.name}" animate:flip="{{duration: flipDurationMs}}">{item.name}</div>
    {/each}
</section>

If you don't provide the aria-labels everything will still work, but the messages to the user will be less informative. Note: in general you probably want to use semantic-html (ex: ol and li elements rather than section and div) but the library is screen readers friendly regardless (or at least that's the goal :)). If you want to implement your own custom screen-reader alerts, roles and instructions, you can use the autoAriaDisabled options and wire everything up yourself using markup and the consider and finalize handlers (for example: unsortable list).

Keyboard support

Drag Handles Support

Due to popular demand, starting in version 0.9.46 the library exports a wrapper action that greatly improves the ergonomics around using drag handles. Notes:

<script>
    import {dragHandleZone, dragHandle} from "svelte-dnd-action";
    import {flip} from "svelte/animate";

    let items = [
        {
            id: 1,
            text: "Item 1"
        },
        {
            id: 2,
            text: "Item 2"
        },
        {
            id: 3,
            text: "Item 3"
        }
    ];
    const flipDurationMs = 100;

    function handleSort(e) {
        items = e.detail.items;
    }
</script>

<style>
    div {
        position: relative;
        height: 1.5em;
        width: 10em;
        text-align: center;
        border: 1px solid black;
        margin: 0.2em;
        padding: 0.3em;
    }
    .handle {
        position: absolute;
        right: 0;
        width: 1em;
        height: 0.5em;
        background-color: grey;
    }
</style>

<h3>Drag Handles</h3>
<p>Items can be dragged using the grey handles via mouse, touch or keyboard. The text on the items can be selected without starting a drag</p>
<hr />
<section use:dragHandleZone="{{ items, flipDurationMs }}" on:consider="{handleSort}" on:finalize="{handleSort}">
    {#each items as item (item.id)}
    <div animate:flip="{{ duration: flipDurationMs }}">
        <div use:dragHandle aria-label="drag-handle for {item.text}" class="handle" />
        <span>{item.text}</span>
    </div>
    {/each}
</section>

Examples and Recipes

Rules/ assumptions to keep in mind

Overriding the item id key name

Sometimes it is useful to use a different key for your items instead of id, for example when working with PouchDB which expects _id. It can save some annoying conversions back and forth. In such cases you can import and call overrideItemIdKeyNameBeforeInitialisingDndZones. This function accepts one parameter of type string which is the new id key name. For example:

import {overrideItemIdKeyNameBeforeInitialisingDndZones} from "svelte-dnd-action";
overrideItemIdKeyNameBeforeInitialisingDndZones("_id");

It applies globally (as in, all of your items everywhere are expected to have a unique identifier with this name). It can only be called when there are no rendered dndzones (I recommend calling it within the top-level Githubissues.

  • Githubissues is a development platform for aggregating issues.