Published on

Building a Wrapper Component for Sortable.js in Vue 3

Authors

Creating interactive and customizable UI components is an essential part of frontend development. One of the popular libraries for drag-and-drop functionality is Sortable.js, which provides a robust way to create sortable, draggable lists.

In this post, I’ll walk you through creating a custom Vue 3 wrapper for Sortable.js, exploring how it works, and why I chose to build my own component rather than using an existing wrapper.



Why Sortable.js?

Sortable.js is widely used across projects needing sortable lists and drag-and-drop functionality. It’s well-maintained, feature-rich, and flexible enough to be adapted to many different use cases.

Some comparable core libraries in the UI toolkit ecosystem include:

Demo of Sortable Wrapper

To get an idea of what this component looks like, here’s a short demo of the component in action:

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Click to see the Code
vue
<script setup lang="ts">
import Sortable from '../../.vitepress/theme/Sortable.vue'

const options = {
  multiDrag: true,
  group: 'shared',
  animation: 150,
}

const list = [
	{ id: 1 }, { id: 2 }, { id: 3 },
	{ id: 4 }, { id: 5 }, { id: 6 }
]
</script>

<template>
	<div :class="$style.lists">
		<Sortable :list :options :class="$style.list">
			<template #item="{ element }">
				<div :class="$style.item">
					{{ `Item ${element.id}` }}
				</div>
			</template>
		</Sortable>
		<Sortable :list :options :class="$style.list">
			<template #item="{ element }">
				<div :class="[$style.item, $style.tinted]">
					{{ `Item ${element.id}` }}
				</div>
			</template>
		</Sortable>
	</div>
</template>

<style module>
.lists {
	display: flex;
	gap: 16px;
}

.header {
	font-size: 1.125rem;
	font-weight: bold;
	width: 100%;
}

.list {
	display: flex;
	flex-direction: column;
	gap: 8px;
	width: 100%;
	background-color: white;
	border: 1px solid #e5e5e5;
	padding: 8px;
	border-radius: 4px;
}

.item {
	padding: 8px 16px;
	background-color: white;
	border: 1px solid #d1d5db;
	border-radius: 4px;
	cursor: grab;
}

.item.tinted {
	background-color: lightyellow;
}
</style>

In this example:

  • Import the Component: We import the Sortable component.
  • Pass the Data: We pass a list of items to the Sortable, with the default item-id id to uniquely identify each item.
  • Define Item Template: We use the #item slot to define how each item should be rendered.

Building a Custom Sortable Component in Vue 3

To start you'll need the Sortable.js library itself, for this use your package manager of choice and don't forget the typescript types:

bash
yarn add sortablejs @types/sortablejs

If you want to activate the MultiDrag feature, add the following to your entrypoint file:

ts
import Sortable, { MultiDrag } from 'sortablejs'

Sortable.mount(new MultiDrag())

Here’s the complete code for the wrapper component I built, which we’ll call Sortable.vue:

Click to see the Code
vue
<!-- eslint-disable @typescript-eslint/no-explicit-any -->
<script setup lang="ts">
/*
 * based on https://github.com/MaxLeiter/sortablejs-vue3 with some changes
 */

import { ref, type PropType, watch, onUnmounted, computed, useAttrs, type Ref } from 'vue'
import Sortable, { type Options } from 'sortablejs'

type SortableOptionsProp = Omit<
  Options,
  | 'onUnchoose'
  | 'onChoose'
  | 'onStart'
  | 'onEnd'
  | 'onAdd'
  | 'onUpdate'
  | 'onSort'
  | 'onRemove'
  | 'onFilter'
  | 'onMove'
  | 'onClone'
  | 'onChange'
>

type ExposedProps = {
  containerRef: Ref<HTMLElement | null>
  sortable: Ref<Sortable | null>
  isDragging: Ref<boolean>
  selected: Ref<HTMLElement[] | null>
  clearSelected: () => void
}

const props = defineProps({
  /** All SortableJS options are supported; events are handled by the `defineEmits` below and should be used with v-on */
  options: {
    type: Object as PropType<SortableOptionsProp>,
    default: null,
    required: false,
  },
  /** Your list of items **/
  list: {
    type: [Array, Object] as PropType<any[]>,
    default: () => [],
    required: true,
  },
  /** The name of the key present in each item in the list that corresponds to a unique value. */
  itemKey: {
    type: [String, Function] as PropType<string | ((item: any) => string | number | symbol)>,
    default: 'id',
    required: false,
  },
  /** The element type to render as. */
  tag: {
    type: String as PropType<string>,
    default: 'div',
    required: false,
  },
})

const emit = defineEmits<{
  (eventName: 'choose', evt: Sortable.SortableEvent): void
  (eventName: 'unchoose', evt: Sortable.SortableEvent): void
  (eventName: 'select', evt: Sortable.SortableEvent): void
  (eventName: 'deselect', evt: Sortable.SortableEvent): void
  (eventName: 'start', evt: Sortable.SortableEvent): void
  (eventName: 'end', evt: Sortable.SortableEvent): void
  (eventName: 'add', evt: Sortable.SortableEvent): void
  (eventName: 'update', evt: Sortable.SortableEvent): void
  (eventName: 'sort', evt: Sortable.SortableEvent): void
  (eventName: 'remove', evt: Sortable.SortableEvent): void
  (eventName: 'filter', evt: Sortable.SortableEvent): void
  (eventName: 'move', evt: Sortable.MoveEvent, originalEvent: Event): void
  (eventName: 'clone', evt: Sortable.SortableEvent): void
  (eventName: 'change', evt: Sortable.SortableEvent): void
}>()

const attrs = useAttrs()

const isDragging = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const sortable = ref<Sortable | null>(null)
const selected = ref<HTMLElement[] | null>(null)

const getKey = computed(() => {
  if (typeof props.itemKey === 'string') {
    return (item: any) => item[props.itemKey as string]
  }
  return props.itemKey
})

const selectedClass = computed(() => props.options.selectedClass || 'sortable-selected')

/*
 *  getSelected / setSelected used instead of computed because the selected items changing
 *  do not trigger the reactivity chain
 */
const getSelected = () => {
  if (!props.options.multiDrag) {
    return null
  }
  const items = containerRef.value?.getElementsByClassName(selectedClass.value)
  return Array.from(items || []) as HTMLElement[]
}

const setSelected = () => (selected.value = getSelected())

const clearSelected = () => {
  if (!selected.value || !selected.value.length) {
    return
  }
  selected.value.forEach((el) => {
    /** Calls Sortable internals and triggers onDeselect **/
    Sortable.utils.toggleClass(el, selectedClass.value, false)
  })
}

defineExpose<ExposedProps>({
  containerRef,
  sortable,
  isDragging,
  selected,
  clearSelected,
})

watch(containerRef, (newDraggable) => {
  if (newDraggable) {
    sortable.value = new Sortable(newDraggable, {
      ...props.options,
      onChoose: (event) => emit('choose', event),
      onUnchoose: (event) => emit('unchoose', event),
      onSelect: (event) => {
        setSelected()
        emit('select', event)
      },
      onDeselect: (event) => {
        setSelected()
        emit('deselect', event)
      },
      onStart: (event) => {
        isDragging.value = true
        emit('start', event)
      },
      onEnd: (event) => {
        // This is a hack to move the event to the end of the event queue.
        // cf this issue: https://github.com/SortableJS/Sortable/issues/1184
        setTimeout(() => {
          isDragging.value = false
          emit('end', event)
        })
      },
      onAdd: (event) => emit('add', event),
      onUpdate: (event) => emit('update', event),
      onSort: (event) => emit('sort', event),
      onRemove: (event) => emit('remove', event),
      onFilter: (event) => emit('filter', event),
      // See https://github.com/MaxLeiter/sortablejs-vue3/pull/56 for context on `attrs`.
      onMove: (event, originalEvent) =>
        'onMoveCapture' in attrs
          ? (attrs.onMoveCapture as (event: Sortable.MoveEvent, originalEvent: Event) => void)(event, originalEvent)
          : emit('move', event, originalEvent),
      onClone: (event) => emit('clone', event),
      onChange: (event) => emit('change', event),
    })
  }
})

watch(
  () => props.options,
  (options) => {
    if (options && sortable?.value) {
      for (const property in options) {
        sortable.value.option(property as keyof SortableOptionsProp, options[property as keyof SortableOptionsProp])
      }
    }
  },
)

onUnmounted(() => {
  if (sortable.value) {
    sortable.value.destroy()
    containerRef.value = null
    sortable.value = null
  }
})
</script>

<template>
  <component :is="$props.tag" ref="containerRef" :class="$props.class">
    <slot name="header"></slot>
    <slot v-for="(item, index) of list" :key="getKey(item)" :element="item" :index="index" name="item"></slot>
    <slot name="footer"></slot>
  </component>
</template>

Why Did I Opt for a Custom Component?

Instead of using an existing Vue wrapper for Sortable.js, I decided to develop my own version. Specifically, I based my component on MaxLeiter's sortablejs-vue3, but with several modifications to suit my needs. Below are the reasons for this decision.

Viability Reasons

The existing library lacked essential features that my use case required. For instance, it didn't support the MultiDrag plugin, including the necessary props and related functionalities. Additionally, there was no support for the behavior and external tracking associated with selected elements, both of which were critical for my project.

Beyond these feature gaps, the library contained some issues that affected its usability. Specifically, the container prop type was incorrectly defined, which led to complications during development. There were also unnecessary type casts in exposed props, further complicating the integration.

In order to achieve the flexibility needed to customize and adapt the component to my unique requirements, I realized that a custom solution was the best option.

Technical Reasons

After experimenting with several libraries for over a week, it became apparent that there was no stable, reliable Vue 3 wrapper available for Sortable.js.

The official wrapper, Vue.Draggable, still only supports Vue 2, limiting its compatibility with Vue 3 projects. Another alternative, vue.draggable.next, is plagued by bugs and lacks support for critical features like MultiDrag, which I needed.

Other wrappers I explored were smaller projects, often maintained by a single developer, and lacked the regular maintenance required for production-grade applications. With these limitations in mind, I opted to create a custom component that would better meet my needs.

Workflow Reasons

There are three primary reasons why Vue, and particularly Vue 3, presents challenges when working with dependencies.

First, unlike React, Vue doesn’t benefit from an extensive ecosystem of widely supported libraries. This means that developers often need to find creative solutions or implement their own components to fill the gaps.

Second, Vue’s architecture often necessitates wrapper components over widely used libraries such as highcharts, sortable, and bootstrap. These wrappers can be fragile and overly reliant on the framework’s internals, making them less reliable and harder to maintain.

Finally, there’s a significant difference between Vue 2 and Vue 3, which has resulted in breaking changes that affect compatibility with many Vue 2 wrappers. Unfortunately, most substitutes for these wrappers in Vue 3 are either in early development or nonexistent, further complicating the integration process.

I have firsthand experience with these challenges. In a previous project, I led a team in migrating from Vue 2 to Vue 3 with TypeScript. We were painfully blocked for more than six months because there was no alternative to BootstrapVue for Vue 3. We had to use non-production-ready, unmaintained, low-quality libraries or create our own versions, which was a significant pain point.

Back to the Custom Sortable Wrapper

The component I developed is contained within a single file and is relatively straightforward in terms of complexity. For a core UI functionality library, MaxLeiter's sortablejs-vue3 has relatively few forks and stars. This is in contrast to the original Sortable.js repository, which has significantly more, reflecting its established use and popularity. Whenever possible, I aim to avoid adding extra dependencies to the project, particularly if the functionality I need can be achieved with a simple, readable, single-file solution.

Conclusion

Building a custom wrapper for Sortable.js allowed me to create a flexible, maintainable solution tailored to my project needs. Vue 3’s ecosystem still lacks many production-ready wrappers, and this component demonstrates the benefits of building your own when existing solutions fall short.

By controlling every aspect of the integration, I could ensure compatibility with Vue 3 and add specific functionality, like multi-drag support. If you’re considering using drag-and-drop in your own projects, I encourage you to explore Sortable.js and build your own Vue wrapper if you need more control or features.

Feel free to check out the SortableJS documentation and try implementing your own wrapper for an even deeper understanding.