Max Gfeller All Articles
16.3.2021

Developing A Vue 3 Headless Component

Development
Web
Vue

For a Vue 3 project i'm currently working on i needed a file selector component, where users can select and upload files. I needed this in multiple parts of the project, so i developed a component for it.

In its empty state it presented you with a dropzone, where users could drag files into, and a button which opens the systems file dialog to manually select files.

Empty file selector

Selected files were displayed with a small icon that represents the file type and the possiblity to remove them again.

File selector with to selected files

It was all styled with TailwindCSS, ready to be used throughout the project. It worked really well and i included it in multiple parts of the project.

However, there is a point in the application where users can submit an issue. They can give it a title, some description and then attach files (mostly screenshots) to it. Of course the file selector component can just be included below the text field, but wouldn't it look nicer if it was integrated into the field itself?

Because integrating it into the form in any other way is now basically impossible with the existing file selector component, i decided to take the opportunity and try to refactor the component into a headless one.

What

The concept of headless components, as far as i know, started in 2017 with a library called Downshift. Its goal was to provide a set of primitives to build flexible and accessible autocomplete, combobox and select components using React. I personally only really learned about the concept when Tailwind Labs introduced Headless UI, a set of unstyled, accessible UI components, available for Vue and React.

Headless components by itself implement all the functionality needed, but come without styling. Developers using these components then have to style them by themselves. Check out this example from Headless UI.

Why

Even if it requires more effort to integrate into your application than a already styled component, it makes headless components more accessible. In the past, standard components like a dropdown menu or a select box were implemented for each JavaScript UI library and then often also for each popular CSS framework. Those components also greatly differed in quality and were often very specific in how they could be used.

Also, developers tend to underestimate the work required to make a seemingly simple component: the different states, keyboard navigation, focus handling as well as proper accessiblity.

We didn't want to add 200 lines of gnarly JS to every component example, so we started working on Headless UI as a way to extract all of that noise, without giving up any flexibility in the actual UI design.

Adam Wathan, Tailwind Labs

I imagine the future of web development to be this: a set of standard components, decoupled from styling, ready to be used in any way, providing a great set of defaults and good accessiblity for everyone.

How

After deciding on re-factoring my component as a headless one, i first have to decide on the API. As far as i can see, the component consists of those sub components:

  • A dropzone where files can be dragged into
  • A button that opens the systems file selection dialog
  • An actual <input type="file"> (which is hidden)

And of course one wrapper component, which i'm simply naming FileSelector.

To me it's already clear that i want the component to have a v-model attribute consisting of the selected files. In Vue 3, this can be achieved with the modelValue prop:

<script>
export default {
  emits: ['update:modelValue'],
  props: {
    modelValue: {
      type: Array,
      required: true
    }
  }
}
</script>

The event used to update the modelValue prop is called update:modelValue, so i declared it in the components emits property.

The wrapper FileSelector component should also already render the file input element, which needs to be hidden because it can't be styled at all:

<template>
  <input
    style="display: none;"
    type="file"
    @change="updateFiles"
  >
  <slot />
</template>

The updateFiles method is also implemented in the wrapper component and looks like this:

const updateFiles = () => {
  emit('update:modelValue', [...props.modelValue, ...inputRef.value.files])
}

I want to separate the components from each other, because it should be optional to use the dropzone or the button component. That means instead of one component there will now be three different ones and because the logic happens in the wrapper component i have to inject a few methods into the sub components using the Vue 3 provide function:

const inputRef = ref(null)
const openDialog = () => {
  inputRef.value.click()
}

provide('openDialog', openDialog)

provide('addFiles', (files) => {
  emit('update:modelValue', [...props.modelValue, ...files])
})

In the DialogButton component i then can inject the openDialog method like this:

const openDialog = inject('openDialog')

The whole drag and drop logic is then implemented in the Dropzone component:

<template>
  <div
    @dragover="handleDragover"
    @drop.stop.prevent="handleDrop"
    @dragleave="handleDragLeave"
  >
    <slot :hovered="hovered" />
  </div>
</template>
<script>
import { inject, ref } from 'vue'
export default {
  setup () {
    const hovered = ref(false)
    const addFiles = inject('addFiles')

    const handleDrop = (evt) => {
      hovered.value = false
      const files = Array.from(evt.dataTransfer.items).map((item) => {
        if (item.kind !== 'file') return null
        return item.getAsFile()
      }).filter(Boolean)

      addFiles(files)
    }

    const handleDragover = (evt) => {
      evt.preventDefault()
      hovered.value = true
    }

    const handleDragLeave = () => {
      hovered.value = false
    }

    return {
      hovered,
      handleDragover,
      handleDragLeave,
      handleDrop
    }
  }
}
</script>

As you can see, the hovered attribute is passed to the slot as a prop.

This already looks pretty good but when trying to use the component, i realized something: a headless button component is not really helpful when you already have an existing button component in your project. That's why the FileSelector wrapper component now provides a openDialog prop to its slot content:

<template>
  <input
    style="display: none;"
    type="file"
    @change="updateFiles"
  >
  <slot :openDialog="openDialog" />
</template>

Now developers can easily use their own buttons like this:

<x-button @click="openDialog">Add files</x-button>

Result

Using this new headless component i can also finally integrate it a bit nicer into the aforementioned feedback screen:

And with that our little refactoring is done. The published library can be found here and an example how it can be used can be found here. I hope this little step by step guide was useful and maybe even inspires you to use and implement your own headless components.

✌🏻 Did you like this article? Follow me on Twitter for more content!