src/Selector.ts

   1/**

   2 * # Abstract Selector

   3 * Creates a Selector with several Selectables.

   4 * The `updateModel` property determines how selecting an item affects 

   5 * the `isSelected` status of all other items. Preconfigured options are

   6 * -  {@link Selector.oneOfItems oneOfItems} allows only one selection at a time

   7 * -  {@link Selector.anyItems   anyItems} allows mutliple selections

   8 * 

   9 * 

  10 * ### Invocation

  11 * implementation dependant

  12 * 

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

  14 * - desc: {@link Selector.SelectorDesc SelectorDesc}

  15 *     - items: string[];                // the items on the selector

  16 *     - defaultItem?: number|string;    // the initial selected item, by index or name

  17 *     - clicked: (item:string) => void; // called upon user selection

  18 *     - itemCss?:string[];              // css to apply to items;

  19 */

  20

  21 /** */

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

  23

  24/** passed into Menu from the calling application */

  25export interface SelectorDesc {

  26    /** the items on the menu */

  27    items: string[];

  28    /** optional array of css styles; each will be applied to the respective item  */

  29    itemCss?: string[];

  30    /** the initial selected item */

  31    defaultItem?: string|number;

  32    /** the function to call when the selection changes */

  33    clicked: (item:string) => void;

  34    /** the function to call if this item receives a mouseDown event */

  35    mouseDown?: (item:string) => void;

  36    /** the function to call if this item receives a mouseUp event */

  37    mouseUp?: (item:string) => void;

  38}

  39

  40/** interface of the parameter passed to a `Selectable` */

  41export interface SelectableDesc {

  42    /** the item's title */

  43    title: string;

  44    /** the item's select status */

  45    isSelected: boolean;

  46    /** optional css class to use */

  47    css?: string;

  48    /** optional style string to apply */

  49    style?: string;

  50    /** the function to call if this item is clicked */

  51    clicked?: (item:string) => void;

  52    /** the function to call if this item receives a mouseDown event */

  53    mouseDown?: (item:string) => void;

  54    /** the function to call if this item receives a mouseUp event */

  55    mouseUp?: (item:string) => void;

  56}

  57

  58/**

  59 * a function used to notify listeners of a selection event.

  60 * @param items the list of selectable items

  61 * @param title the name of the current selection

  62 */

  63export type selectionModel = (items:SelectableDesc[], title:string) => SelectableDesc;

  64

  65/** 

  66 * called to update selection after the item with title `title` was selected.

  67 * `oneOfItems` ensures that `title` will be selected and all others deselected

  68 */

  69export function oneOfItems(items:SelectableDesc[], title:string):SelectableDesc {

  70    items.forEach((item:SelectableDesc) => { 

  71        item.isSelected = (item.title===title); 

  72    });

  73    if (!items.some((item:SelectableDesc) => item.isSelected)) { items[0].isSelected = true; }

  74    return items.filter((item:SelectableDesc) => item.isSelected)[0];

  75}

  76

  77/** 

  78 * called to update selection after the item with title `title` was selected.

  79 * `anyItems` ensures that `title` will be selected independant of all others

  80 */

  81export function anyItems(items:SelectableDesc[], title:string):SelectableDesc {

  82    items[title].isSelected = !items[title].isSelected; 

  83    return items[title];

  84}

  85

  86export interface SelectorState {

  87    /** 

  88     * determines which function to use to updatye selections after events.

  89     * Pre-configured function include:

  90     * - oneOfItems: default; only one item of the set can be selected at a time

  91     * - anyItem: each item can individually be selected. Pressing an item again will deselect it.

  92     */

  93    updateModel: selectionModel;

  94    /** instance variable, keeping a list of menu items and a `select` function for tracking which item is selected. */

  95    items: SelectableDesc[];

  96    events: (e:any)=>void;

  97    itemClicked?: (item:string) => string;

  98    defaultItem?: string;

  99}

 100

 101

 102/**

 103 * Abstract base class fopr menu and button selectors. 

 104 */

 105export abstract class Selector {

 106    /**

 107     * takes care of copying menu items from `attrs` to `state`. This generates a redraw when the items change

 108     * @param node 

 109     */

 110    static updateItems(node:Vnode) {

 111        const items = node.attrs.desc.items || [];

 112        items.map((itm:string, i:number) => {

 113            const item = node.state.items[itm] || {

 114                title: itm, 

 115                isSelected: false 

 116            };

 117            node.state.items[i] = item;

 118            node.state.items[itm] = item;

 119        });

 120    }

 121

 122    /**

 123     * called when component is initialized, setting the internal state of the selector from

 124     * parameters passed in the `attrs` field. Currently supported attributes:

 125     * - clicked:   an event callback to notify of a click event; 

 126     * - mouseUp:   an event callback to notify of a mouseUp event; 

 127     * - mouseDown: an event callback to notify of a mouseDown event; 

 128     * @param node then node to be initiailized

 129     * @param model model to use for state update; either `oneOfItems` (the default) or `anyItems`

 130     */

 131    static init(node: Vnode, model=oneOfItems) {

 132        node.state.updateModel = model;

 133        node.state.items = [];

 134        node.state.events = {};

 135        node.state.itemClicked = (item:string) => item;

 136        node.state.defaultItem = node.attrs.desc.defaultItem;

 137        node.state.events.mouseDown = node.attrs.desc.mouseDown;

 138        node.state.events.mouseUp   = node.attrs.desc.mouseUp;

 139        node.attrs.desc.clicked     = node.attrs.desc.clicked || ((item:string) => console.log(`missing clicked() function for selector item ${item}`));

 140        node.state.events.clicked   = node.attrs.desc.clicked;

 141        Selector.updateItems(node);

 142    }

 143

 144    oninit(node: Vnode) { 

 145        Selector.init(node); 

 146    }

 147    onupdate(node: Vnode) { 

 148        Selector.updateItems(node); 

 149    }

 150

 151    /**

 152     * ensures that at least one item is selected. If none is selected,

 153     * 

 154     * @param node 

 155     */

 156    protected static ensureSelected(node: Vnode) {

 157        if(node.state.items && !node.state.items.some((i:SelectableDesc) => i.isSelected) && node.state.items.length>0) { 

 158            if (node.state.defaultItem && node.state.items[node.state.defaultItem]) { 

 159                node.state.items[node.state.defaultItem].isSelected = true; 

 160            } else if (node.state.items) { 

 161                node.state.items[0].isSelected = true; 

 162            } 

 163        }

 164    }

 165

 166

 167    /**

 168     * render an item of the Selector group

 169     * @param node the node holding the group state

 170     * @param i index of item to render

 171     */

 172    protected static renderItem(node: Vnode, i:number) {

 173        const reactor = (callback:(itm:string)=>void) => (title:string) => {

 174            node.state.updateModel(node.state.items, title); // internal state update

 175            title = node.state.itemClicked(title);

 176            if (typeof callback === 'function') { 

 177                callback(title);  // trigger any external actions from the selection

 178            }     

 179        }; 

 180        if (i<0) { console.log(`illegal render index ${i} ${node.state.items.map((i:any)=>i.title).join('|')}`); i = 0; }

 181        const item:SelectableDesc = node.state.items[i];

 182        const title:string = item.title || '';

 183        const itemCss = item.css || '';

 184

 185        // Selector.checkSelectedItem(node, desc);

 186        return renderSelectable({ 

 187            title: title, 

 188            css: itemCss,        // possibly undefined

 189            // isSelected: node.state.selectedItem? (l.toLowerCase() === node.state.selectedItem.toLowerCase()) : false, 

 190            isSelected: node.state.items[title]? node.state.items[title].isSelected : false, 

 191            mouseDown: node.state.events.mouseDown,

 192            mouseUp: node.state.events.mouseUp,

 193            clicked: reactor(node.state.events.clicked)

 194        });

 195    }

 196    abstract view(node: Vnode): Vnode;

 197}

 198

 199/**

 200 * Creates a Selectable as part of the `Selector`, 

 201 * as configured by the desc:SelectableDesc object passed as a parameter.

 202 * Selectables can be in one of two states, selected or not selected. 

 203 * @return an `.hs-selectable` node

 204 */

 205export function renderSelectable(d:SelectableDesc) {

 206    const onclick       = d.clicked?   () => { d.clicked(d.title); }   : undefined;

 207    const onmousedown   = d.mouseDown? () => { d.mouseDown(d.title); } : undefined;

 208    const onmouseup     = d.mouseUp?   () => { d.mouseUp(d.title); }   : undefined;

 209    return m(`.hs-selectable ${d.css || ''} ${d.isSelected?'hs-selected'''}`, 

 210        { style: d.style, onclick:onclick, onmousedown:onmousedown, onmouseup:onmouseup },

 211        d.title

 212    );

 213}

 214