Drill-in menu with XForms

As part of an XForms project I’m doing I’ve been looking into menu options and came across the drill-in menu, popularised by a famous range of mp3 players. The menu comes in two parts, a history section at the top and current choices below. As each selection is made, that choice is added to the history and it’s children appear in the menu.

The advantages here are that it’s a compact mechanism for use with any deeply nested structure. Only the current menu is shown at any time and the history gives a convenient breadcrumb trail to that choice-set. If you don’t want to use a tree, linked lists or fly-out, drill-in might be for you. Luckily, it’s fairly easy to do as an XForm and sits nicely on my SimpleXist server.

Note: I’m using XSLTForms here to do my XForms and the SimpleXist set-up described elsewhere. The code expects /resources/xsltforms in the root. 

Data Data Data

The source data for the menu is a standard nested piece of XML. It could be in one piece, a collection, whatever. The important factor is that there is some mechanism to uniquely target each menu item. In my case, I’m using menu.xml and a numeric id; I’ve set the root item to ‘000’.

Next step is some XQuery to put up the menu. I’ve taken the decision to send the whole menu with each submission and get back a complete one to replace the instance. I’m using /db/projects/project3 in the SimpleXist idiom for the data. Here’s menu.xql:

xquery version "1.0";
declare option exist:serialize "method=xml media-type=text/xml indent=yes";
let $data_collection := '/db/projects/project3/'
let $data := request:get-data()
let $q := if (empty($data//prefix)) then ('000') else ($data//prefix)
let $file := concat($data_collection,'menu.xml')
let $head := doc($file)//category[id=$q]
let $this_item := <item id="{$q}" children="{count($head[1]/category)}">{$head[1]/title/text()}</item>
let $hist := $data//history/item
return
<data>
 <prefix/>
<history>
{$hist}
{if ($this_item/@children > 0 ) then ($this_item) else ()}
</history>
<menu id="{$q}">
 {for $z in $head/category
 return <item id="{$z/id}" children="{count($z/category)}">{$z/title/text()}</item>}
</menu>
</data>

The key points here are that that the submission posts the whole menu. The prefix element contains the choice or ‘000’ as default. The new menu is put into $head and $this_item set to the chosen item. Then $history is set up to the current history. Lastly, a new menu is assembled: $this_item is appended to the history if the current choice has a child menu, then all the menu items for this choice are assembled along with a count of children. This last is so the form can display and behave differently for “branches” and “leaves” in the form. The default output looks like so:

Note that the menu isn’t in the format of the original menu; that’s deliberate. The XForm is wrapped around the menu and would be harder to change than the original data. With a bit of XQuery, pretty much any tree data could be coerced into this format.

Simple XForm

Now we have data it’s time to set up the XForm. It’s pretty simple at the moment with one instance to hold the current menu, and one submission to get the next one as shown by drill.xql the meat of which is below:

<head>
<xf:model>
<!-- Instance to hold the menu -->
<xf:instance id="navigation" src="menu.xql" xmlns="" />
<xf:submission id="nextmenu" method="post" 
replace="instance" instance="navigation" 
ref="instance('navigation')" action="menu.xql"/>

</xf:model>
</head>
<body>
<div id="drillin">
<div id="history">
<!-- History Bar -->
<xf:repeat id="hist-repeat"
  nodeset="instance('navigation')/history/item">
<!-- Output as a set of links -->
<xf:trigger appearance="minimal">
<xf:label><xf:output value="position()"/> <b><xf:output ref=".  "/></b></xf:label>
<!-- When we're clicked .... -->
<xf:action ev:event="DOMActivate">
<!-- Rather long-winded way to put current choice in prefix -->
<xf:setvalue ref="instance('navigation')/prefix" 
  value="instance('navigation')/history/item
  [index('hist-repeat')]/@id"/>
<xf:delete
  nodeset="instance('navigation')/history/item[position() &gt;=
  index('hist-repeat')]"/>
<xf:send submission="nextmenu"/></xf:action>
</xf:trigger>
</xf:repeat>
</div>

<div id="menu">
<!-- Menu Items -->
<xf:repeat id="nav-repeat"
  nodeset="instance('navigation')/menu/item">
<xf:trigger appearance="minimal"> 
<xf:label><xf:output value="if(./@children = 0,'>','+')"/> 
<xf:output ref="."/></xf:label>
<xf:action ev:event="DOMActivate">
<xf:setvalue ref="instance('navigation')/prefix"
  value="instance('navigation')/menu/item[index('nav-repeat')]/@id"/>
<xf:send if="@children !=0" submission="nextmenu"/>
</xf:action>
</xf:trigger>
</xf:repeat>
</div>
</div>
</body>

The history div holds the history data as a list of links via a repeat. Position() is used so these get numbered as there is an implied order. There is a trigger hooked into the selections which does two things:

  1. It sets the prefix element to the id of the selected item.
  2. It deletes any item below the selected one in the history.

The deletion is necessary as it lets the history react properly if you select an item in the middle; everything ‘later’ is deleted. Then the whole menu is submitted.

The menu div hold the current menu options, again as a repeat. There is a bit of code in the label to put a ‘>’ or ‘+’ in front of the data depending on whether there are children or not. On selection of an item, again the whole menu is submitted, but only if that menu has children.

A little bit more complicated

Now the basics are out of the way it’s time to put together something a little more interesting. If you want this version feel free to download it here. Initially, I was going to use this menu to pick categories, then I got the idea to use it for navigation instead. Assuming for the sake of argument that a menu item points to some resource NNN.xml, the leaves can now do something useful. Lets start with 000.xml in the database for the root item:

<content>
 <h4>Hello</h4>
 <p>I'm some content</p>
</content>

We’ll need a bit more XQuery, query.xql,  to get a given resource from it’s id. Again  the menu gets sent:

xquery version "1.0";
declare option exist:serialize "method=xml media-type=text/xml indent=yes";

let $data_collection := '/db/projects/project3/'
let $data := request:get-data()
let $q := if (empty($data//prefix)) then ('000') else ($data//prefix)
let $file := concat($data_collection,$q,'.xml') 
let $tail := $data//item[@id = $q][1]
let $strpath := string-join(for $t in ($data//history/item,$tail)
return $t,'/')
let $error :=
<content>
<h2><a name='h1'>Oops</a></h2>
<p>I can't find {$file} in the database</p>
</content> 

let $input := if(doc-available($file) = true()) then (doc($file)) else $error

return
<data>
<meta>{$strpath}</meta>
<content>{$input}</content>
</data>

There is an error message as of course most of the items won’t exist – feel free to add some! One handy additional feature we can get from having the whole menu is a breadcrumb via the history made up of each item, plus current separated by a ‘/’ and put in the meta element. Of course it could just as easily hold other things like creation date etc.

We now only need to be able to tell the XForm menu section to get the resource if it’s a leaf, otherwise the next menu.

<xf:send if="@children != 0" submission="nextmenu"/>
<xf:send if="@children = 0" submission="getdata"/>

Last step is a bit of CSS and some framing :

Note. If you find that all the resource text is coming out without tags, then you need a later release of XSLTForms which has support for mime-types in the output element.

More ideas

Of course it probably isn’t entirely practical to have a menu like this in an XForm for navigation, though it’s an interesting experiment in the ‘All XForm’ direction. However,this is a handy tool even if only for it’s original use of picking categories.

Although this is an XForms program at the moment, there’s no reason why it couldn’t be hooked up using JQuery/CSS to get the same sort of look.

Advertisements