src/TypeAhead.ts

   1/**

   2 * # TypeAhead

   3 * Provides a search box with a type-ahead dropdown to show valid options that match the current search input.

   4 * 

   5 * ### Profile

   6 * invoked as `m(hsWidget.TypeAhead, {  });`

   7 * 

   8 * ### Attributes (node.attrs):

   9 * - `list: string | string[]` the list to search in. If `list` is a string, it serves

  10 *    as a URL to a `json` file containing an array of search terms. Else, if it is a 

  11 *    string[] it serves directly as an array of search terms

  12 * - `placeholder: string` an indicator what to enter in the search box

  13 * - `onsubmit: (term:string) => void`  a function to call when a term is submitted

  14 * - `autofocus: boolean` whether the search box automatically gets the focus

  15 * 

  16 * ### Example

  17 * 

  18 * 'script.js'>

  19 * let hero = '';

  20 * let friend = '';

  21 * m.mount(root, {view: () => m('.hs-white', [

  22 *      m('h4', hero.length? `Selected: ${hero}` : 'Local List: Search for a Superhero'),

  23 *      m(hsWidget.TypeAhead, { 

  24 *         placeholder: 'favorite hero',

  25 *         onsubmit: item => hero = item,

  26 *         list: ['Batman''Superman''Spiderman''Hulk']

  27 *      }),

  28 *      m('h4', friend.length? `Selected: ${friend}` : 'Remote List: Search for a Friend'),

  29 *      m(hsWidget.TypeAhead, { 

  30 *         placeholder: 'best friend',

  31 *         onsubmit: item => friend = item,

  32 *         autofocus: true,

  33 *         list: 'example/search.json'

  34 *      })

  35 *   ])

  36 * });

  37 * 

  38 * 

  39 * 

  40 */

  41

  42 /** */

  43import { m, Vnode } from 'hslayout';

  44

  45// emphasize literal matches as *bold* in the drop down list

  46function emphasize(item:string, match:string) {

  47    const re = new RegExp(match, 'gi');

  48    const decorations = item

  49        .replace(re, (m:string) => `${m}`)

  50        .split('<')

  51        .map((s:string) => {

  52            if (s.startsWith('/b>')) { 

  53                return m('span', {name:item}, s.slice(3)); 

  54            } else if (s.startsWith('b>')) {

  55                return m('b', {name:item}, s.slice(2));

  56            } else {

  57                return m('span', {name:item}, s);

  58            }

  59        });

  60    return m('span', decorations); 

  61}

  62

  63class GetList {

  64    public list:string[] = [];

  65    private captureList(list:any[], map:(l:any[])=>string[]) {

  66        this.list = map? map(list) : list;

  67    }

  68    constructor(list:string|string[], map?:(item:any[])=>string[]) {

  69        if (typeof list === 'string') {

  70            m.request({ method: "GET", url: list })

  71            .then((data:any[]) => this.captureList(data, map));

  72        } else {

  73            this.captureList(list, map);

  74        }

  75    }

  76}

  77

  78export class TypeAhead {

  79    oninit(node:Vnode) {

  80        node.state.inputNode = '';

  81        node.state.hidePopdown = true;

  82        node.state.value = '';

  83        node.state.typeAheadList = [];

  84        node.state.onsubmit = node.attrs.onsubmit;

  85        node.state.list = node.attrs.list;

  86    }

  87    view(node:Vnode) {

  88        const gl = new GetList(node.state.list);

  89        const nosubmit = () => console.log('no submit function defined');

  90        const submit = (v:string) => {

  91            node.state.inputNode.setSelectionRange(0, node.state.inputNode.value.length);

  92            node.state.hidePopdown = true;

  93            return node.state.onsubmit? node.state.onsubmit(v) : nosubmit();

  94        };

  95        const select = (e:any) => { if (e) { 

  96            node.state.inputNode.value = e.target.attributes.name.value;

  97            submit(e.target.attributes.name.value);

  98        }};

  99        const input = (e:any) => {

 100            const n = node.state.inputNode = e.target;

 101            const input = node.state.value = n.value;

 102            const withinInput = new RegExp(`${input}`, 'gi');

 103            const beginningOfInput = new RegExp(`^${input}`, 'gi');

 104            node.state.typeAheadList = gl.list.filter((l:string) => l.match(withinInput));

 105            n.value = node.state.typeAheadList.filter((l:string) => l.match(beginningOfInput))[0] || input; 

 106            node.state.hidePopdown = n.value.length===0; 

 107            let pos = input.length;

 108            n.setSelectionRange(pos, n.value.length);

 109        };

 110        const keyPressed = (e:any) => {

 111            const n = node.state.inputNode = e.target;

 112            if (e.code === 'Enter') {

 113                submit(n.value);

 114            } else if (e.code === 'Backspace') {

 115                const input = n.firstChild.data;

 116                if (input.length > 0) {

 117                    n.value = input.slice(0);

 118                }

 119            }

 120        };

 121        const inputNode = {

 122            contenteditable:true,

 123            placeholder:    node.attrs.placeholder,

 124            autofocus:      node.attrs.autofocus || true,

 125            onkeydown:      keyPressed,

 126            oninput:        input

 127        };

 128            

 129        return m('.hs-form', [

 130            m(`input.hs-typeahead-input${node.state.value?'.hs-typeahead-value' : '.hs-typeahead-placeholder'}`, 

 131                inputNode, 

 132                m.trust(node.state.value?node.state.value : node.attrs.placeholder)

 133            ),

 134            node.state.hidePopdown? undefined : 

 135                m('.hs-typeahead-list', node.state.typeAheadList.map((l:string) => 

 136                    m('', { onclick: select }, emphasize(l, node.state.value))))

 137        ]);

 138    }

 139}