XForms Technique: Suggestions

Steven Pemberton, CWI Amsterdam

Version: 2020-02-16.

Introduction

For selections that have a lot of options, it can be irritating to have to scroll through a long list of values to get to the one you want.

For instance, selecting a country from the list of all countries when filling in an address, all the more so if you are not sure which form of the country name is being used: do they want England, Great Britain, United Kingdom, or UK? Is it Holland, Netherlands, or The Netherlands?

The Technique

You have a value you want to enter, say country. Furthermore, you have a document containing the list of possible countries.

<instance id="data">
   <data xmlns="">
      <country/>
   </data>
</instance>
<instance id="countries" src="countries.xml"/>

The document countries looks like this:

<countries>
    <country>Afghanistan</country>
    <country>Albania</country>
    <country>Algeria</country>
    <country>Andorra</country>
    <country>Anguilla</country>
    ...

The traditional way of doing it is to use a select1:

<select1 ref="country" label="Country" appearance="minimal">
   <itemset ref="instance'countries')/country">
      <label ref="."/>
      <value ref="."/>
   </itemset>
</select1/>
<output ref="country" label="Your choice"/>

That looks like this:

Source

As you can see, a huge list.

What we will do instead is have a plain input for the country:

<input ref="country" incremental="true" label="Country"/>

and under it a select1 that displays the options that match that input.

To do this we will have a support value called suggestion:

<instance id="admin">
   <admin xmlns="">
      <suggestion/>
   </admin>
</instance>

This value is only of relevance if something has been typed in for the country already:

<bind ref="instance('admin')/suggestion" relevant="instance('data')/country!='.'"/>

and we input it using a select1 that only contains the items from the list of countries that match the input typed so far.

The countries that match are:

country[starts-with(., instance('data')/country)]

So here is the suggestion select1:

<select1 ref="suggestion" appearance="full" label="Suggestions">
   <itemset ref="instance('countries')/country[starts-with(. instance('v')/country)]">
      <label ref="."/>
      <value ref="."/>
   </itemset>
</select1>

Here it is in use:

Source

Four remarks.

Firstly you'll see that the suggestions pop up when you start typing. This is thanks to the relevance bind.

Secondly, you'll see that as you type, the suggestions adapt. This is thanks to the incremental="true" on the input, and the filter on the select1.

Thirdly, you might notice that it is a case-sensitive match. We can fix that easily enough by changing the filter to compare the lower case versions of both strings:

country[starts-with(lower-case(.), lower-case(instance('data')/country))]

Finally, and most importantly, when you select a suggestion, nothing happens. We fix that by copying the value across, when a selection is chosen:

<select1 ref="instance('admin')/suggestion" appearance="full" label="Suggestions">
   <itemset ref="instance('countries')/country[
                           starts-with(lower-case(.), lower-case(instance('data')/country))]">
      <label ref="."/>
      <value ref="."/>
   </itemset>
   <action ev:event="xforms-value-changed">
      <setvalue ref="instance('data')/country" value="context()"/>
   </action>
</select1>

giving:

Source

We're nearly there.

You'll see that one suggestion remains after you have made a choice. We can make that disappear by fixing the relevance condition for suggestion. Once the choice is made, country and suggestion will be equal, and so, suggestion is no longer relevant:

<bind ref="instance('admin')/suggestion"
      relevant="instance('data')/country!='' and
                instance('data')/country!=."/>

Finally, we should add a validity constraint to the country value: it is only valid if it matches one of the values of the countries list:

<bind ref="country" 
      constraint="count(instance('countries')/country[.=instance('data')/country])=1"/>

Source

Actually, we could make one more improvement: if you type in the country's name, and completely ignore the suggestions, a single suggestion remains. We can get rid of this by adjusting the relevance for suggestions, so that if you type in a name that exactly matches a country name, the suggestions disappear:

<bind ref="instance('admin')/suggestion"
      relevant="instance('data')/country!='' and 
                instance('data')/country!=.  and
                count(instance('countries')/country[.=instance('data')/country])!=1"
   />

Source

And just to compare, here is a version where instead of starts-with, we use contains to generate the suggestions:

country[contains(lower-case(.), lower-case(instance('data')/country))]

Source