Building a grid component in Vue.js

Hari Narasimhan
5 min readMay 21, 2017

--

Grid components are notoriously complex to build. There are so many aspects and it takes forever to build a perfect one. Having said that, building a grid for your specific needs should not be that difficult.

In this article, we will explore how to build a grid component “Griddy” in Vue.js. The overall idea is to explore ways of building this component, so that people can look at their specific use cases and extent it.

Warning

The component is by no means production ready, this is strictly for educational purpose only.

Concepts we will explore.

We will explore the following concepts, they are just a minimum set and we will be just scratching the surface.

  • Fixed Header
  • Resize columns
  • Fetching data
  • Navigation

Why Vue.js?

I simply love it, it is fun to code in Vue.js, it extends itself very well for developing UI components.

We will use the .vue file for defining components. The .vue file will be split into template, script and style sections.

The Markup

<template>
<div class="wrapper" :style="{width: width, height: height + 'px'}">
<div class="grid-header">
<span class="title">{{title}}</span>
</div>
<div class="grid-body" :style="{height: tableBodyHeight + 'px'}">
<div class="table-header-wrapper">
<table class="table-header">
<tr>
<th v-for="column in columns" :data-column-name= "column.name" :width="column.width ? column.width : '100'"> {{column.label}}</th>
</tr>
</table>
</div>
<div class="table-body-wrapper">
<table class="table-body">
<tr v-for="row in rows">
<td v-for="column in columns" :width="column.width ? column.width : '100'"> {{val(row, column.field)}}</td>
</tr>
</table>
</div>
</div>
<div class="grid-footer">
<div v-if="!error">
<span><a @click="handlePrevious">Previous</a></span>
<span>{{page}}</span>
<span><a @click="handleNext">Next</a></span>
</div>
<span v-else>{{error}}</span>
</div>
</div>
</template>

The template has a wrapper to hold the grid component. The wrapper has a its overflow-y set to scroll. The wrapper contains grid-header, grid-body and grid-footer.

The grid-header can be used for displaying title, holding buttons etc, our example just has the title. The footer can be utilised for pagination controls. In our example the footer has a previous and next link and displays current page number in the centre.

The grid body, contains two tables. The two tables are required to create a fixed header effect. The technique for this has been adapted from the article “The Holy Grail: Pure CSS Scrolling Tables With Fixed Header” by Josh Marinacci. I am not going to explain the CSS in detail, I request the reader to wade through the code and Josh’s article.

The code

The Vue.js component takes up the following properies

  • title — Title for the grid
  • columns — Metadata for the columns containing name, field to pluck from response and column width
  • width — Width of the grid
  • height — Height of the grid
  • resourceURL — API endpoint to fetch the data

When the component is mounted, we initialise the component

mounted () {
const vm = this
vm.header = vm.$el.getElementsByClassName('table-header')[0]
vm.body = vm.$el.getElementsByClassName('table-body')[0]
vm.setResizeGrips()
vm.syncColumnWidths()
},

The reference to table-header and table-footer are stored in the component. The setResizeGrips method as its name suggests adds the resize grips to the header table. The syncColumnWidths method synchronises the column widths between the header table and body table. As you will see, syncColumnWidths will be called each time the header column is resized.

setResizeGrips () {
const vm = this
const headerCols = Array.from(vm.header.getElementsByTagName('th'))
headerCols.forEach((th) => {
th.style.position = 'relative'
const grip = document.createElement('div')
grip.className = 'grip'
grip.innerHTML = '&nbsp'
grip.style.top = 0
grip.style.right = 0
grip.style.bottom = 0
grip.style.width = '5px'
grip.style.position = 'absolute'
grip.style.cursor = 'col-resize'
grip.addEventListener('mousedown', this.onMouseDown)
th.appendChild(grip)
vm.grips.push(grip)
})
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
}

The mouse events are attached to each grip and the document.

syncColumnWidths () {
const vm = this
const headerCols = Array.from(vm.header.getElementsByTagName('th'))
const widths = headerCols.map((h) => h.width ? h.width : h.clientWidth)
const bodyCols = Array.from(vm.body.querySelectorAll('tr:first-child>td'))
bodyCols.forEach((c, i) => {
c.width = widths[i] + 'px'
})
}

Mouse events

onMouseDown (e) {
const vm = this
vm.thElm = e.target.parentNode
vm.startOffset = vm.thElm.offsetWidth - e.pageX
},
onMouseMove (e) {
const vm = this
if (vm.thElm) {
const colName = vm.thElm.getAttribute('data-column-name')
const width = vm.startOffset + e.pageX
if (vm.greaterThenMinWidth(colName, width)) {
vm.thElm.width = width + 'px'
vm.syncColumnWidths()
}
}
},
onMouseUp (e) {
const vm = this
vm.thElm = undefined
vm.syncColumnWidths()
}

The code for syncing the header table and body table was inspired from jQuery colResizable plugin.

Since we have added event listeners to DOM elements, we have to be responsible enough to unregister them when the component is destroyed. We add the code in beforeDestroy hook. We had cached the grips earlier, we will use the same to remove event listeners.

beforeDestroy () {
const vm = this
vm.grips.forEach((grip) => grip.removeEventListener('mousedown', vm.onMouseDown))
document.removeEventListener('mousemove', vm.onMouseMove)
document.removeEventListener('mouseup', vm.onMouseUp)
}

The data fetching us done using the vue-resource plugin and the resourceURL property. This is very rudimentary, for a robust implementation one must implement the concept of a store to abstract the data fetches.

Our data fetching is simple

fetchData () {
this.resource.get({results: this.records, page: this.page}).then((response) => {
this.rows = response.body.results
}).catch(() => {
this.error = 'Error occured while trying to fetch data'
})
}

I am not going to explain the helper methods, like handlePrevious click etc they are very simple, kindly follow the code.

Though I have not explicitly shown how the component can be used by the consumer, it is very easy and straightforward.

<griddy :columns="columns"
:resourceURL = "resourceURL"
width="200"
height="500">
</griddy>

Where columns contains the column meta data and resource url contains API end point to query

resourceURL: 'https://randomuser.me/api',
columns: [
{name: 'title', label: 'Title', field: 'name.title', width: '100', type: 'text', isMandatory: true},
{name: 'firstName', label: 'First Name', field: 'name.first', width: '200', type: 'text', isMandatory: true},
{name: 'lastName', label: 'Last Name', field: 'name.last', width: '200', type: 'text', isMandatory: true},
{name: 'dataOfBirth', label: 'Date of Birth', field: 'dob', type: 'date', width: '200', format: 'yyyy-MM-dd'}
]

Result

Something pretty neat for 200+ lines of HTML, JS and CSS, there is a huge scope for improvement. I recently read a data grid design article by Andrew Coyle titled “ Design Better Data Tables”, it was a comprehensive set of design patterns for data tables. I wish someday that our humble grid control implements all those patterns.

Griddy in action

I hope this gave you an insight into how a grid component can be constructed. Please feel free to drop in some comments for suggestions, improvements etc.

The code for the grid component is available at https://github.com/harin76/griddy

Acknowledgements & Credits

  1. Josh Marinacci — For The Holy Grail: Pure CSS Scrolling Tables With Fixed Header
  2. Alvaro Prieto Lauroba — colResizable jQuery Plugin
  3. VanillaJS — DOM manipulations
  4. Andrew Coyle — Design Better Data Tables
  5. randomuser.me — REST API for testing.

--

--

Hari Narasimhan
Hari Narasimhan

Written by Hari Narasimhan

Inspired by interesting things around me!

Responses (2)