Recursive Expandable/Collapsible Menu in Vue.js

If you’ll forgive the crude styling of the Codepen above, I’d like to go over a pattern I’ve found myself using in Vue time and time again: The dynamic recursive menu.

One of the main strengths of such a menu is its ability to be created from a set of data for which you do not know the depth nor all of the keys involved.

You can see this structure pretty well in the dummy data I’m using to build the menu:

const dummyData = { "Video Game Consoles": { "Nintendo Entertainment System": { "Games": ["Battletoads", "Contra", "Adventures of Lolo", "Castlevania"], "Consoles": ["Gen 1", "Gen 2"] }, "Super Nintendo Entertainment System": { "Games": ["Chrono Trigger", "Legend of Zelda, A link to the Past", "Final Fantasy III/VI"], "Consoles": ["Gen 1", "Gen 2"] }, "Sega Genesis": { "Games": ["Sonic", "Sonic And Knuckles", "Gunstar Heroes"], "Consoles": ["Gen 1", "Gen 2"] } } }
Code language: JavaScript (javascript)

Because we’re dealing with a recursive component there’s really only one Vue component doing all of the work in this code. Since it’s recursive, it calls itself within the template.

Vue.component('node',{ props: ['data', 'depth'], data: function() { return { expanded: {} } }, computed: { menuItems: function() { if (Array.isArray(this.data)) { return this.data; } else { return Object.keys(this.data); } }, }, methods: { toggle: function(item) { Vue.set(this.expanded, item, !this.expanded[item]); }, hasChildren: function(item) { let isObject = typeof(this.data[item]) === 'object'; if (!isObject) { return false; } let hasChildren = Object.keys(this.data[item]).length > 0; return ( isObject && hasChildren ) } }, template: `<div :style="{'margin-left': depth * 8 + 'px'}"> <div v-for="item in menuItems"> <span v-if="hasChildren(item)">+ </span> <a href="#" @click="toggle(item)">{{item}}</a> <div v-if="expanded[item] && hasChildren(item)"> <node :data="data[item]" :depth="depth + 1"></node> </div> </div> </div>` })
Code language: JavaScript (javascript)

I’ll break down what it’s doing piece by piece

First let’s start with the template. This is where the recursive call takes place. Notice the node element and the wrapping div which will only display if an internal expanded property for that value is set as checked by the v-if attribute.

By default nothing is expanded which allows only iterating to the next node of a branch if the anchor text has been clicked activating the toggle(item) method.

`<div :style="{'margin-left': depth * 8 + 'px'}"> <div v-for="item in menuItems"> <span v-if="hasChildren(item)">+ </span> <a href="#" @click="toggle(item)">{{item}}</a> <div v-if="expanded[item] && hasChildren(item)"> <node :data="data[item]" :depth="depth + 1"></node> </div> </div> </div>`
Code language: HTML, XML (xml)

Here is some of the data that template is interacting with. Note the computed menuItems property. This checks if a value is an array or an object to decide what data to return. In the case of objects we want to display their keys and in the case of arrays we want to display their values. Note the arrays in the original dummy data.

props: ['data', 'depth'], data: function() { return { expanded: {} } }, computed: { menuItems: function() { if (Array.isArray(this.data)) { return this.data; } else { return Object.keys(this.data); } }, },
Code language: JavaScript (javascript)

I’d also like to mention the data property we pass in. Whenever we call a new node we pass in the next deepest set of data. Notice in the nodes in the template above we call data[item] which would be the next deepest node by the current key we’re iterating over described as item and is incidentally the string we click in the menu to toggle the expand/collapse

The depth property is wholly optional. I only use this have a multiplier for a margin in the CSS styling to allow each depth of the menu tree to appear in a different column which makes the experience much more usable. You can see this in the weird :style attribute in the template

Finally let’s discuss the methods we use to interact with this component:

methods: { toggle: function(item) { Vue.set(this.expanded, item, !this.expanded[item]); }, hasChildren: function(item) { let isObject = typeof(this.data[item]) === 'object'; if (!isObject) { return false; } let hasChildren = Object.keys(this.data[item]).length > 0; return ( isObject && hasChildren ) } },
Code language: JavaScript (javascript)

We have a toggle(item) method which simply sets the internal expanded object to be the opposite of what it is now for the item we clicked. This expanded object is used to keep track of what nodes are hidden/visible based on what has been clicked.

Note here the use of Vue.set(). This is used to ensure that when we update the expanded object by key Vue is able to do so reactively.

The hasChildren(item) method simply checks if the current node is an object which has children. If not there is no use displaying an icon that shows it can be expanded. We denote this in this code by a span element in the template with a ‘+’ in the slot which is visible/hidden based on the result of this method per-item.

And that’s all there is to it. It’s a very straightforward process and modifications can be made for styling, additional info like your current ‘path’, an emit based bus to allow the child-most component to communicate with the parent and beyond. All use cases I’ve encountered a few times. But also code for another day

CATEGORIES:

Tags:

No Responses

Leave a Reply

Your email address will not be published.