Javascript Treeview Controls: Devil in the <DETAILS>
Treeview controls were once a staple of desktop applications. The ability to drill down into a hierarchy of information, and to open and close "folders" made organization of content that may have thousands of entries feasible.
Yet as applications have shifted to the web, the once common treeview control has become more rare. Part of this can be attributed to shifting design styles for web pages and web applications that have favored activity streams over hierarchical content, some of it has to do with a move towards data binding facilities that don't necessarily work well with recursive data. A lot of this disfavor comes down to the complexity in creating dynamic treeviews.
Getting Into the <DETAILS>
However, a new set of tags in HTML5, and a bit of creative CSS work, can make treeview controls surprisingly easy to build. In particular, the <details> and <summary>elements can be nested in much the same way as <ul> (or <ol>) and <li> list tags, but have the advantage of allowing users to show or hide most of a section within a <details> block except for the content of a <summary> child element, as shown in Figure 1.
Figure 1. A <details> and <summary> set.
This would be encoded as follows:
<details>
<summary>A visible tag</summary>
<div>Anything that isn't the summary remains hidden until either
the item is clicked or some kind of toggle is selected.</div>
</details>
Listing 1. A simple <details> element
This simple building block can go a long way towards handling some of the more problematic event management that treeviews require, and can do so more efficiently (when native) than is typically the case with Javascript components. For instance, with no Javascript code, it is possible to build a basic static data tree.
<div class="treeview">
<details>
<summary id="root">Root</summary>
<details>
<summary id="item-1">Item 1</summary>
<details>
<summary id="item-1-1">Item 1.1</summary>
<details>
<summary id="item-1-2">Item 1.2</summary>
<details>
<summary id="item-1-2-1">Item 1.2.1</summary>
</details>
</details>
</details>
<details>
<summary id="item-1-2">Item 1.2</summary>
</details>
</details>
<details>
<summary id="item-2">Item 2</summary>
<details>
<summary id="item-2-1">Item 2.1</summary>
</details>
<details>
<summary id="item-2-2">Item 2.2</summary>
<details>
<summary id="item-2-2-1">Item 2-2-1</summary>
</details>
</details>
</details>
</details>
</div>
Listing 2. An HTML-only treeview
Figure 2. A rendered static treeview
Making this functional then requires only one more step, a bit of Javascript to capture selection events:
document.querySelector(".treeview").addEventListener("click",(event)=>{
var node=event.target;
if (node.nodeName == "SUMMARY"){
console.log(`${node.textContent} [${node.id}]`);
}
})
Listing 3. Getting the "click" event on a line in the tree.
Now, it's worth noting that this is a basic control - when you click on an entry, it will display a focus only as long as the broader control has the focus. It's also likely that the data that you will want to display will not be known up front, so there has to be a way of loading in content from external files. Finally, this doesn't necessarily give you a lot of detail about what you're selecting. That's why it's worth creating a Javascript class that will give you these kinds of functionality without having to do a huge amount of work in actually managing the tree.
<DETAILS> Oriented
What's needed here is to provide the mechanisms to both create the <details>/<summary> sections from basic data and to handle some very basic bookkeeping tasks. That's the role of the Treeview class:
class Treeview {
constructor(treeviewId,imageBaseUrl){
this.treeviewId = treeviewId;
this.selected=null;
this.imageBase=imageBaseUrl;
};
on(eventName,fn){
var me = this;
switch(eventName){
case "select":{
document.querySelector(this.treeviewId).addEventListener("click",(event)=>{ if (event.target.nodeName=='SUMMARY'){
if (me.selected != null){document.getElementById(me.selected).removeAttribute("selected");
}
document.getElementById(event.target.id).setAttribute("selected","true");
console.log(event.target.id);
me.selected=event.target.id;
event.target.setAttribute("open",!event.target.parentNode.hasAttribute("open"));
fn(event)
}});
break;}
}
}
appendData(data,targetId){
document.getElementById(targetId).parentNode.innerHTML += this.walkData(data);
};
replaceData(data,targetId){
if (targetId!=null){
var target=document.getElementById(targetId);
target.outerHTML = this.walkData(data)
}
else {
var target = document.querySelector(this.treeviewId);
target.innerHTML = this.walkData(data);
}
};
walkData(data){
var me=this;
var buf = Object.keys(data).map((key)=>`<details><summary id="${key}" ${Object.keys(data[key]).map((subkey)=>{return subkey != 'children'?`data-${subkey}="${data[key][subkey]}"`:' '}).join(' ')}><img class="icon" src="${me.imageBase}${data[key].icon?data[key].icon:data[key].children?'Folder.png':'Item.png'}"> </img>${data[key].label}</summary>
${data[key].children?me.walkData(data[key].children):""}</details>`);
return buf.join("\n")
};
open(id){
var node = document.getElementById(id);
while(node.parentNode.nodeName=="DETAILS"){
node = node.parentNode;
node.setAttribute("open","true");
}
};
close(id){
var node = document.getElementById(id).parentNode;
node.removeAttribute("open");
var detailNodes = node.querySelectorAll("DETAILS");
console.log(detailNodes); detailNodes.forEach((node)=>node.removeAttribute("open"));
};
select(id){
this.open(id);
document.getElementById(id).focus();
document.getElementById(id).click();
}
}
Listing 4. The Treeview class.
The constructor for the class simply identifies the <div> element on the page that will be the container for the class and initializes the selected property, which contains the id of the active item in the treeview. If there is an existing <details>/<section> tree, it will work with that, but it can also work with content loaded throught the API.
The .replaceData() and .appendData() methods facilitate the loading of external content, in this case JSON content which follows a certain basic pattern.
var patients = {categories:{
label:"Categories",
description:"This identifies the different classes of objects in the data model",
children:{
"class:patient":{
label:"Patients",
type:"class:category",
description:"Recipients of medical care services.",
children:{
"patient:janeDoe":{label:"Jane Doe",
icon:"Woman.png",
type:"class:patient",
description:"35 year old writer of mystery novels",
postalCode:"98027",
children:{
"jd:plans":{
label:"Plans",
children:{
"plan:JDHI1":{label:"Health Insurance JDHI1", type:"class:plan",icon:"Plan.png"},
"plan:JDDI1":{label:"Dental Insurance JDDI1", type:"class:plan",icon:"Plan.png"},
"plan:JDVI1":{label:"Vision Insurance JDVI1", type:"class:plan",icon:"Plan.png"}
}
}
}
},
"person:briannen":{label:"Briannen Storm",
description:"24 year old female medical examiner",
icon:"Woman.png",
type:"class:patient",
postalCode:"98041",
children:{
"bs:plans":{
label:"Plans",
children:{
"plan:BSHI1":{label:"Health Insurance BSHI1", type:"class:plan",icon:"Plan.png"},
"plan:BSDI1":{label:"Dental Insurance BSDI1", type:"class:plan",icon:"Plan.png"},
"plan:BSVI1":{label:"Vision Insurance BSVI1", type:"class:plan",icon:"Plan.png"}
}
}
}
},
"person:thomasKey":{label:"Thomas Key",
description:"42 year old software architect",
icon:"Man.png",
type:"class:patient",
postalCode:"98043",
children:{
"group:KTplans":{
label:"Plans",
children:{
"plan:TKHI1":{label:"Health Insurance HI2", type:"class:plan",icon:"Plan.png"},
"plan:TKDI1":{label:"Dental Insurance DI2", type:"class:plan",icon:"Plan.png"},
"plan:TKVI1":{label:"Vision Insurance VI2", type:"class:plan",icon:"Plan.png"}
}
}
}
}
}
}
}
}};
var physicians = {
"class:physician":{
label:"Physicians",
type:"class:category",
description:"Individual providers of medical care services",
children:
{"physician:drstrange":{
label:"Dr. Stephen Strange",
description:"Master of the Arcane",
icon:"Doctor_Strange.jpg",
type:"class:physician",
children:{
"class:specialty":{
label:"Specialties",
children:{
"drdee:generalCare":{
label:"General Practitioner",
icon:"Doctor_Male.png",
description:"Provides general intake and consultative services, primary care physician."
},
"drdee:pediatrics":{
label:"Pediatrics",
icon:"Pediatrics.png",
description:"Provides care primarily for infants and children."
}
}
}
}
},
"physician:drjones":{
label:"Dr. Indiana Jones",
description:"Archeologist Extraordinaire",
type:"class:physician",
icon:"Doctor_Male.png",
children:{
"class:specialty":{
label:"Specialties",
children:{
"drjones:venomologist":{
label:"Venomologist",
icon:"Snake.png",
description:"Specializes in the effects and symptons of bites from snakes, spiders and other venomous animals"
}
}
}
}
},
"physician:drWho":{
label:"Doctor Who",
type:"class:physician",
icon:"Doctor_Who.jpg",
description:"Mad man in a blue box. ",
children:{
"class:specialty":{
label:"Specialties",
children:{
"drjones:chronicologist":{
label:"Chronicologist",
icon:"Tardis.png",
description:"Specializes in time travel-related diseases"
}
}
}
}
}
}
}
};
Listing 5. Two sample Javascript tree objects.
This uses the concept of a keyed data set. The first object contains patient information, with the primary keys for each object being either part of the root object or part of the children: object. Each object also has a label: property that identifies the label that will appear as the text of each line in the tree. The remaining properties should generally be atomic, and represent information that will be added as data-* attributes to each <summary> element.
The icon: attribute is used to identify a custom icon for a given entry. If it isn't given, then the walkTree() routine checks to see whether a resource has a children:property. If it does, then a folder icon is displayed, if it doesn't, a generic object icon is shown. Meanwhile, the type: identifies the base class or type for the resource in question (it's not used here, but may be used for querying databases for new resources or for similar UI needs).
This structure should not contain all of the information from the dataset. Rather, it contains just enough information to display content in the tree and identify the keys that will then retrieve the detailed content from some external data source. The <summary>element also echoes the open state of the <details> element and is used by the treeview to highlight the most recently selected item, regardless of whether the treeview has the focus or not.
So, the following code will load in the patient information (the first data block), then three seconds later will append the physicians information (the second data block):
var treeview = new Treeview(".treeview","https://www.example.com/path/to/images/")
treeview.replaceData(patients);
setTimeout(()=>treeview.appendData(physicians,"categories"),3000);
Listing 6. Loading in the the data
In this case, .replaceData() takes either one or two arguments. The first argument is the data block in question. The second argument is the id of the node to replace. If the second argument isn't given, it generates the <details>/<summary> tree from the data block and replaces the root node (which requires that the object being passed has only one property label in it).
The .appendData() method takes a data block which can have zero or more nodes in it, and inserts these as children to the indicated node. This is useful when retrieving additional content from the web (such as a new node that's only exposed when the user first clicks on the entry). In this example, the nodes will always be appended to the existing list of nodes in that property.
The .select() method takes the id of a node as a key, and opens up the tree to that point (by calling the .open() method, which sets the open attribute on all parent <details> elements to "true". It also sets the focus() of the element and updates the selected flag (used primarily for CSS). Once .select() is called, it also can execute the .on("select",fn) event handler, which lets a user write a callback function when a resource is selected, such as what's shown in Listing 7.
treeview.on("select",(event)=>{
var node = event.target;
var data = node.dataset;
display.innerHTML = `<div class="label">${data.label}</div>${data.description?`<div class="descr">${data.description}</div>`:''}`;
console.log(event.target)
});
Listing 7. Acting upon the selection of a given item in the tree view.
This makes use of the dataset properties retrieved from the <summary> element to populate the corresponding field. The event.target itself is revealing (Listing 8):
<summary id="patient:janeDoe" data-label="Jane Doe" data-type="class:Patient"
data-description="35 year old writer of mystery novels"
data-postalcode="98027" open="false" selected="true">Jane Doe</summary>
Listing 8. What a "live" <summary> element looks like.
In general, the keys for labeled node should be unique, or the .select() method in particular will fail. This isn't true for property labels, but anything that is a direct child of the object or the children: tag should be globally unique to the dataset. (Not surprisingly, the treeview control was intended to solve problems associated with the semantic web and linked data).
Styling and Compatibility
For all that <details> is remarkably useful, it is taking a while for some consensus to evolve with regard to styling and CSS compatibility in particular. There has been a proposal for a CSS 4 pseudo-property called ::marker that would allow for the styling of the marker itself. At this point, the Chrome browser (and Vivaldi, not surprisingly) support the ::-webkit-details-marker property, that allows for the styling of the marker. The sample CSS makes use of that (Listing 9):
details details {margin-left:10pt;}
.treeview {
padding:5pt;
border:inset 2px lightGray;
width:240pt;
font:10pt Arial;
display:inline-block;
}
summary[selected="true"]{
background-color:yellow;
}
summary {
cursor:pointer;
}
summary:hover {
background-color:#C0e0ff;
}
summary .icon {
width:16px;
height:16px;
}
/* Hide the marker (if possible) if a node is a leaf node */
summary:only-child::-webkit-details-marker {
color:transparent;
}
details details {
margin-left:10pt;
display:block;
}
#display {
display:inline-block;
vertical-align:top;
}
#display {font-family:Arial;}
.label {font-size:14pt;font-weight:bold;}
.descr {font-size:10pt}
Listing 9. CSS markup for the Treeview Control.
Here, the marker for a leaf node (with no <details> children) is set to transparent, while the background color is set to green. The background-image and size are also set, and could be a GIF, PNG or SVG image.
Mozilla (Gecko) and Internet Explorer do not yet have the ability to modify the marker elements, though they there are indications that they will likely be doing so in the next year. These should thus be seen as degraded capabilities - the treeview will still work, it just may require the use of inline content, perhaps using the content:url() properties with the ::before pseudo-selector.
Neither Internet Explorer nor Edge support the <details> element natively yet. However, the details element polyfill by Javan Makhmali provides a good polyfill shim that gives most of the required behavior except for the CSS ::marker support. The linked image goes to a CodePen that lets you play with the control:
Figure 3. The final treeview component.
<summary>
The <details> element is reaching a point where it has the potential to facilitate a number of more basic controls, including treeviews, accordions and inline popups, features that now require Javascript intervention.
Because these are native components, they also have a much smaller potential memory footprint, making them attractive as lightweight alternatives to framework widgets. They also serve to illustrate how powerful CSS is becoming in reducing the overall complexity of application development on the web.
Kurt Cagle is a writer, blogger, and general ne'er-do-well, living in the wild hinterlands of Seattle with his family and quantum cat.
#TheCagleReport