Sorting Turbo Frame content with SortableJS

Hello,

I want to use SortableJS to order elements within a turbo frame. The Sortable object is created with the .sortable element.

<div class="sortable">
  <turbo_frame id="sortable-items">
    <div class="sort-item">...</div>
    <div class="sort-item">...</div>
    <div class="sort-item">...</div>
    <div class="sort-item">...</div>
  </turbo_frame>
</div>

Sorting works fine without the turbo frame, but once I wrap the sortable items in the turbo frame it stops working. I think the turbo frame is not propagating the dragover event. I added dragover event listeners to the .sortable, .sortable-item and turbo_frame elements but only .sortable-item elements receive the event.

How do I get the turbo_frame to propagate the dragover event? I don’t want to place .sortable inside the turbo_frame just to avoid the issue.

Thanks!

Hi @Bevan, are you using a turbo-frame to lazy load the items?

Hi @brendon, no I am not lazy-loading any content.

After some further investigation, I think the issue might be an interaction issue between Stimulus and SortableJS, especially when used in conjunction with Turbo Drive.

Originally I was initializing SortableJS within a Stimulus controller, and with the turbo_frame included dragging the items doesn’t work.

<div class="sortable" data-controller="sortable" data-sortable-draggable-value='.drag-item'>
  <turbo-frame id="sortable-items">
      <div class="drag-item">A</div>
      <div class="drag-item">B</div>
      <div class="drag-item">C</div>
      <div class="drag-item">D</div>
  </turbo-frame>
</div>

I believe that SortableJS is initalized correctly in the Stimulus controller. I have checked that the Sortable instance has ‘.drag-item’ set for the draggable property:

export default class extends Controller {
    static values = {
        draggable: String
    }

    connect() {
        this.sortable = Sortable.create(this.element, {
          draggable: this.hasDraggableValue ? this.draggableValue : null
        });
    }
}

But if I initialize SortableJS inside a script tag instead of in the controller, the dragging works as expected:

<div class="sortable">
  <turbo-frame id="sortable-items">
    <div class="drag-item">A</div>
    <div class="drag-item">B</div>
    <div class="drag-item">C</div>
    <div class="drag-item">D</div>
  </turbo-frame>
</div>

<script>
    let sortableElement = document.getElementsByClassName('sortable')[0];

    let sortable = Sortable.create(sortableElement, {
        draggable: '.drag-item'
    });
</script>

Any ideas about what the cause might be and how to resolve it?

Have you probed this.draggableValue to make sure it’s getting through to the connect function? I assume it’s fine, but I’ve been caught out so many times with typo’s and forgetting to add Value to the end etc… Also, check that your controller is even mounting at all.

Are you using a turbo-frame because the items are editable and you want to replace the list with the editing interface then back to the list again? Just trying to figure out your use case here.

Maybe as brendon says make sure this in this.draggableValue is indeed Stimulus.

Although i am pretty sure turbo-frames don’t interefere. I actually make turbo-frames draggable. Though the class is hardcoded, but I would not use a Value but rather classic Dataset.

Here the beginning of my Stimulus controller

import { Controller } from "@hotwired/stimulus"
import Sortable from 'sortablejs';

export default class extends Controller {

  // This controller will allow reorder the cards in the forms, not the resume

  connect(){
    this.elementsSortable(this.element)    
  }

  elementsSortable(element){

    let stimulusElement = this 
    let container = element 

    element.sortable = Sortable.create(container,{
      animation: 150,
      draggable: ".draggable",
      ghostClass: "",
      chosenClass: "",
      dragClass: "sortable-ghost",
      forceFallback: false,
      dragoverBubble: true,
      handle: ".section-card__grippy",
      onEnd: function(event){
        var recArray = Array.from(container.children).map(x => x.id);    
        stimulusElement.orderUpdater(recArray);     
      }
    })
  }

As you see, instead of binding this which I fund unintuitive, I sometimes declare const Stimulus = this at the beginning of my stimulus controller and use it later

And how is it called :

index template :

<%= turbo_frame_tag "certifications_list",  data: {"controller":"ResumeDataSortable"} do %>
  <%= render certifications.order(card_order: :asc) %>
<% end %>

And now the beginning of the _certification.html.erb partial :

<%= turbo_frame_tag dom_id(certification), class:"draggable" do %>
  <div class="section-card my-4" data-controller="interaction" >
    <div class="section-card__content">