npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@fastkit/vue-sortable

v0.2.0

Published

Integration implementation of SortableJS for Vue applications.

Readme

@fastkit/vue-sortable

🌐 English | 日本語

A library that provides drag & drop element sorting functionality for Vue.js applications. A Vue integration implementation of SortableJS, supporting three usage methods: components, directives, and Composable API.

Features

  • Drag & Drop Sorting: Intuitive mouse and touch operation for changing element order
  • Three Usage Methods: Choose from components, directives, or Composable API
  • Multi-drag Support: Simultaneous selection and movement of multiple elements
  • Inter-group Movement: Element movement between different Sortable lists
  • Pre-update Guard Function: Validation and cancellation processing before sorting
  • Animation Support: Smooth sorting animations
  • Full TypeScript Support: Type safety through strict type definitions
  • Vue 3 Composition API: Complete integration with reactive system
  • SSR Support: Safe operation in server-side rendering environments
  • Customizable: Support for all SortableJS options

Installation

npm install @fastkit/vue-sortable

Basic Usage

Usage with Components

<template>
  <div>
    <h2>Task List</h2>

    <Sortable
      v-model="tasks"
      item-key="id"
      :animation="200"
    >
      <template #wrapper="{ children, attrs }">
        <ul v-bind="attrs" class="task-list">
          <component :is="children" />
        </ul>
      </template>

      <template #item="{ data, attrs }">
        <li v-bind="attrs" class="task-item">
          <div class="task-content">
            <span class="task-handle">≡</span>
            <div class="task-info">
              <h4>{{ data.title }}</h4>
              <p>{{ data.description }}</p>
              <span class="task-priority" :class="`priority-${data.priority}`">
                {{ getPriorityLabel(data.priority) }}
              </span>
            </div>
            <div class="task-actions">
              <button @click="editTask(data)" class="edit-button">Edit</button>
              <button @click="deleteTask(data.id)" class="delete-button">Delete</button>
            </div>
          </div>
        </li>
      </template>
    </Sortable>

    <div class="add-task">
      <button @click="addTask" class="add-button">+ Add New Task</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Sortable } from '@fastkit/vue-sortable'

interface Task {
  id: string
  title: string
  description: string
  priority: 'low' | 'medium' | 'high'
  completed: boolean
}

const tasks = ref<Task[]>([
  {
    id: '1',
    title: 'Create Project Proposal',
    description: 'Create proposal for new project',
    priority: 'high',
    completed: false
  },
  {
    id: '2',
    title: 'Prepare Meeting Materials',
    description: 'Prepare materials for next week\'s meeting',
    priority: 'medium',
    completed: false
  },
  {
    id: '3',
    title: 'Bug Fix',
    description: 'Investigate and fix reported bugs',
    priority: 'high',
    completed: false
  },
  {
    id: '4',
    title: 'Update Documentation',
    description: 'Update API documentation to latest version',
    priority: 'low',
    completed: false
  }
])

const getPriorityLabel = (priority: Task['priority']) => {
  const labels = {
    low: 'Low',
    medium: 'Medium',
    high: 'High'
  }
  return labels[priority]
}

const addTask = () => {
  const newTask: Task = {
    id: Date.now().toString(),
    title: 'New Task',
    description: 'Please enter task description',
    priority: 'medium',
    completed: false
  }
  tasks.value.push(newTask)
}

const editTask = (task: Task) => {
  console.log('Edit:', task)
  // Implement edit functionality
}

const deleteTask = (taskId: string) => {
  const index = tasks.value.findIndex(task => task.id === taskId)
  if (index !== -1) {
    tasks.value.splice(index, 1)
  }
}
</script>

<style>
.task-list {
  list-style: none;
  padding: 0;
  margin: 0;
  background: #f8f9fa;
  border-radius: 8px;
  min-height: 100px;
}

.task-item {
  margin: 8px;
  background: white;
  border-radius: 6px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.task-item:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.task-content {
  display: flex;
  align-items: center;
  padding: 15px;
  gap: 15px;
}

.task-handle {
  color: #6c757d;
  cursor: grab;
  font-size: 18px;
  user-select: none;
}

.task-handle:active {
  cursor: grabbing;
}

.task-info {
  flex: 1;
}

.task-info h4 {
  margin: 0 0 5px 0;
  color: #495057;
  font-size: 16px;
}

.task-info p {
  margin: 0 0 8px 0;
  color: #6c757d;
  font-size: 14px;
}

.task-priority {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.priority-low {
  background: #d4edda;
  color: #155724;
}

.priority-medium {
  background: #fff3cd;
  color: #856404;
}

.priority-high {
  background: #f8d7da;
  color: #721c24;
}

.task-actions {
  display: flex;
  gap: 8px;
}

.edit-button,
.delete-button {
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
  transition: background-color 0.2s ease;
}

.edit-button {
  background: #007bff;
  color: white;
}

.edit-button:hover {
  background: #0056b3;
}

.delete-button {
  background: #dc3545;
  color: white;
}

.delete-button:hover {
  background: #c82333;
}

.add-task {
  margin-top: 20px;
  text-align: center;
}

.add-button {
  padding: 12px 24px;
  background: #28a745;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  transition: background-color 0.2s ease;
}

.add-button:hover {
  background: #1e7e34;
}
</style>

Usage with Directives

<template>
  <div>
    <h2>Image Gallery Sorting</h2>

    <div
      v-sortable="{
        animation: 300,
        ghostClass: 'sortable-ghost',
        chosenClass: 'sortable-chosen',
        dragClass: 'sortable-drag',
        onEnd: handleSortEnd
      }"
      class="image-gallery"
    >
      <div
        v-for="(image, index) in images"
        :key="image.id"
        :data-sortable-key="image.id"
        class="image-item"
      >
        <img :src="image.url" :alt="image.title">
        <div class="image-overlay">
          <h4>{{ image.title }}</h4>
          <p>{{ image.description }}</p>
          <div class="image-actions">
            <button @click="editImage(index)" class="action-button edit">
              Edit
            </button>
            <button @click="deleteImage(index)" class="action-button delete">
              Delete
            </button>
          </div>
        </div>
      </div>
    </div>

    <div class="gallery-controls">
      <button @click="addImage" class="control-button">
        + Add Image
      </button>
      <button @click="resetOrder" class="control-button">
        Reset Order
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { SortableEvent } from '@fastkit/vue-sortable'

interface ImageItem {
  id: string
  title: string
  description: string
  url: string
}

const images = ref<ImageItem[]>([
  {
    id: '1',
    title: 'Beautiful Sunset',
    description: 'Beautiful sunset scenery over the ocean',
    url: 'https://picsum.photos/300/200?random=1'
  },
  {
    id: '2',
    title: 'Mountain Landscape',
    description: 'Magnificent mountain scenery',
    url: 'https://picsum.photos/300/200?random=2'
  },
  {
    id: '3',
    title: 'City Night View',
    description: 'Sparkling city nightscape',
    url: 'https://picsum.photos/300/200?random=3'
  },
  {
    id: '4',
    title: 'Natural Greenery',
    description: 'Vibrant green nature',
    url: 'https://picsum.photos/300/200?random=4'
  }
])

const originalOrder = [...images.value]

const handleSortEnd = (event: SortableEvent) => {
  const { oldIndex, newIndex } = event

  if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
    const item = images.value.splice(oldIndex, 1)[0]
    images.value.splice(newIndex, 0, item)

    console.log(`Moved image from ${oldIndex} to ${newIndex}`)
  }
}

const addImage = () => {
  const newImage: ImageItem = {
    id: Date.now().toString(),
    title: 'New Image',
    description: 'Added image',
    url: `https://picsum.photos/300/200?random=${Date.now()}`
  }
  images.value.push(newImage)
}

const editImage = (index: number) => {
  console.log('Edit:', images.value[index])
  // Implement edit functionality
}

const deleteImage = (index: number) => {
  images.value.splice(index, 1)
}

const resetOrder = () => {
  images.value = [...originalOrder]
}
</script>

<style>
.image-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
  min-height: 200px;
}

.image-item {
  position: relative;
  border-radius: 8px;
  overflow: hidden;
  cursor: move;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.image-item:hover {
  transform: scale(1.02);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

.image-item img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  display: block;
}

.image-overlay {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
  color: white;
  padding: 20px;
  transform: translateY(100%);
  transition: transform 0.3s ease;
}

.image-item:hover .image-overlay {
  transform: translateY(0);
}

.image-overlay h4 {
  margin: 0 0 5px 0;
  font-size: 16px;
}

.image-overlay p {
  margin: 0 0 10px 0;
  font-size: 14px;
  opacity: 0.9;
}

.image-actions {
  display: flex;
  gap: 8px;
}

.action-button {
  padding: 4px 8px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
  transition: background-color 0.2s ease;
}

.action-button.edit {
  background: #007bff;
  color: white;
}

.action-button.edit:hover {
  background: #0056b3;
}

.action-button.delete {
  background: #dc3545;
  color: white;
}

.action-button.delete:hover {
  background: #c82333;
}

.gallery-controls {
  margin-top: 20px;
  display: flex;
  gap: 10px;
  justify-content: center;
}

.control-button {
  padding: 10px 20px;
  background: #6c757d;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s ease;
}

.control-button:hover {
  background: #545b62;
}

/* SortableJS styles */
.sortable-ghost {
  opacity: 0.4;
}

.sortable-chosen {
  transform: scale(1.05);
}

.sortable-drag {
  transform: rotate(5deg);
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
</style>

Usage with Composable API

<template>
  <div>
    <h2>Task Movement Between Groups</h2>

    <div class="kanban-board">
      <!-- TODO column -->
      <div class="kanban-column">
        <h3 class="column-header todo">TODO</h3>
        <div
          v-sortable="todoSortable.directiveValue"
          v-bind="todoSortable.wrapperAttrs"
          class="task-list"
        >
          <div
            v-for="item in todoSortable.items"
            :key="item.key"
            v-bind="item.attrs"
            class="kanban-task"
            :class="{ selected: item.selected }"
          >
            <div class="task-header">
              <span class="task-id">#{{ item.data.id }}</span>
              <button @click="item.select()" class="select-button">
                {{ item.selected ? '✓' : '○' }}
              </button>
            </div>
            <h4>{{ item.data.title }}</h4>
            <p>{{ item.data.description }}</p>
            <div class="task-meta">
              <span class="assignee">{{ item.data.assignee }}</span>
              <span class="estimate">{{ item.data.estimate }}h</span>
            </div>
          </div>
        </div>
      </div>

      <!-- DOING column -->
      <div class="kanban-column">
        <h3 class="column-header doing">DOING</h3>
        <div
          v-sortable="doingSortable.directiveValue"
          v-bind="doingSortable.wrapperAttrs"
          class="task-list"
        >
          <div
            v-for="item in doingSortable.items"
            :key="item.key"
            v-bind="item.attrs"
            class="kanban-task"
            :class="{ selected: item.selected }"
          >
            <div class="task-header">
              <span class="task-id">#{{ item.data.id }}</span>
              <button @click="item.select()" class="select-button">
                {{ item.selected ? '✓' : '○' }}
              </button>
            </div>
            <h4>{{ item.data.title }}</h4>
            <p>{{ item.data.description }}</p>
            <div class="task-meta">
              <span class="assignee">{{ item.data.assignee }}</span>
              <span class="estimate">{{ item.data.estimate }}h</span>
            </div>
          </div>
        </div>
      </div>

      <!-- DONE column -->
      <div class="kanban-column">
        <h3 class="column-header done">DONE</h3>
        <div
          v-sortable="doneSortable.directiveValue"
          v-bind="doneSortable.wrapperAttrs"
          class="task-list"
        >
          <div
            v-for="item in doneSortable.items"
            :key="item.key"
            v-bind="item.attrs"
            class="kanban-task"
            :class="{ selected: item.selected }"
          >
            <div class="task-header">
              <span class="task-id">#{{ item.data.id }}</span>
              <button @click="item.select()" class="select-button">
                {{ item.selected ? '✓' : '○' }}
              </button>
            </div>
            <h4>{{ item.data.title }}</h4>
            <p>{{ item.data.description }}</p>
            <div class="task-meta">
              <span class="assignee">{{ item.data.assignee }}</span>
              <span class="estimate">{{ item.data.estimate }}h</span>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="board-actions">
      <button @click="addTask" class="action-button">
        + Add New Task
      </button>
      <button @click="showStats" class="action-button">
        Show Statistics
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useSortable } from '@fastkit/vue-sortable'
import type { SortableUpdateContext } from '@fastkit/vue-sortable'

interface KanbanTask {
  id: string
  title: string
  description: string
  assignee: string
  estimate: number
}

const todoTasks = ref<KanbanTask[]>([
  {
    id: '1',
    title: 'API Endpoint Design',
    description: 'Design user management API',
    assignee: 'Tanaka',
    estimate: 8
  },
  {
    id: '2',
    title: 'Database Design',
    description: 'Design user table',
    assignee: 'Sato',
    estimate: 4
  }
])

const doingTasks = ref<KanbanTask[]>([
  {
    id: '3',
    title: 'Login Feature Implementation',
    description: 'Implement user login functionality',
    assignee: 'Suzuki',
    estimate: 6
  }
])

const doneTasks = ref<KanbanTask[]>([
  {
    id: '4',
    title: 'Project Initial Setup',
    description: 'Set up Git repository and basic configuration',
    assignee: 'Yamada',
    estimate: 2
  }
])

const beforeUpdate = async (ctx: SortableUpdateContext<KanbanTask>) => {
  const { entries, oldValues, newValues } = ctx

  // Move confirmation dialog
  const moveCount = entries.length
  const message = moveCount === 1
    ? `Move task "${entries[0].data.title}"?`
    : `Move ${moveCount} tasks?`

  const confirmed = confirm(message)

  if (!confirmed) {
    return false
  }

  // Move log
  console.log('Task moved:', {
    entries,
    oldValues: oldValues.map(t => t.title),
    newValues: newValues.map(t => t.title)
  })
}

const todoSortable = useSortable(
  {
    modelValue: todoTasks,
    itemKey: 'id',
    group: 'kanban',
    animation: 200,
    multiDrag: true,
    selectedClass: 'selected',
    beforeUpdate
  },
  { emit: (event, value) => {
    if (event === 'update:modelValue') {
      todoTasks.value = value
    }
  } }
)

const doingSortable = useSortable(
  {
    modelValue: doingTasks,
    itemKey: 'id',
    group: 'kanban',
    animation: 200,
    multiDrag: true,
    selectedClass: 'selected',
    beforeUpdate
  },
  { emit: (event, value) => {
    if (event === 'update:modelValue') {
      doingTasks.value = value
    }
  } }
)

const doneSortable = useSortable(
  {
    modelValue: doneTasks,
    itemKey: 'id',
    group: 'kanban',
    animation: 200,
    multiDrag: true,
    selectedClass: 'selected',
    beforeUpdate
  },
  { emit: (event, value) => {
    if (event === 'update:modelValue') {
      doneTasks.value = value
    }
  } }
)

const addTask = () => {
  const newTask: KanbanTask = {
    id: Date.now().toString(),
    title: 'New Task',
    description: 'Please enter task details',
    assignee: 'Unassigned',
    estimate: 1
  }
  todoTasks.value.push(newTask)
}

const showStats = () => {
  const stats = {
    todo: todoTasks.value.length,
    doing: doingTasks.value.length,
    done: doneTasks.value.length,
    totalEstimate: {
      todo: todoTasks.value.reduce((sum, t) => sum + t.estimate, 0),
      doing: doingTasks.value.reduce((sum, t) => sum + t.estimate, 0),
      done: doneTasks.value.reduce((sum, t) => sum + t.estimate, 0)
    }
  }

  alert(`Task Statistics:\nTODO: ${stats.todo} items (${stats.totalEstimate.todo} hours)\nDOING: ${stats.doing} items (${stats.totalEstimate.doing} hours)\nDONE: ${stats.done} items (${stats.totalEstimate.done} hours)`)
}
</script>

<style>
.kanban-board {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
  margin: 20px 0;
}

.kanban-column {
  background: #f8f9fa;
  border-radius: 8px;
  padding: 15px;
  min-height: 400px;
}

.column-header {
  margin: 0 0 15px 0;
  padding: 10px;
  border-radius: 6px;
  text-align: center;
  font-size: 14px;
  font-weight: 600;
  text-transform: uppercase;
}

.column-header.todo {
  background: #e3f2fd;
  color: #1976d2;
}

.column-header.doing {
  background: #fff3e0;
  color: #f57c00;
}

.column-header.done {
  background: #e8f5e8;
  color: #388e3c;
}

.task-list {
  min-height: 300px;
  padding: 10px;
  border: 2px dashed #dee2e6;
  border-radius: 6px;
  transition: border-color 0.2s ease;
}

.task-list:hover {
  border-color: #007bff;
}

.kanban-task {
  background: white;
  border-radius: 6px;
  padding: 12px;
  margin-bottom: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  cursor: move;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.kanban-task:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.kanban-task.selected {
  border: 2px solid #007bff;
  background: #f0f8ff;
}

.task-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.task-id {
  font-size: 12px;
  color: #6c757d;
  font-family: monospace;
}

.select-button {
  background: none;
  border: 1px solid #6c757d;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  cursor: pointer;
  font-size: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.select-button:hover {
  border-color: #007bff;
  color: #007bff;
}

.kanban-task h4 {
  margin: 0 0 6px 0;
  font-size: 14px;
  color: #495057;
}

.kanban-task p {
  margin: 0 0 10px 0;
  font-size: 12px;
  color: #6c757d;
  line-height: 1.4;
}

.task-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 11px;
}

.assignee {
  background: #e9ecef;
  color: #495057;
  padding: 2px 6px;
  border-radius: 10px;
}

.estimate {
  color: #6c757d;
  font-weight: 500;
}

.board-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-top: 20px;
}

.action-button {
  padding: 10px 20px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s ease;
}

.action-button:hover {
  background: #0056b3;
}
</style>

Advanced Usage Examples

Pre-update Guard Functionality

<template>
  <div>
    <h2>Sorting That Requires Approval</h2>

    <div class="approval-settings">
      <label>
        <input v-model="requireApproval" type="checkbox">
        Require approval for sorting
      </label>
    </div>

    <Sortable
      v-model="items"
      item-key="id"
      :animation="200"
      :before-update="beforeUpdate"
    >
      <template #wrapper="{ children, attrs }">
        <div v-bind="attrs" class="approval-list">
          <component :is="children" />
        </div>
      </template>

      <template #item="{ data, attrs }">
        <div v-bind="attrs" class="approval-item">
          <div class="item-content">
            <span class="item-icon">📄</span>
            <div class="item-info">
              <h4>{{ data.title }}</h4>
              <p>{{ data.description }}</p>
            </div>
            <div class="item-status">
              <span class="status-badge" :class="data.status">
                {{ getStatusLabel(data.status) }}
              </span>
            </div>
          </div>
        </div>
      </template>
    </Sortable>

    <!-- Approval dialog -->
    <div v-if="pendingUpdate" class="approval-modal-overlay">
      <div class="approval-modal">
        <h3>Sorting Approval</h3>
        <p>Apply the following changes?</p>

        <div class="change-summary">
          <div v-for="entry in pendingUpdate.entries" :key="entry.data.id">
            <strong>{{ entry.data.title }}</strong>
            <span v-if="entry.oldIndex !== undefined && entry.newIndex !== undefined">
              : {{ entry.oldIndex + 1 }}th → {{ entry.newIndex + 1 }}th
            </span>
          </div>
        </div>

        <div class="approval-actions">
          <button @click="rejectUpdate" class="reject-button">
            Cancel
          </button>
          <button @click="approveUpdate" class="approve-button">
            Approve
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Sortable } from '@fastkit/vue-sortable'
import type { SortableUpdateContext } from '@fastkit/vue-sortable'

interface ApprovalItem {
  id: string
  title: string
  description: string
  status: 'pending' | 'approved' | 'rejected'
}

const requireApproval = ref(true)
const pendingUpdate = ref<SortableUpdateContext<ApprovalItem> | null>(null)
let updateResolver: ((result: boolean) => void) | null = null

const items = ref<ApprovalItem[]>([
  {
    id: '1',
    title: 'Budget Application',
    description: 'Next fiscal year budget application documents',
    status: 'pending'
  },
  {
    id: '2',
    title: 'HR Evaluation Sheet',
    description: 'Quarterly HR evaluation submission',
    status: 'approved'
  },
  {
    id: '3',
    title: 'Expense Report',
    description: 'Business trip expense report documents',
    status: 'pending'
  },
  {
    id: '4',
    title: 'Project Proposal',
    description: 'New project proposal',
    status: 'rejected'
  }
])

const getStatusLabel = (status: ApprovalItem['status']) => {
  const labels = {
    pending: 'Pending Approval',
    approved: 'Approved',
    rejected: 'Rejected'
  }
  return labels[status]
}

const beforeUpdate = async (ctx: SortableUpdateContext<ApprovalItem>) => {
  if (!requireApproval.value) {
    return true
  }

  // Show approval dialog
  pendingUpdate.value = ctx

  return new Promise<boolean>((resolve) => {
    updateResolver = resolve
  })
}

const approveUpdate = () => {
  if (updateResolver) {
    updateResolver(true)
    updateResolver = null
  }
  pendingUpdate.value = null
}

const rejectUpdate = () => {
  if (updateResolver) {
    updateResolver(false)
    updateResolver = null
  }
  pendingUpdate.value = null
}
</script>

<style>
.approval-settings {
  margin: 20px 0;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}

.approval-settings label {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 500;
}

.approval-list {
  background: #f8f9fa;
  border-radius: 8px;
  padding: 15px;
  min-height: 200px;
}

.approval-item {
  background: white;
  border-radius: 6px;
  margin-bottom: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s ease;
}

.approval-item:hover {
  transform: translateY(-1px);
}

.item-content {
  display: flex;
  align-items: center;
  padding: 15px;
  gap: 15px;
}

.item-icon {
  font-size: 24px;
}

.item-info {
  flex: 1;
}

.item-info h4 {
  margin: 0 0 5px 0;
  color: #495057;
}

.item-info p {
  margin: 0;
  color: #6c757d;
  font-size: 14px;
}

.status-badge {
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.status-badge.pending {
  background: #fff3cd;
  color: #856404;
}

.status-badge.approved {
  background: #d4edda;
  color: #155724;
}

.status-badge.rejected {
  background: #f8d7da;
  color: #721c24;
}

.approval-modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.approval-modal {
  background: white;
  border-radius: 8px;
  padding: 30px;
  max-width: 500px;
  width: 90vw;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.approval-modal h3 {
  margin: 0 0 15px 0;
  color: #495057;
}

.change-summary {
  background: #f8f9fa;
  border-radius: 6px;
  padding: 15px;
  margin: 15px 0;
}

.change-summary div {
  margin: 5px 0;
  font-size: 14px;
}

.approval-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
  margin-top: 20px;
}

.reject-button,
.approve-button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.reject-button {
  background: #6c757d;
  color: white;
}

.reject-button:hover {
  background: #545b62;
}

.approve-button {
  background: #28a745;
  color: white;
}

.approve-button:hover {
  background: #1e7e34;
}
</style>

Plugin Installation

// main.ts
import { createApp } from 'vue'
import { installSortableDirective } from '@fastkit/vue-sortable'
import App from './App.vue'

const app = createApp(App)

// Install v-sortable directive globally
installSortableDirective(app)

app.mount('#app')

CSS File Import

// main.ts or target component
import '@fastkit/vue-sortable/vue-sortable.css'

API Reference

Sortable Component

interface SortableProps<T extends SortableData = SortableData> {
  modelValue?: T[]                              // Data array to be sorted
  itemKey?: string | IterableKeyResolver<T>    // Item key detection method
  itemKeyCandidates?: IterableKeyDetectorCandidate[]  // Key auto-detection candidates
  clone?: (source: T) => T                     // Item clone function
  beforeUpdate?: SortableUpdateGuardFn<T>      // Pre-update guard function

  // SortableJS options
  group?: string | GroupOptions
  sort?: boolean
  disabled?: boolean
  animation?: number
  handle?: string
  filter?: string
  ghostClass?: string
  chosenClass?: string
  dragClass?: string
  // ... other SortableJS options
}

type SortableEmits<T> = {
  'update:modelValue': (modelValue: T[]) => true
}

useSortable Composable

function useSortable<T extends SortableData = SortableData>(
  props: SortableProps<T>,
  { emit }: Pick<SetupContext<SortableEmits<T>>, 'emit'>
): SortableContext<T>

interface SortableContext<T> {
  readonly sortable: Sortable | undefined
  readonly el: SortableDirectiveElement<T> | undefined
  readonly id: string
  readonly group: GroupOptions | undefined
  readonly directiveValue: SortableDirectiveValue<SortableContext<T>>
  readonly disabled: boolean
  readonly items: SortableItemDetails<T>[]
  readonly guardInProgress: boolean
  readonly canOperation: boolean
  readonly wrapperAttrs: Record<string, any>

  getIndexByData(data: T): number
  replace(data: T, index: number): Promise<void>
  getKeys(): string[]
  getKeyByData(data: T): string
  getKeyByElement(el: HTMLElement): string
  findDataByKey(key: string): T | undefined
  getDataByKey(key: string): T
  findElementByKey(key: string): HTMLElement
}

v-sortable Directive

interface SortableDirectiveValue<C = undefined> {
  onMounted?: (sortable: Sortable) => void
  inject?: () => C

  // SortableJS options
  group?: string | GroupOptions
  sort?: boolean
  disabled?: boolean
  animation?: number
  handle?: string
  filter?: string
  ghostClass?: string
  chosenClass?: string
  dragClass?: string

  // Event handlers
  onStart?: (event: ExtendedSortableEvent<SortableEvent>) => void
  onEnd?: (event: ExtendedSortableEvent<SortableEvent>) => void
  onAdd?: (event: ExtendedSortableEvent<SortableEvent>) => void
  onRemove?: (event: ExtendedSortableEvent<SortableEvent>) => void
  onSelect?: (event: ExtendedSortableEvent<SortableEvent>) => void
  onDeselect?: (event: ExtendedSortableEvent<SortableEvent>) => void
  // ... other events
}

Pre-update Guard

type SortableUpdateGuardFn<T extends SortableData = SortableData> = (
  ctx: SortableUpdateContext<T>
) => SortableGuardReturn

type SortableGuardReturn = boolean | void | Promise<boolean | void>

interface SortableUpdateContext<T> {
  readonly event: SortableUpdateEvent<T> | undefined
  readonly sortable: SortableContext<T>
  readonly entries: SortableUpdateEntry<T>[]
  readonly oldValues: T[]
  readonly newValues: T[]
}

interface SortableUpdateEntry<T> {
  readonly type: 'add' | 'sort' | 'remove'
  readonly oldIndex?: number
  readonly newIndex?: number
  readonly sameGroup: boolean
  readonly from: SortableContext<T>
  readonly to: SortableContext<T>
  readonly data: T
}

Installation Functions

function installSortableDirective(app: App): App

Performance Optimization

Handling Large Data

// Example of combining with virtual scrolling
const useLargeSortable = <T extends SortableData>(items: Ref<T[]>) => {
  const visibleItems = computed(() =>
    items.value.slice(startIndex.value, endIndex.value)
  )

  const sortable = useSortable({
    modelValue: visibleItems,
    itemKey: 'id',
    beforeUpdate: async (ctx) => {
      // Show confirmation dialog for large data
      if (items.value.length > 1000) {
        return confirm('Execute sorting for large data?')
      }
    }
  }, { emit })

  return sortable
}

Memory Leak Prevention

<script setup>
import { onBeforeUnmount } from 'vue'

const sortable = useSortable(props, { emit })

// Automatic cleanup when component is destroyed is handled internally,
// so manual cleanup is usually not necessary
</script>

Related Packages

  • sortablejs - Core SortableJS library
  • @fastkit/helpers - Utility functions
  • @fastkit/vue-utils - Vue.js development utilities

License

MIT