Source: xui/List.js

/**
 * A list which supports a "rich rendering" of the items it contains 
 * (item values may be rendered using HTML).
 * <p>Part of the XUI module which, for now, has an undocumented API.
 */
export class List extends HTMLElement {
    constructor() {
        super();
        
        this._singleClickToAccept = false;
        
        this.addEventListener("click", this.onClick.bind(this));
        this.addEventListener("keydown", this.onKeydown.bind(this));
        
        this._connected = false;
        this._labelMaker = null;
        this._htmlRenderer = null;
        this._disabledChecker = null;
        this._items = [];
        this._selection = [];
        this._anchor = this._extent = -1;
    }

    // -----------------------------------------------------------------------
    // Custom element
    // -----------------------------------------------------------------------

    connectedCallback() {
        if (!this._connected) {
            this._connected = true;

            // User code may have already added its own classes.
            this.classList.add("xui-control");
            // tabindex="0" makes this element focusable and tab-able.
            this.setAttribute("tabindex", "0");
            
            if (!this.hasAttribute("selectionmode")) {
                this.selectionMode = "single";
            }
        }
    }

    static get observedAttributes() {
        return [ "selectionmode" ];
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
        if (newVal === "single" && oldVal === "multiple") {
            let index = this.getSelection();
            if (index >= 0) {
                this.setSelection(index);
            }
        }
    }
    
    // -----------------------------------------------------------------------
    // Input events
    // -----------------------------------------------------------------------
    
    get singleClickToAccept() {
        return this._singleClickToAccept;
    }

    set singleClickToAccept(single) {
        // For use by AutocompleteField.
        this._singleClickToAccept = single;
    }
    
    onClick(event) {
        if (event.button === 0 && event.detail === 1) { 
            // First click on primary button.
            Util.consumeEvent(event);

            if (this.length > 0) {
                let index = this.clickEventToIndex(event);
                if (index >= 0) {
                    if (event.shiftKey) {
                        this.extendSelection(index, /*clickCount*/ 1);
                    } else if (Util.modKey(event)) {
                        this.toggleSelection(index, /*clickCount*/ 1);
                    } else {
                        let changed =
                            this.changeSelection(index, /*clickCount*/ 1);
                        
                        if (!changed &&
                            this.singleClickToAccept &&
                            this.isSingleSelectionMode()) {
                            // Clicking on an item already selected in the
                            // list will not cause the selectionchanged event
                            // to be sent hence, without this trick,
                            // AutocompleteField.acceptSelection would not be
                            // invoked.
        
                            this.selectionChanged(/*prevSelection*/ [], 1);
                        }
                    }
                }
            }
                
            this.focus();
        }
    }

    clickEventToIndex(event) {
        let itemDiv = null;
        let elem = event.target;
        while (elem !== null && !Object.is(elem, this)) {
            if (elem.classList.contains("xui-lst-item")) {
                itemDiv = elem;
                break;
            }
            
            elem = elem.parentElement;
        }

        let index = -1;
        if (itemDiv !== null) {
            let i = 0;
            let child = this.firstElementChild;
            while (child !== null) {
                if (Object.is(child, itemDiv)) {
                    index = i;
                    break;
                }

                ++i;
                child = child.nextElementSibling;
            }
        }

        return index;
    }

    extendSelection(index, clickCount=0) {
        if (this.isSingleSelectionMode()) {
            this.changeSelection(index, clickCount);
            return;
        }

        // ---
        
        let curAnchor = this._anchor;
        if (curAnchor < 0) {
            this.setAnchor(0);
            curAnchor = this._anchor;
        }

        let from, to;
        if (index < curAnchor) {
            from = index;
            to = curAnchor;
        } else if (index > curAnchor) {
            from = curAnchor;
            to = index;
        } else {
            from = to = index;
        }
        let sel = [];
        for (let i = from; i <= to; ++i) {
            sel.push(i);
        }
        if (!List.sameSelection(sel, this._selection)) {
            // This clears anchor and extent.
            let prevSelection = this.setSelection(...sel);
            this.selectionChanged(prevSelection, clickCount);
        }

        this.setAnchor(curAnchor); // Restore anchor.
        this._extent = index;
    }

    static sameSelection(a1, a2) {
        let count;
        if ((count = a1.length) !== a2.length) {
            return false;
        }

        for (let i = 0; i < count; ++i) {
            if (a1[i] !== a2[i]) {
                return false;
            }
        }
        
        return true;
    }
    
    toggleSelection(index, clickCount=0) {
        let sel = [ ...this._selection ]; // Never modify this._selection
        let pos = sel.indexOf(index);
        if (pos >= 0) {
            // Selected ==> unselect.
            sel.splice(pos, 1);
        } else {
            // Not selected ==> select.
            if (this.isSingleSelectionMode()) {
                sel = [ index ];
            } else {
                sel.push(index);
            }
        }
        let prevSelection = this.setSelection(...sel);
        this.selectionChanged(prevSelection, clickCount);
        
        this.setAnchor(index);
    }

    changeSelection(index, clickCount=0) {
        let changed = false;
        if (this._selection.length !== 1 || this._selection[0] !== index) {
            let prevSelection = this.setSelection(index);
            changed = this.selectionChanged(prevSelection, clickCount);
        }
        // Otherwise, nothing to do.
        
        this.setAnchor(index);

        return changed;
    }

    selectionChanged(prevSelection, clickCount=0) {
        if (List.sameSelection(this._selection, prevSelection)) {
            return false;
        }
        
        let event =
            new Event("selectionchanged",
                      { bubbles: true, cancelable: false, composed: true });
        event.xuiSelection = this.getSelection();
        event.xuiClickCount = clickCount;
        this.dispatchEvent(event);
        
        return true;
    }
    
    onKeydown(event) {
        let isHotKey = true; 
        switch (event.key) {
        // Ctrl-PageUp and Ctrl-PageDown not sent by Chrome & Firefox.
        case "PageUp":
            this.onUpKey(event.shiftKey, Util.modKey(event), 10);
            break;
        case "PageDown":
            this.onDownKey(event.shiftKey, Util.modKey(event), 10);
            break;
        case "ArrowUp":
        case "Up":
            this.onUpKey(event.shiftKey, Util.modKey(event));
            break;
        case "ArrowDown":
        case "Down":
            this.onDownKey(event.shiftKey, Util.modKey(event));
            break;
        case "Home":
            this.onHomeKey(event.shiftKey, Util.modKey(event));
            break;
        case "End":
            this.onEndKey(event.shiftKey, Util.modKey(event));
            break;
        case " ": // Space bar
            this.onSpaceKey(event.shiftKey, Util.modKey(event));
            break;
        case "a":
            this.onAKey(event.shiftKey, Util.modKey(event));
            break;
        default:
            isHotKey = false;
            break;
        }

        if (isHotKey) {
            // Whatever its modifiers.
            Util.consumeEvent(event);
        }
    }

    // ---------------------------------------
    // Low-level API used by AutocompleteField
    // ---------------------------------------
    
    onUpKey(isShift, isMod, skip=1) {
        if (this.length === 0) {
            return;
        }
        
        let curExtent = this._extent;
        if (this._anchor < 0) {
            this.setAnchor(this.length-1);
            curExtent = this.length + skip - 1; // See nextExtent below.
        }

        let nextExtent = curExtent - skip;
        if (nextExtent < 0) {
            nextExtent = 0;
        }

        if (isShift) {
            this.extendSelection(nextExtent);
        } else if (isMod) {
            this.setAnchor(nextExtent);
        } else {
            this.changeSelection(nextExtent);
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    onDownKey(isShift, isMod, skip=1) {
        if (this.length === 0) {
            return;
        }
        
        let curExtent = this._extent;
        if (this._anchor < 0) {
            this.setAnchor(0);
            curExtent = -skip; // See nextExtent below.
        }

        let nextExtent = curExtent + skip;
        if (nextExtent >= this.length) {
            nextExtent = this.length-1;
        }

        if (isShift) {
            this.extendSelection(nextExtent);
        } else if (isMod) {
            this.setAnchor(nextExtent);
        } else {
            this.changeSelection(nextExtent);
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    onHomeKey(isShift, isMod) {
        if (this.length === 0) {
            return;
        }
        
        if (this._anchor < 0) {
            this.setAnchor(0);
        }

        let nextExtent = 0;

        if (isShift) {
            this.extendSelection(nextExtent);
        } else if (isMod) {
            this.setAnchor(nextExtent);
        } else {
            this.changeSelection(nextExtent);
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    onEndKey(isShift, isMod) {
        if (this.length === 0) {
            return;
        }
        
        if (this._anchor < 0) {
            this.setAnchor(this.length-1);
        }

        let nextExtent = this.length-1;

        if (isShift) {
            this.extendSelection(nextExtent);
        } else if (isMod) {
            this.setAnchor(nextExtent);
        } else {
            this.changeSelection(nextExtent);
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    onSpaceKey(isShift, isMod) {
        if (this.length === 0) {
            return;
        }
        
        if (this._anchor >= 0) {
            if (isMod) {
                this.toggleSelection(this._anchor);
            } else {
                this.changeSelection(this._anchor);
            }
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    onAKey(isShift, isMod) {
        if (this.length === 0) {
            return;
        }
        
        if (isMod) {
            let prevSelection = this.selectAll();
            this.selectionChanged(prevSelection);
        }
        
        if (this._extent >= 0) {
            this.ensureIsVisible(this._extent);
        }
    }
    
    // -----------------------------------------------------------------------
    // API
    // -----------------------------------------------------------------------

    get selectionMode() {
        return this.getAttribute("selectionmode");
    }

    set selectionMode(mode) {
        this.setAttribute("selectionmode",
                          (mode === "multiple")? mode : "single");
    }

    isSingleSelectionMode() {
        return (this.selectionMode === "single");
    }
    
    get labelMaker() {
        return this._labelMaker;
    }

    set labelMaker(maker) {
        this._labelMaker = maker;
    }
    
    get htmlRenderer() {
        return this._htmlRenderer;
    }

    set htmlRenderer(renderer) {
        this._htmlRenderer = renderer;
    }
    
    get disabledChecker() {
        return this._disabledChecker;
    }

    set disabledChecker(checker) {
        this._disabledChecker = checker;
    }
    
    get length() {
        return this._items.length;
    }

    get(index) {
        let item = null;
        if (index >= 0 && index < this.length) {
            item = this._items[index];
        }

        return item;
    }

    getLabel(index) {
        let label = null;
        if (index >= 0 && index < this.length) {
            label = this.itemLabel(this._items[index]);
        }

        return label;
    }
    
    itemLabel(item) {
        let label = null;
        if (this._labelMaker !== null) {
            try {
                label = this._labelMaker(item);
            } catch (error) {
                console.error(`XUI.List.labelMaker has failed: ${error}`);
            }
        }
        if (label === null) {
            label = item.toString();
        }
        
        return label;
    }
    
    isDisabled(index) {
        let disabled = false;
        if (index >= 0 && index < this.length) {
            disabled = this.itemIsDisabled(this._items[index]);
        }

        return disabled;
    }
    
    itemIsDisabled(item) {
        let disabled = false;
        if (this._disabledChecker !== null) {
            try {
                disabled = this._disabledChecker(item);
            } catch (error) {
                console.error(`XUI.List.disabledChecker has failed: ${error}`);
            }
        }

        return disabled;
    }
    
    getAll(copy=true) {
        if (copy) {
            return [ ...this._items ]; // A copy that can be modified.
        } else {
            return this._items; // Must not be modified. Use with care.
        }
    }

    setAll(items) {
        this.removeAll();
        
        this._items = [ ...items ]; // Our own, private, copy.

        let i = 0;
        for (let item of items) {
            let child = document.createElement("div");
            child.setAttribute("class", "xui-lst-item");
            this.populateChild(child, item, i++);
            
            this.appendChild(child);
        }
        
        this.update(0);
        
        this.ensureIsVisible(0);
    }

    removeAll() {
        this._items = [];
        this._selection = [];
        this._anchor = -1;

        Util.removeAllChildren(this);
    }

    populateChild(child, item, index) {
        child.innerHTML = this.itemInnerHTML(item, index);
        if (this.itemIsDisabled(item)) {
            child.classList.add("xui-lst-disabled");
        } else {
            child.classList.remove("xui-lst-disabled");
        }
    }
    
    itemInnerHTML(item, index) {
        let html = null;
        if (this._htmlRenderer !== null) {
            // index may be rendered by client code too.
            try {
                html = this._htmlRenderer(item, index);
            } catch (error) {
                console.error(`XUI.List.htmlRenderer has failed: ${error}`);
            }
        }
        
        if (html === null) {
            html = Util.escapeHTML(this.itemLabel(item));
            if (html.indexOf('\n') >= 0) {
                html = html.replaceAll('\n', "<br>");
            }
        }

        return html;
    }
    
    update(from) {
       this._selection = [];
       this._anchor = -1;
       
       let i = 0;
       let child = this.firstElementChild;
       while (child !== null) {
           let classList = child.classList;
           
           if (i >= from) {
               classList.remove("xui-lst-item0", "xui-lst-item1");
               classList.add("xui-lst-item" + String(i % 2));
           }
           
           if (classList.contains("xui-lst-selected")) {
               this._selection.push(i);
           }
           if (classList.contains("xui-lst-anchor")) {
               this._anchor = i;
           }
           
           ++i;
           child = child.nextElementSibling;
       }
    }

    ensureIsVisible(index) {
        const count = this.length;
        if (count === 0) {
            // Nothing to do.
            return;
        }
        
        if (index < 0) {
            index = 0;
        } else if (index >= count) {
            index = count-1;
        }
        this.children[index].scrollIntoView({ block: "nearest",
                                              inline: "nearest" });
    }
    
    set(index, item) {
        let replaced = null;
        if (index >= 0 && index < this.length) {
            replaced = this._items[index];
            this._items[index] = item;

            this.populateChild(this.children[index], item, index);
        }

        return replaced;
    }

    remove(index) {
        let removed = null;
        if (index >= 0 && index < this.length) {
            removed = this._items[index];
            this._items.splice(index, 1);

            this.removeChild(this.children[index]);

            this.update(index);
        }

        return removed;
    }
    
    add(...newItems) {
        this.insert(this.length, ...newItems);
    }

    insert(index, ...newItems) {
        if (newItems.length === 0) {
            // Nothing to do.
            return;
        }
        
        const count = this.length;
        if (index < 0) {
            index = 0;
        } else if (index > count) {
            index = count;
        }
        let before = (index === count)? /*at end*/ null : this.children[index];

        let i = index;
        for (let newItem of newItems) {
            let child = document.createElement("div");
            child.setAttribute("class", "xui-lst-item");
            this.populateChild(child, newItem, i++);
            
            this.insertBefore(child, before);
        }
        
        this._items.splice(index, 0, ...newItems);
        
        this.update(index);
    }

    getSelection() {
        if (this.isSingleSelectionMode()) {
            return (this._selection.length > 0)? this._selection[0] : -1;
        } else {
            return [ ...this._selection ]; // A copy that can be modified.
        }
    }
    
    selectAll() {
        let sel = [];
        const count = this.length;
        for (let i = 0; i < count; ++i) {
            sel.push(i);
        }
        return this.setSelection(...sel);
    }
    
    clearSelection() {
        return this.setSelection();
    }
    
    setSelection(...indices) {
        if (this.isSingleSelectionMode() && indices.length > 0) {
            indices = indices.slice(0, 1);
        }

        let prevSelection = this._selection;
        this._selection = [];
        
        const count = this.length;
        for (let i = 0; i < count; ++i) {
            let item = this._items[i];
            let childClassList = this.children[i].classList;
            
            if (indices.includes(i) && !this.itemIsDisabled(item)) {
                this._selection.push(i);

                childClassList.add("xui-lst-selected");
            } else {
                childClassList.remove("xui-lst-selected");
            }
        }

        // Forget about these selection marks.
        this.setAnchor(-1);
        
        return prevSelection;
    }

    // ---------------------------------------
    // Low-level API used by AutocompleteField
    // ---------------------------------------
    
    setAnchor(index) {
        let oldAnchor = this._anchor;
        this._anchor = this._extent = -1;
        if (index >= 0 && index < this.length) {
            this._anchor = this._extent = index;
        }

        if (this._anchor !== oldAnchor) {
            if (oldAnchor >= 0) {
                this.children[oldAnchor].classList.remove("xui-lst-anchor");
            }
            if (this._anchor >= 0) {
                this.children[this._anchor].classList.add("xui-lst-anchor");
            }
        }
    }

    get anchor() {
        return this._anchor;
    }

    get extent() {
        return this._extent;
    }
}

window.customElements.define("xui-list", List);