Skip to content

FreeformDropZone

Enables cross-list drag & drop between multiple TheFreeform instances.

Basic Usage

vue
<FreeformDropZone id="list-a" :accept="acceptFn">
  <template #default="{ isOver, isAccepted }">
    <div :class="{ 'bg-green-100': isOver && isAccepted, 'bg-red-100': isOver && !isAccepted }">
      <TheFreeform v-model="listA" drop-zone-id="list-a" @drop-to-zone="onDropToZone">
        <FreeformItem v-for="item in listA" :key="item.id" :item="item" />
        <FreeformPlaceholder />
      </TheFreeform>
    </div>
  </template>
</FreeformDropZone>

Props

PropTypeDefaultDescription
idstringauto-generatedUnique zone identifier
accept(items) => boolean-Validate if drop is allowed

Slot Props

Default Slot

vue
<FreeformDropZone id="my-zone">
  <template #default="{ isOver, isAccepted }">
    <!-- Your content -->
  </template>
</FreeformDropZone>
PropTypeDescription
isOverbooleanItems are being dragged over this zone
isAcceptedbooleanDrop would be accepted

Connecting TheFreeform to a Zone

Use the drop-zone-id prop on TheFreeform to connect it to a FreeformDropZone:

vue
<FreeformDropZone id="list-a">
  <TheFreeform v-model="items" drop-zone-id="list-a">
    <!-- items -->
  </TheFreeform>
</FreeformDropZone>

Handling Cross-Zone Drops

The @drop-to-zone event fires when items are dropped from another zone:

ts
function onDropToZone(
  items: FreeformItemData[],  // Dropped items
  zoneId: string,             // Target zone ID
  index: number,              // Drop position in list
  containerId: string | null  // Container ID if dropped into container
) {
  if (containerId) {
    // Handle drop into container
    console.log(`Dropped into container ${containerId}`)
  } else {
    // Handle drop into zone
    moveItems(items, zoneId, index)
  }
}

Hierarchical Accept

When using FreeformDropZone with containers inside, the accept logic is hierarchical:

  • Zone accept: Only checked for direct drops into the zone
  • Container accept: Checked when dropping into a container inside the zone

This allows patterns like "zone accepts only cards, but cards (containers) accept controls":

vue
<FreeformDropZone id="dashboard" :accept="acceptOnlyCards">
  <TheFreeform v-model="cards" drop-zone-id="dashboard" @drop-to-zone="onDropToZone">
    <FreeformItem
      v-for="card in cards"
      :key="card.id"
      :item="card"
      :accept="acceptOnlyControls"
    />
    <FreeformPlaceholder />
  </TheFreeform>
</FreeformDropZone>
ts
// Zone accepts only cards directly
function acceptOnlyCards(items: FreeformItemData[]) {
  return items.every(i => i.type === 'card')
}

// Containers (cards) accept only controls
function acceptOnlyControls(items: FreeformItemData[]) {
  return items.every(i => i.type === 'control')
}

Key behavior: Items dragged to a container bypass the zone's accept - only the container's accept is checked. This enables complex nested accept patterns.

Complete Example

vue
<script setup lang="ts">
const listA = ref([
  { id: 'a1', name: 'Apple' },
  { id: 'a2', name: 'Banana' },
])

const listB = ref([
  { id: 'b1', name: 'Cherry' },
  { id: 'b-folder', name: 'Folder', type: 'container' },
])

function onDropToZone(items, zoneId, index, containerId) {
  // Remove from source list
  listA.value = listA.value.filter(i => !items.some(t => t.id === i.id))
  listB.value = listB.value.filter(i => !items.some(t => t.id === i.id))

  if (containerId) {
    console.log(`Dropped into container: ${containerId}`)
    return
  }

  // Add to target list at index
  if (zoneId === 'list-a') {
    listA.value.splice(index, 0, ...items)
  } else if (zoneId === 'list-b') {
    listB.value.splice(index, 0, ...items)
  }
}
</script>

<template>
  <div class="flex gap-4">
    <FreeformDropZone id="list-a" class="flex-1">
      <template #default="{ isOver, isAccepted }">
        <div
          class="p-4 rounded border-2"
          :class="{
            'border-gray-200': !isOver,
            'border-green-500 bg-green-50': isOver && isAccepted,
            'border-red-500 bg-red-50': isOver && !isAccepted,
          }"
        >
          <h2>List A</h2>
          <TheFreeform
            v-model="listA"
            drop-zone-id="list-a"
            class="flex flex-wrap gap-2"
            @drop-to-zone="onDropToZone"
          >
            <FreeformItem v-for="item in listA" :key="item.id" :item="item" />
            <FreeformPlaceholder />
          </TheFreeform>
        </div>
      </template>
    </FreeformDropZone>

    <FreeformDropZone id="list-b" class="flex-1">
      <template #default="{ isOver, isAccepted }">
        <div
          class="p-4 rounded border-2"
          :class="{
            'border-gray-200': !isOver,
            'border-green-500 bg-green-50': isOver && isAccepted,
            'border-red-500 bg-red-50': isOver && !isAccepted,
          }"
        >
          <h2>List B</h2>
          <TheFreeform
            v-model="listB"
            drop-zone-id="list-b"
            class="flex flex-wrap gap-2"
            @drop-to-zone="onDropToZone"
          >
            <FreeformItem v-for="item in listB" :key="item.id" :item="item" />
            <FreeformPlaceholder />
          </TheFreeform>
        </div>
      </template>
    </FreeformDropZone>
  </div>
</template>

MIT Licensed