DEV Community

Daniel P 🇨🇦
Daniel P 🇨🇦

Posted on • Edited on

Vue3 Composition API - Creating a dragable element

Vue3 Composition API - Take 2

My initial fumbling around with the newly released Vue3 (pre-release version) had not gone well. In short, I've made some silly mistakes and hadn't done nearly enough of reading before starting. Now, after couple more days, I wanted to give an update on my progress in a form of a how-to.

The Goal:

Break down dragable component using the Composition API in Vue3

I've chosen to do this, because dragging a component requires the following:

  • reactive values
  • computed values
  • watch
  • event listeners

Previously I've done similar things with Higher Order Components or Mixins. Either way, I got it to work, and there is nothing in this release that will unlock functionality that was not available before, but it allows us to do things with better ease and code maintainability.

The Plan

The idea in this experiment is to separate the dragging functionality out of the component, so that we can call a function and pass the returned values to the template. The component code should look something like this:

// reusable function
const makeDragable = element => {
  // create reactive object
  const position = reactive({x: 0, y: 0, /*etc...*/ });

  // compute style
  const style = computed(() => {
    // To Be Implemented (TBI)
    return {};
  });

  // create  mouse interaction functions
  const onMouseDown = e => {/* TBI */};
  const onMouseMove = e => {/* TBI */};
  const onMouseUp = e => {/* TBI */};

  // assign mousedown listener
  element.addEventListener("mousedown", onMouseDown);

  // return objects
  return { position, style };
}

// my component
const MyComponent = Vue.createComponent({
  setup() {
    const { position, style } = makeDragable(el);
    return { position, style };
  },
  template: document.getElementById("myComponent").innerHTML
});

This shows the beginnings of what this code for the reusable function and the component may look like. The problem is that el is not defined, and if we were to define it, it would be null, since the component doesn't get mounted until after setup executes.

The way to deal with this, is to create a reference (ref) to a reactive value that the template will render.

const MyComponent = Vue.createComponent({
  setup() {
    // create reactive reference variable el
    const el = ref(null);
    // send el to function to assign mouse listeners
    const { position, style } = makeDragable(el);
    // pass el to template
    return { el, position, style };
  },
  template: document.getElementById("myComponent").innerHTML
});

Then we can pass it to the template using (ref="el")

<template id="myComponent">
  <div ref="el" :style="style">
    <h3>DRAG ME</h3>
    <pre>{{ position }}</pre>
  </div>
</template>

This will create a reactive reference for variable el and initialize it as null and send (return) it for use in the template. The template assigns the reference to the div in the template.
At this point the el in the makeDragable function changes from null to an HTMLElement. If we were to assign listeners on first run, it would fail because the element is not mounted and the el variable is null. In order to assign the listeners to the element, I used a watch that will assign the functionality once the value changes

The Code

The code uses the vue3 pre-release code current at the time of writing. The steps to generate can be found on the vue3 page on my previous post.

// reusable function
const makeDragable = element => {
  const position = reactive({x: 0, y: 0, /*etc...*/ });

  // compute style
  const style = computed(() => {
    // To Be Implemented (TBI)
    return {};
  });

  const onMouseDown = e => {/* TBI */};
  const onMouseMove = e => {/* TBI */};
  const onMouseUp = e => {/* TBI */};

  // Add a watch to assign the function when it changes, and is an instance of HTMLElement
  watch(element, element => {
    if (!element instanceof HTMLElement) return;
    element.addEventListener("mousedown", onMouseDown);
  }

  // return objects
  return { position, style };
}

Fill in the owl

As far as the Composition API implementation goes, this pretty much finishes it off. The rest is just implementing the mouse interaction which I'm including in the full code at the end. It can also be seen in this jsFiddle

In this case, I'm using a single component, so the benefit may not be clear. The idea is that I could easily create other components that use this functionality. In this jsFiddle I've split the position and style into separate functions, so that I can create a different style for the svg elements. With minor modifications, I can have a dragable HTMLElement or SVGGraphicsElement.

Notes

Here is a list of things I've come across while working on this

  • template ref and JavaScript ref are not the same.
    • the template ref allows referencing DOM elements. In Vue2 this would be a string that can be then referenced using vm.$refs. The composition-api plugin for Vue2 cannot handle it the same way as Vue3 and requires a render function or jsx. In Vue3, the concept has been unified, so even though the function of the two differs they work together and the ref expects a defined object instead of a string.
  • ref is like reactive but not the same
    • ref is a useful for a single property. In this case we're interested in creating a single element for assignment and watching for changes.
    • reactive is useful when you have multiple properties, like the position parameters, which are tied together
  • watch is a lifecycle hook for component fragments
    • use watch to handle the equivalent of updated and beforeUnmount
    • watch accepts an onCleanup parameter that fires between beforeUnmount and unmounted of the component
  • lifecycle methods seemed to have changed
    • Vue3 currently supports
    • beforeMount
    • mounted
    • beforeUpdate
    • updated
    • beforeUnmount
    • unmounted
    • The following lifecycle hooks from Vue2 are currently (at the time of writing) not available.
    • beforeCreate
    • created
    • activated
    • deactivated
    • beforeDestroy
    • destroyed
    • errorCaptured
  • Vue dev tools don't work with Vue3 yet

Code

It uses a compiled IIFE Vue dependency, that this article shows how I generated

Template

<div id="app"></div>

<!-- APP Template -->
<template id="appTemplate">
  <!-- one component -->
  <my-component>
    <!-- nested child component -->
    <my-component></my-component>
  </my-component>
</template>

<!-- myComponent Template -->
<template id="myComponent">
  <div ref="el" class="dragable" :style="style">
    <h3>DRAG ME</h3>
    <pre>{{ position }}</pre>
    <pre>{{ style }}</pre>
    <slot></slot>
  </div>
</template>

<style>
.dragable {font-family: "Lucida Sans", Geneva, Verdana, sans-serif;width: 40%;max-width: 90%;min-width: 320px;min-height: 6.5em;margin: 0;color: rgb(6, 19, 29);background-color: rgb(187, 195, 209);border-radius: 16px;padding: 16px;touch-action: none;user-select: none;-webkit-transform: translate(0px, 0px);transform: translate(0px, 0px);transition: transform 0.1s ease-in, box-shadow 0.1s ease-out;border: 1px solid rgb(6, 19, 29);} pre { width: 48%; display: inline-block; overflow: hidden; font-size: 10px; }
</style>

JS

const { reactive, computed, ref, onMounted, watch } = Vue;

const makeDragable = element => {
  const position = reactive({
    init: false,
    x: 0,
    y: 0,
    width: 0,
    height: 0,
    isDragging: false,
    dragStartX: null,
    dragStartY: null
  });

  const style = computed(() => {
    if (position.init) {
      return {
        position: "absolute",
        left: position.x + "px",
        top: position.y + "px",
        width: position.width + "px",
        height: position.height + "px",
        "box-shadow": position.isDragging
          ? "3px 6px 16px rgba(0, 0, 0, 0.15)"
          : "",
        transform: position.isDragging ? "translate(-3px, -6px)" : "",
        cursor: position.isDragging ? "grab" : "pointer"
      };
    }
    return {};
  });

  const onMouseDown = e => {
    let { clientX, clientY } = e;
    position.dragStartX = clientX - position.x;
    position.dragStartY = clientY - position.y;

    position.isDragging = true;

    document.addEventListener("mouseup", onMouseUp);
    document.addEventListener("mousemove", onMouseMove);
  };

  const onMouseMove = e => {
    let { clientX, clientY } = e;
    position.x = clientX - position.dragStartX;
    position.y = clientY - position.dragStartY;
  };

  const onMouseUp = e => {
    let { clientX, clientY } = e;
    position.isDragging = false;
    position.dragStartX = null;
    position.dragStartY = null;
    document.removeEventListener("mouseup", onMouseUp);
    document.removeEventListener("mousemove", onMouseMove);
  };

  watch(element, (element, prevElement, onCleanup) => {
    if (!element instanceof HTMLElement) return;
    let rect = element.getBoundingClientRect(element);

    position.init = true;
    position.x = Math.round(rect.x);
    position.y = Math.round(rect.y);
    position.width = Math.round(rect.width);
    position.height = Math.round(rect.height);

    element.addEventListener("mousedown", onMouseDown);

    onCleanup(() => {
      // do cleanup
    })
  });

  return {
    position,
    style
  };
};

const MyComponent = Vue.createComponent({
  setup(props) {
    const el = ref(null);
    const { position, style } = makeDragable(el);

    return {
      el,
      position,
      style
    };
  },
  template: document.getElementById("myComponent").innerHTML
});

const App = {
  template: document.getElementById("appTemplate").innerHTML
};

const app = Vue.createApp({});
app.component("my-component", MyComponent);
app.mount(App, "#app");

Top comments (3)

Collapse
 
istng profile image
Iván Gk

thanks man, will be trying to make it responsiveish

Collapse
 
imaginativeone profile image
Doug Franklin

Does this work?

Collapse
 
istng profile image
Iván Gk

it does for me, currently trying to make it responsive... found out that setting the position.x on the watch function allows me to set an offset-like initial poisition