A package to create accessible sortable lists in Svelte.
Live demos:
<dialog>
support.pnpm install @rodrigodagostino/svelte-sortable-list
npm install @rodrigodagostino/svelte-sortable-list
yarn add @rodrigodagostino/svelte-sortable-list
<script lang="ts">
import {
SortableItem,
SortableList,
type SortableListProps,
} from '@rodrigodagostino/svelte-sortable-list';
</script>
<script lang="ts">
import {
SortableItem,
SortableList,
type SortableItemData,
sortItems,
} from '@rodrigodagostino/svelte-sortable-list';
let items: SortableItemData[] = [
{
id: 'list-item-1',
text: 'List item 1',
},
{
id: 'list-item-2',
text: 'List item 2',
},
{
id: 'list-item-3',
text: 'List item 3',
},
{
id: 'list-item-4',
text: 'List item 4',
},
{
id: 'list-item-5',
text: 'List item 5',
},
];
function handleDragEnd(event: CustomEvent<DragEndEventDetail>) {
const { draggedItemIndex, targetItemIndex, isCanceled } = event.detail;
if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex)
items = sortItems(items, draggedItemIndex, targetItemIndex);
}
</script>
<SortableList on:dragend={handleDragEnd}>
{#each items as item, index (item.id)}
<SortableItem {...item} {index}>
<div class="ssl-content">
<span class="ssl-content__text">{item.text}</span>
</div>
</SortableItem>
{/each}
</SortableList>
The following is a list of the accessibility features provided by the package:
These are the steps to navigate and operate the list:
Tab
to focus the list.Arrow Up
, Arrow Left
, Arrow Down
, Arrow Right
, Home
or End
to focus the first item in the list.Arrow Up
or Arrow Left
to move the focus to the previous item.Arrow Down
or Arrow Right
to move the focus to the next item.Home
to move the focus to the first item.End
to move the focus to the last item.Space
to start dragging an item. When dragging, press Space
again to drop the dragged item.Arrow Up
or Arrow Left
to move the dragged item to the previous position.Arrow Down
or Arrow Right
to move the dragged item to the next position.Home
to move the dragged item to the first position.End
to move the dragged item to the last position.Escape
to cancel the drag and return the item to its initial position.There are two main things that need to be considered to customize the screen reader announcements:
aria-description
attribute: the keyboard navigation instructions (default: Press the arrow keys to move through the list items. Press Space to start dragging an item. When dragging, use the arrow keys to move the item around. Press Space again to drop the item, or Escape to cancel.
).announcements
prop: the announcements to be read out by the screen reader during drag and drop operations.In addition to those, you can also use the following too (accepted by both the <SortableList>
and <SortableItem>
components):
aria-label
attribute: the name of the list.aria-labelledby
attribute: the ID of the element that provides the name of the list.The following example contains most of the code that you can find in the “With custom announcements” demo page, and it shows how we could translate the announcements to Spanish:
<script lang="ts">
...
const announcements: SortableListProps['announcements'] = {
lifted: (_, draggedItemIndex) => {
return `Ha levantado un item en la posición ${draggedItemIndex! + 1}.`;
},
dragged: (_, draggedItemIndex, __, targetItemIndex) => {
const startPosition = draggedItemIndex + 1;
const endPosition = targetItemIndex + 1;
const result =
startPosition !== endPosition
? `desde la posición ${startPosition} a la posición ${endPosition}`
: `de vuelta a su posición inicial de ${startPosition}`;
return `Ha movido el item ${result}.`;
},
dropped: (_, draggedItemIndex, __, targetItemIndex) => {
const startPosition = draggedItemIndex + 1;
const endPosition = typeof targetItemIndex === 'number' ? targetItemIndex + 1 : null;
const result =
endPosition === null
? `Se ha mantenido en su posición inicial de ${startPosition}`
: startPosition !== endPosition
? `Se ha movido desde la posición ${startPosition} a la posición ${endPosition}`
: `Ha vuelto a su posición inicial de ${startPosition}`;
return `Ha soltado el item. ${result}.`;
},
canceled: (_, draggedItemIndex) => {
return `Ha cancelado el arrastre. El item ha vuelto a su posición inicial de ${draggedItemIndex + 1}.`;
},
};
</script>
<SortableList
...
aria-description="Presione las flechas para desplazarte por los elementos de la lista. Presione Espacio para empezar a arrastrar un elemento. Al arrastrar, use las flechas para moverlo. Presione Espacio de nuevo para soltar el elemento o Escape para cancelar."
{announcements}
>
...
</SortableList>
The following is a list of the available components inside the package:
Component | Description |
---|---|
<SortableList> |
The primary container. Provides the main structure, the drag-and-drop interactions and emits the available events. |
<SortableItem> |
An individual item within <SortableList> . Holds the data and content for each list item, as well as the <Handle> and <Remove> components when needed. |
<Handle> |
An element that limits the draggable area of a list item to itself. Including it inside a <SortableItem> will directly activate the handle functionality for that item. |
<Remove> |
A <button> element that (when pressed) removes an item. Including it inside a <SortableItem> will directly allow it to dispatch the remove event for that item. |
It would be possible to avoid the <Remove>
component and just go with a <button>
element to trigger the removal of an item, but keep in mind that this component doesn’t just fire a click event, it will also focus the next item in the list if the user is using the keyboard to press the remove button. Otherwise, after the focused element is removed, the focus will fall back to the <body>
.
<SortableList>
propsProp | Type | Default | Possible values | Description |
---|---|---|---|---|
gap |
number | undefined |
12 |
Number equal to or above 0 . |
Separation between items (in pixels). |
direction |
string | undefined |
'vertical' |
'vertical' or 'horizontal' |
Orientation in which items will be arranged. |
transitionDuration |
number | undefined |
240 |
Number equal to or above 0 . |
Time the transitions for the ghost (dropping) and items (translation, addition, removal) take to complete (in milliseconds). Assign it a value of 0 to remove animations. |
hasDropMarker |
boolean | undefined |
false |
true or false |
If true , displays a position marker representing where the dragged item will be positioned when drag-and-dropping. |
hasLockedAxis |
boolean | undefined |
false |
true or false |
If true , prevents the dragged item from moving away from the main axis. |
hasBoundaries |
boolean | undefined |
false |
true or false |
If true , items will only be draggable inside the list limits. |
canClearOnDragOut |
boolean | undefined |
false |
true or false |
If true , the target item will be cleared when a the dragged item (by a pointing device) does not collide with any of the items in the list. This will cause the dragged item to return to its initial position when dropped. Otherwise, it will take the position of the last item it collided with. |
canRemoveOnDropOut |
boolean | undefined |
false |
true or false |
If true , items will be removed when dragged and dropped outside of the list boundaries. This needs to be coupled with the on:remove event handler for it to complete the removal process. |
isLocked |
boolean | undefined |
false |
true or false |
If true , will allow every item in the list to be focused, but will prevent them from being dragged (both through pointer and keyboard). Interactive elements inside will operate normally. |
isDisabled |
boolean | undefined |
false |
true or false |
If true , will allow every item in the list to be focused, but will prevent them from being dragged (both through pointer and keyboard) and change its appearance to dimmed. Interactive elements inside will be disabled. |
<SortableList>
events[!Note] Events are fired in the order they are displayed below.
Event | Type | Trigger | Returns |
---|---|---|---|
on:mounted |
CustomEvent<MountedEventDetail> |
The component is mounted. | event: { |
on:dragstart |
CustomEvent<DragStartEventDetail> |
An item starts to be dragged by a pointer device or a keyboard. | event: { |
on:drag |
CustomEvent<DragEventDetail> |
A dragged item is moved around by a pointer device or a keyboard (fires every few hundred milliseconds). | event: { |
on:drop |
CustomEvent<DropEventDetail> |
A dragged item is released by a pointer device or a keyboard. | event: { |
on:dragend |
CustomEvent<DragEndEventDetail> |
A dragged item reaches its destination after being released. | event: { |
<SortableItem>
propsProp | Type | Default | Possible values | Description |
---|---|---|---|---|
id |
string |
undefined |
Unique string. | Unique identifier for each item. |
index |
number |
undefined |
Unique number. | Position of the item in the list. |
isLocked |
boolean | undefined |
false |
true or false |
If true , will prevent the item from being dragged. |
isDisabled |
boolean | undefined |
false |
true or false |
If true , will prevent the item from being dragged and change its appearance to dimmed. |
Function | Description |
---|---|
sortItems() |
Provides an easy mechanism to reorder items (it’s recommended to be used in combination with the on:dragend event). |
removeItem() |
Provides an easy mechanism to remove an item from your list (should be used in combination with the on:drop event). |
Example:
<script lang="ts">
import { ... removeItem, sortItems } from '$lib/index.js';
...
function handleDragEnd(event: CustomEvent<DragEndEventDetail>) {
const { draggedItemIndex, targetItemIndex, isCanceled } = event.detail;
if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex)
items = sortItems(items, draggedItemIndex, targetItemIndex);
}
function handleRemoveClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest<HTMLLIElement>('.ssl-item');
const itemIndex = Number(item?.dataset.itemIndex);
if (!item || itemIndex < 0) return;
items = removeItem(items, itemIndex);
}
</script>
<SortableList on:dragend={handleDragEnd}>...</SortableList>
Type | Description |
---|---|
SortableListProps |
Provides definitions for the <SortableList> component. |
SortableItemProps |
Provides definitions for the <SortableItem> component. |
SortableItemData |
Provides definitions for your items list data. |
MountedEventDetail |
Provides definitions for the <SortableList> mounted custom event detail. |
DragStartEventDetail |
Provides definitions for the <SortableList> dragstart custom event detail. |
DragEventDetail |
Provides definitions for the <SortableList> drag custom event detail. |
DropEventDetail |
Provides definitions for the <SortableList> drop custom event detail. |
DragEndEventDetail |
Provides definitions for the <SortableList> dragend custom event detail. |
Example:
<script lang="ts">
import type { ... DragEndEventDetail, DropEventDetail } from '$lib/types/index.js';
...
function handleDrop(event: CustomEvent<DropEventDetail>) {
const { draggedItemIndex, isBetweenBounds, canRemoveOnDropOut } = event.detail;
if (!isBetweenBounds && canRemoveOnDropOut) items = removeItem(items, draggedItemIndex);
}
function handleDragEnd(event: CustomEvent<DragEndEventDetail>) {
const { draggedItemIndex, targetItemIndex, isCanceled } = event.detail;
if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex)
items = sortItems(items, draggedItemIndex, targetItemIndex);
}
</script>
If you want to make use of the styles present in the demo pages, import them in your project like so:
<script>
import 'svelte-sortable-list/styles.css';
</script>
[!IMPORTANT] To customize the appearance of the list items and ghost element (and not cause any conflicts or interferences with the core styles and transitions), the usage of the
.ssl-item
and.ssl-ghost
selectors must be avoided. You can instead create a wrapping element for the item’s content (like the element with the.ssl-content
class included in one of the examples), which should be the direct child of the element with the.ssl-item
class (the ghost element will simply mirror the list item’s content and appearance).
This is a list of the selectors you can use to style the list and the list items to your heart’s desire:
Selector | Points to |
---|---|
.ssl-list |
The <SortableList> main container. |
.ssl-list[aria-orientation="vertical"] |
The <SortableList> main container when direction is set to vertical . |
.ssl-list[aria-orientation="horizontal"] |
The <SortableList> main container when direction is set to horizontal . |
.ssl-list[data-has-drop-marker="true"] |
The <SortableList> main container while hasDropMarker is enabled. |
.ssl-list[data-can-remove-on-drop-out="true"] |
The <SortableList> main container while canRemoveOnDropOut is enabled. |
.ssl-list[data-is-locked="true"] |
The <SortableList> that is locked. |
.ssl-list[aria-disabled="true"] |
The <SortableList> that is disabled. |
.ssl-item |
Each <SortableItem> main container. |
.ssl-item[data-is-pointer-dragging="true"] |
The <SortableItem> that is being dragged by a pointing device. |
.ssl-item[data-is-pointer-dropping="true"] |
The <SortableItem> that is being dropped by a pointing device. |
.ssl-item[data-is-keyboard-dragging="true"] |
The <SortableItem> that is being dragged by the keyboard. |
.ssl-item[data-is-keyboard-dropping="true"] |
The <SortableItem> that is being dropped by the keyboard. |
.ssl-item[data-is-locked="true"] |
Each <SortableItem> that is locked. |
.ssl-item[aria-disabled="true"] |
Each <SortableItem> that is disabled. |
.ssl-item[data-is-removing="true"] |
The <SortableItem> that is being removed by dropping it outside the list limits by a pointing device. |
.ssl-ghost |
The shadow element displayed under the pointer when dragging. |
.ssl-ghost[data-is-pointer-dragging="true"] |
The shadow element while it’s being dragged by a pointing device. |
.ssl-ghost[data-is-pointer-dropping="true"] |
The shadow element while it’s being dropped by a pointing device. |
.ssl-ghost[data-is-between-bounds="true"] |
The shadow element while it’s inside the list limits. |
.ssl-ghost[data-is-removing="true"] |
The shadow element while a <SortableItem> is being removed. |
.ssl-ghost[data-can-remove-on-drop-out="true"] |
The shadow element while canRemoveOnDropOut is enabled. |
.ssl-handle |
The <Handle> main container. |
.ssl-remove |
The <Remove> main container. |
[!TIP] Combining the available selectors appropriately should be enough to style the list and the list items to your heart’s content. For example, the following would target the direct child of the
<Ghost>
component whencanRemoveOnDropOut
is enabled and is being dragged outside of the list limits:.ssl-ghost[data-can-remove-on-drop-out="true"][data-is-between-bounds="false"] .ssl-content { ... }
If you find that your particular case is not covered, please, feel free to submit a request :)
Custom property | Description |
---|---|
--gap |
Separation between items (in pixels). |
--transition-duration |
Time the transitions for the ghost (dropping) and items (translation, addition, removal) take to complete (in milliseconds). |
While working on a SvelteKit project, I ran into the need of adding drag-and-drop capabilities to a couple of items lists, for which I decided to make use of SortableJS, which is certainly a popular option. I implemented it through a Svelte Action and it provided just what I needed, or so it seemed. After a while I realized I was not only missing touch screen support (since it was built with the HTML Drag and Drop API), but also accessibility was nowhere to be seen, and seems there are no plans to work on it.
I was not able to find any other suitable option, so this problem felt like a good opportunity to build my own package. And so while doing some research to try and understand the implications of such feature, I ran into a very interesting article and a very interesting talk by Vojtech Miksu which really guided me through the different paths available, their advantages, pain points and limitations to create a drag-and-drop system, putting particular focus on accessibility and touch screen support.
Even though React Movable was built for React, it served as my main inspiration when building this package. So thank you again, Vojtech :)