UNPKG

w2ui

Version:

UI widgets for modern apps. Data table, forms, toolbars, sidebar, tabs, tooltips, popups. All under 120kb (gzipped).

22,041 lines • 993 kB
/* w2ui 2.0.0 (4/26/2023, 10:40:17 AM) (c) http://w2ui.com, vitmalina@gmail.com */
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: w2utils
 *  - on/off/trigger methods id not showing in help
 *  - refactored with event object
 */

class w2event {
    constructor(owner, edata) {
        Object.assign(this, {
            type: edata.type ?? null,
            detail: edata,
            owner,
            target: edata.target ?? null,
            phase: edata.phase ?? 'before',
            object: edata.object ?? null,
            execute: null,
            isStopped: false,
            isCancelled: false,
            onComplete: null,
            listeners: []
        })
        delete edata.type
        delete edata.target
        delete edata.object
        this.complete = new Promise((resolve, reject) => {
            this._resolve = resolve
            this._reject = reject
        })
        // needed empty catch function so that promise will not show error in the console
        this.complete.catch(() => {})
    }
    finish(detail) {
        if (detail) {
            w2utils.extend(this.detail, detail)
        }
        this.phase = 'after'
        this.owner.trigger.call(this.owner, this)
    }
    done(func) {
        this.listeners.push(func)
    }
    preventDefault() {
        this._reject()
        this.isCancelled = true
    }
    stopPropagation() {
        this.isStopped = true
    }
}
class w2base {
    /**
     * Initializes base object for w2ui, registers it with w2ui object
     *
     * @param {string} name  - name of the object
     * @returns
     */
    constructor(name) {
        this.activeEvents = [] // events that are currently processing
        this.listeners = [] // event listeners
        // register globally
        if (typeof name !== 'undefined') {
            if (!w2utils.checkName(name)) return
            w2ui[name] = this
        }
        this.debug = false // if true, will trigger all events
    }
    /**
     * Adds event listener, supports event phase and event scoping
     *
     * @param {*} edata - an object or string, if string "eventName:phase.scope"
     * @param {*} handler
     * @returns itself
     */
    on(events, handler) {
        if (typeof events == 'string') {
            events = events.split(/[,\s]+/) // separate by comma or space
        } else {
            events = [events]
        }
        events.forEach(edata => {
            let name = typeof edata == 'string' ? edata : (edata.type + ':' + edata.execute + '.' + edata.scope)
            if (typeof edata == 'string') {
                let [eventName, scope] = edata.split('.')
                let [type, execute] = eventName.replace(':complete', ':after').replace(':done', ':after').split(':')
                edata = { type, execute: execute ?? 'before', scope }
            }
            edata = w2utils.extend({ type: null, execute: 'before', onComplete: null }, edata)
            // errors
            if (!edata.type) { console.log('ERROR: You must specify event type when calling .on() method of '+ this.name); return }
            if (!handler) { console.log('ERROR: You must specify event handler function when calling .on() method of '+ this.name); return }
            if (!Array.isArray(this.listeners)) this.listeners = []
            this.listeners.push({ name, edata, handler })
            if (this.debug) {
                console.log('w2base: add event', { name, edata, handler })
            }
        })
        return this
    }
    /**
     * Removes event listener, supports event phase and event scoping
     *
     * @param {*} edata - an object or string, if string "eventName:phase.scope"
     * @param {*} handler
     * @returns itself
     */
    off(events, handler) {
        if (typeof events == 'string') {
            events = events.split(/[,\s]+/) // separate by comma or space
        } else {
            events = [events]
        }
        events.forEach(edata => {
            let name = typeof edata == 'string' ? edata : (edata.type + ':' + edata.execute + '.' + edata.scope)
            if (typeof edata == 'string') {
                let [eventName, scope] = edata.split('.')
                let [type, execute] = eventName.replace(':complete', ':after').replace(':done', ':after').split(':')
                edata = { type: type || '*', execute: execute || '', scope: scope || '' }
            }
            edata = w2utils.extend({ type: null, execute: null, onComplete: null }, edata)
            // errors
            if (!edata.type && !edata.scope) { console.log('ERROR: You must specify event type when calling .off() method of '+ this.name); return }
            if (!handler) { handler = null }
            let count = 0
            // remove listener
            this.listeners = this.listeners.filter(curr => {
                if ( (edata.type === '*' || edata.type === curr.edata.type)
                    && (edata.execute === '' || edata.execute === curr.edata.execute)
                    && (edata.scope === '' || edata.scope === curr.edata.scope)
                    && (edata.handler == null || edata.handler === curr.edata.handler)
                ) {
                    count++ // how many listeners removed
                    return false
                } else {
                    return true
                }
            })
            if (this.debug) {
                console.log(`w2base: remove event (${count})`, { name, edata, handler })
            }
        })
        return this // needed for chaining
    }
    /**
     * Triggers even listeners for a specific event, loops through this.listeners
     *
     * @param {Object} edata - Object
     * @returns modified edata
     */
    trigger(eventName, edata) {
        if (arguments.length == 1) {
            edata = eventName
        } else {
            edata.type = eventName
            edata.target = edata.target ?? this
        }
        if (w2utils.isPlainObject(edata) && edata.phase == 'after') {
            // find event
            edata = this.activeEvents.find(event => {
                if (event.type == edata.type && event.target == edata.target) {
                    return true
                }
                return false
            })
            if (!edata) {
                console.log(`ERROR: Cannot find even handler for "${edata.type}" on "${edata.target}".`)
                return
            }
            console.log('NOTICE: This syntax "edata.trigger({ phase: \'after\' })" is outdated. Use edata.finish() instead.')
        } else if (!(edata instanceof w2event)) {
            edata = new w2event(this, edata)
            this.activeEvents.push(edata)
        }
        let args, fun, tmp
        if (!Array.isArray(this.listeners)) this.listeners = []
        if (this.debug) {
            console.log(`w2base: trigger "${edata.type}:${edata.phase}"`, edata)
        }
        // process events in REVERSE order
        for (let h = this.listeners.length-1; h >= 0; h--) {
            let item = this.listeners[h]
            if (item != null && (item.edata.type === edata.type || item.edata.type === '*') &&
                (item.edata.target === edata.target || item.edata.target == null) &&
                (item.edata.execute === edata.phase || item.edata.execute === '*' || item.edata.phase === '*'))
            {
                // add extra params if there
                Object.keys(item.edata).forEach(key => {
                    if (edata[key] == null && item.edata[key] != null) {
                        edata[key] = item.edata[key]
                    }
                })
                // check handler arguments
                args = []
                tmp  = new RegExp(/\((.*?)\)/).exec(String(item.handler).split('=>')[0])
                if (tmp) args = tmp[1].split(/\s*,\s*/)
                if (args.length === 2) {
                    item.handler.call(this, edata.target, edata) // old way for back compatibility
                    if (this.debug) console.log(' - call (old)', item.handler)
                } else {
                    item.handler.call(this, edata) // new way
                    if (this.debug) console.log(' - call', item.handler)
                }
                if (edata.isStopped === true || edata.stop === true) return edata // back compatibility edata.stop === true
            }
        }
        // main object events
        let funName = 'on' + edata.type.substr(0,1).toUpperCase() + edata.type.substr(1)
        if (edata.phase === 'before' && typeof this[funName] === 'function') {
            fun = this[funName]
            // check handler arguments
            args = []
            tmp  = new RegExp(/\((.*?)\)/).exec(String(fun).split('=>')[0])
            if (tmp) args = tmp[1].split(/\s*,\s*/)
            if (args.length === 2) {
                fun.call(this, edata.target, edata) // old way for back compatibility
                if (this.debug) console.log(' - call: on[Event] (old)', fun)
            } else {
                fun.call(this, edata) // new way
                if (this.debug) console.log(' - call: on[Event]', fun)
            }
            if (edata.isStopped === true || edata.stop === true) return edata // back compatibility edata.stop === true
        }
        // item object events
        if (edata.object != null && edata.phase === 'before' && typeof edata.object[funName] === 'function') {
            fun = edata.object[funName]
            // check handler arguments
            args = []
            tmp  = new RegExp(/\((.*?)\)/).exec(String(fun).split('=>')[0])
            if (tmp) args = tmp[1].split(/\s*,\s*/)
            if (args.length === 2) {
                fun.call(this, edata.target, edata) // old way for back compatibility
                if (this.debug) console.log(' - call: edata.object (old)', fun)
            } else {
                fun.call(this, edata) // new way
                if (this.debug) console.log(' - call: edata.object', fun)
            }
            if (edata.isStopped === true || edata.stop === true) return edata
        }
        // execute onComplete
        if (edata.phase === 'after') {
            if (typeof edata.onComplete === 'function') edata.onComplete.call(this, edata)
            for (let i = 0; i < edata.listeners.length; i++) {
                if (typeof edata.listeners[i] === 'function') {
                    edata.listeners[i].call(this, edata)
                    if (this.debug) console.log(' - call: done', fun)
                }
            }
            edata._resolve(edata)
            if (this.debug) {
                console.log(`w2base: trigger "${edata.type}:${edata.phase}"`, edata)
            }
        }
        return edata
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: none
 *
 * These are the master locale settings that will be used by w2utils
 *
 * "locale" should be the IETF language tag in the form xx-YY,
 * where xx is the ISO 639-1 language code ( see https://en.wikipedia.org/wiki/ISO_639-1 ) and
 * YY is the ISO 3166-1 alpha-2 country code ( see https://en.wikipedia.org/wiki/ISO_3166-2 )
 */
const w2locale = {
    'locale'            : 'en-US',
    'dateFormat'        : 'm/d/yyyy',
    'timeFormat'        : 'hh:mi pm',
    'datetimeFormat'    : 'm/d/yyyy|hh:mi pm',
    'currencyPrefix'    : '$',
    'currencySuffix'    : '',
    'currencyPrecision' : 2,
    'groupSymbol'       : ',', // aka "thousands separator"
    'decimalSymbol'     : '.',
    'shortmonths'       : ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
    'fullmonths'        : ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
    'shortdays'         : ['M', 'T', 'W', 'T', 'F', 'S', 'S'],
    'fulldays'          : ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
    'weekStarts'        : 'S', // can be "M" for Monday or "S" for Sunday
    // phrases used in w2ui, should be empty for original language
    // keep these up-to-date and in sorted order
    // value = "---" to easier see what to translate
    'phrases': {
        '${count} letters or more...': '---',
        'Add new record': '---',
        'Add New': '---',
        'Advanced Search': '---',
        'after': '---',
        'AJAX error. See console for more details.': '---',
        'All Fields': '---',
        'All': '---',
        'Any': '---',
        'Are you sure you want to delete ${count} ${records}?': '---',
        'Attach files by dragging and dropping or Click to Select': '---',
        'before': '---',
        'begins with': '---',
        'begins': '---',
        'between': '---',
        'buffered': '---',
        'Cancel': '---',
        'Close': '---',
        'Column': '---',
        'Confirmation': '---',
        'contains': '---',
        'Copied': '---',
        'Copy to clipboard': '---',
        'Current Date & Time': '---',
        'Delete selected records': '---',
        'Delete': '---',
        'Do you want to delete search item "${item}"?': '---',
        'Edit selected record': '---',
        'Edit': '---',
        'Empty list': '---',
        'ends with': '---',
        'ends': '---',
        'Field should be at least ${count} characters.': '---',
        'Hide': '---',
        'in': '---',
        'is not': '---',
        'is': '---',
        'less than': '---',
        'Line #': '---',
        'Load ${count} more...': '---',
        'Loading...': '---',
        'Maximum number of files is ${count}': '---',
        'Maximum total size is ${count}': '---',
        'Modified': '---',
        'more than': '---',
        'Multiple Fields': '---',
        'Name': '---',
        'No items found': '---',
        'No matches': '---',
        'No': '---',
        'none': '---',
        'Not a float': '---',
        'Not a hex number': '---',
        'Not a valid date': '---',
        'Not a valid email': '---',
        'Not alpha-numeric': '---',
        'Not an integer': '---',
        'Not in money format': '---',
        'not in': '---',
        'Notification': '---',
        'of': '---',
        'Ok': '---',
        'Opacity': '---',
        'Record ID': '---',
        'record': '---',
        'records': '---',
        'Refreshing...': '---',
        'Reload data in the list': '---',
        'Remove': '---',
        'Remove This Field': '---',
        'Request aborted.': '---',
        'Required field': '---',
        'Reset': '---',
        'Restore Default State': '---',
        'Returned data is not in valid JSON format.': '---',
        'Save changed records': '---',
        'Save Grid State': '---',
        'Save': '---',
        'Saved Searches': '---',
        'Saving...': '---',
        'Search took ${count} seconds': '---',
        'Search': '---',
        'Select Hour': '---',
        'Select Minute': '---',
        'selected': '---',
        'Server Response ${count} seconds': '---',
        'Show/hide columns': '---',
        'Show': '---',
        'Size': '---',
        'Skip': '---',
        'Sorting took ${count} seconds': '---',
        'Type to search...': '---',
        'Type': '---',
        'Yes': '---',
        'Yesterday': '---',
        'Your remote data source record count has changed, reloading from the first record.': '---'
    }
}
/* mQuery 0.7 (nightly) (10/10/2022, 11:30:36 AM), vitmalina@gmail.com */
class Query {
    static version = 0.7
    constructor(selector, context, previous) {
        this.context = context ?? document
        this.previous = previous ?? null
        let nodes = []
        if (Array.isArray(selector)) {
            nodes = selector
        } else if (selector instanceof Node || selector instanceof Window) { // any html element or Window
            nodes = [selector]
        } else if (selector instanceof Query) {
            nodes = selector.nodes
        } else if (typeof selector == 'string') {
            if (typeof this.context.querySelector != 'function') {
                throw new Error('Invalid context')
            }
            nodes = Array.from(this.context.querySelectorAll(selector))
        } else if (selector == null) {
            nodes = []
        } else {
            // if selector is itterable, then try to create nodes from it, also supports jQuery
            let arr = Array.from(selector ?? [])
            if (typeof selector == 'object' && Array.isArray(arr)) {
                nodes = arr
            } else {
                throw new Error(`Invalid selector "${selector}"`)
            }
        }
        this.nodes = nodes
        this.length = nodes.length
        // map nodes to object propoerties
        this.each((node, ind) => {
            this[ind] = node
        })
    }
    static _fragment(html) {
        let tmpl = document.createElement('template')
        tmpl.innerHTML = html
        tmpl.content.childNodes.forEach(node => {
            let newNode = Query._scriptConvert(node)
            if (newNode != node) {
                tmpl.content.replaceChild(newNode, node)
            }
        })
        return tmpl.content
    }
    // innerHTML, append, etc. script tags will not be executed unless they are proper script tags
    static _scriptConvert(node) {
        let convert = (txtNode) => {
            let doc = txtNode.ownerDocument
            let scNode = doc.createElement('script')
            scNode.text = txtNode.text
            let attrs = txtNode.attributes
            for (let i = 0; i < attrs.length; i++) {
                scNode.setAttribute(attrs[i].name, attrs[i].value)
            }
            return scNode
        }
        if (node.tagName == 'SCRIPT') {
            node = convert(node)
        }
        if (node.querySelectorAll) {
            node.querySelectorAll('script').forEach(textNode => {
                textNode.parentNode.replaceChild(convert(textNode), textNode)
            })
        }
        return node
    }
    static _fixProp(name) {
        let fixes = {
            cellpadding: 'cellPadding',
            cellspacing: 'cellSpacing',
            class: 'className',
            colspan: 'colSpan',
            contenteditable: 'contentEditable',
            for: 'htmlFor',
            frameborder: 'frameBorder',
            maxlength: 'maxLength',
            readonly: 'readOnly',
            rowspan: 'rowSpan',
            tabindex: 'tabIndex',
            usemap: 'useMap'
        }
        return fixes[name] ? fixes[name] : name
    }
    _insert(method, html) {
        let nodes = []
        let len  = this.length
        if (len < 1) return
        let self = this
        // TODO: need good unit test coverage for this function
        if (typeof html == 'string') {
            this.each(node => {
                let clone = Query._fragment(html)
                nodes.push(...clone.childNodes)
                node[method](clone)
            })
        } else if (html instanceof Query) {
            let single = (len == 1) // if inserting into a single container, then move it there
            html.each(el => {
                this.each(node => {
                    // if insert before a single node, just move new one, else clone and move it
                    let clone = (single ? el : el.cloneNode(true))
                    nodes.push(clone)
                    node[method](clone)
                    Query._scriptConvert(clone)
                })
            })
            if (!single) html.remove()
        } else if (html instanceof Node) { // any HTML element
            this.each(node => {
                // if insert before a single node, just move new one, else clone and move it
                let clone = (len === 1 ? html : Query._fragment(html.outerHTML))
                nodes.push(...(len === 1 ? [html] : clone.childNodes))
                node[method](clone)
            })
            if (len > 1) html.remove()
        } else {
            throw new Error(`Incorrect argument for "${method}(html)". It expects one string argument.`)
        }
        if (method == 'replaceWith') {
            self = new Query(nodes, this.context, this) // must return a new collection
        }
        return self
    }
    _save(node, name, value) {
        node._mQuery = node._mQuery ?? {}
        if (Array.isArray(value)) {
            node._mQuery[name] = node._mQuery[name] ?? []
            node._mQuery[name].push(...value)
        } else if (value != null) {
            node._mQuery[name] = value
        } else {
            delete node._mQuery[name]
        }
    }
    get(index) {
        if (index < 0) index = this.length + index
        let node = this[index]
        if (node) {
            return node
        }
        if (index != null) {
            return null
        }
        return this.nodes
    }
    eq(index) {
        if (index < 0) index = this.length + index
        let nodes = [this[index]]
        if (nodes[0] == null) nodes = []
        return new Query(nodes, this.context, this) // must return a new collection
    }
    then(fun) {
        let ret = fun(this)
        return ret != null ? ret : this
    }
    find(selector) {
        let nodes = []
        this.each(node => {
            let nn = Array.from(node.querySelectorAll(selector))
            if (nn.length > 0) {
                nodes.push(...nn)
            }
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    filter(selector) {
        let nodes = []
        this.each(node => {
            if (node === selector
                || (typeof selector == 'string' && node.matches && node.matches(selector))
                || (typeof selector == 'function' && selector(node))
            ) {
                nodes.push(node)
            }
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    next() {
        let nodes = []
        this.each(node => {
            let nn = node.nextElementSibling
            if (nn) { nodes.push(nn) }
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    prev() {
        let nodes = []
        this.each(node => {
            let nn = node.previousElementSibling
            if (nn) { nodes.push(nn)}
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    shadow(selector) {
        let nodes = []
        this.each(node => {
            // select shadow root if available
            if (node.shadowRoot) nodes.push(node.shadowRoot)
        })
        let col = new Query(nodes, this.context, this)
        return selector ? col.find(selector) : col
    }
    closest(selector) {
        let nodes = []
        this.each(node => {
            let nn = node.closest(selector)
            if (nn) {
                nodes.push(nn)
            }
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    host(all) {
        let nodes = []
        // find shadow root or body
        let top = (node) => {
            if (node.parentNode) {
                return top(node.parentNode)
            } else {
                return node
            }
        }
        let fun = (node) => {
            let nn = top(node)
            nodes.push(nn.host ? nn.host : nn)
            if (nn.host && all) fun(nn.host)
        }
        this.each(node => {
            fun(node)
        })
        return new Query(nodes, this.context, this) // must return a new collection
    }
    parent(selector) {
        return this.parents(selector, true)
    }
    parents(selector, firstOnly) {
        let nodes = []
        let add = (node) => {
            if (nodes.indexOf(node) == -1) {
                nodes.push(node)
            }
            if (!firstOnly && node.parentNode) {
                return add(node.parentNode)
            }
        }
        this.each(node => {
            if (node.parentNode) add(node.parentNode)
        })
        let col = new Query(nodes, this.context, this)
        return selector ? col.filter(selector) : col
    }
    add(more) {
        let nodes = more instanceof Query ? more.nodes : (Array.isArray(more) ? more : [more])
        return new Query(this.nodes.concat(nodes), this.context, this) // must return a new collection
    }
    each(func) {
        this.nodes.forEach((node, ind) => { func(node, ind, this) })
        return this
    }
    append(html) {
        return this._insert('append', html)
    }
    prepend(html) {
        return this._insert('prepend', html)
    }
    after(html) {
        return this._insert('after', html)
    }
    before(html) {
        return this._insert('before', html)
    }
    replace(html) {
        return this._insert('replaceWith', html)
    }
    remove() {
        // remove from dom, but keep in current query
        this.each(node => { node.remove() })
        return this
    }
    css(key, value) {
        let css = key
        let len = arguments.length
        if (len === 0 || (len === 1 && typeof key == 'string')) {
            if (this[0]) {
                let st = this[0].style
                // do not do computedStyleMap as it is not what on immediate element
                if (typeof key == 'string') {
                    let pri = st.getPropertyPriority(key)
                    return st.getPropertyValue(key) + (pri ? '!' + pri : '')
                } else {
                    return Object.fromEntries(
                        this[0].style.cssText
                            .split(';')
                            .filter(a => !!a) // filter non-empty
                            .map(a => {
                                return a.split(':').map(a => a.trim()) // trim strings
                            })
                    )
                }
            } else {
                return undefined
            }
        } else {
            if (typeof key != 'object') {
                css = {}
                css[key] = value
            }
            this.each((el, ind) => {
                Object.keys(css).forEach(key => {
                    let imp = String(css[key]).toLowerCase().includes('!important') ? 'important' : ''
                    el.style.setProperty(key, String(css[key]).replace(/\!important/i, ''), imp)
                })
            })
            return this
        }
    }
    addClass(classes) {
        this.toggleClass(classes, true)
        return this
    }
    removeClass(classes) {
        this.toggleClass(classes, false)
        return this
    }
    toggleClass(classes, force) {
        // split by comma or space
        if (typeof classes == 'string') classes = classes.split(/[,\s]+/)
        this.each(node => {
            let classes2 = classes
            // if not defined, remove all classes
            if (classes2 == null && force === false) classes2 = Array.from(node.classList)
            classes2.forEach(className => {
                if (className !== '') {
                    let act = 'toggle'
                    if (force != null) act = force ? 'add' : 'remove'
                    node.classList[act](className)
                }
            })
        })
        return this
    }
    hasClass(classes) {
        // split by comma or space
        if (typeof classes == 'string') classes = classes.split(/[,\s]+/)
        if (classes == null && this.length > 0) {
            return Array.from(this[0].classList)
        }
        let ret = false
        this.each(node => {
            ret = ret || classes.every(className => {
                return Array.from(node.classList ?? []).includes(className)
            })
        })
        return ret
    }
    on(events, options, callback) {
        if (typeof options == 'function') {
            callback = options
            options = undefined
        }
        let delegate
        if (options?.delegate) {
            delegate = options.delegate
            delete options.delegate // not to pass to addEventListener
        }
        events = events.split(/[,\s]+/) // separate by comma or space
        events.forEach(eventName => {
            let [ event, scope ] = String(eventName).toLowerCase().split('.')
            if (delegate) {
                let fun = callback
                callback = (event) => {
                    // event.target or any ancestors match delegate selector
                    let parent = query(event.target).parents(delegate)
                    if (parent.length > 0) { event.delegate = parent[0] } else { event.delegate = event.target }
                    if (event.target.matches(delegate) || parent.length > 0) {
                        fun(event)
                    }
                }
            }
            this.each(node => {
                this._save(node, 'events', [{ event, scope, callback, options }])
                node.addEventListener(event, callback, options)
            })
        })
        return this
    }
    off(events, options, callback) {
        if (typeof options == 'function') {
            callback = options
            options = undefined
        }
        events = (events ?? '').split(/[,\s]+/) // separate by comma or space
        events.forEach(eventName => {
            let [ event, scope ] = String(eventName).toLowerCase().split('.')
            this.each(node => {
                if (Array.isArray(node._mQuery?.events)) {
                    for (let i = node._mQuery.events.length - 1; i >= 0; i--) {
                        let evt = node._mQuery.events[i]
                        if (scope == null || scope === '') {
                            // if no scope, has to be exact match
                            if ((evt.event == event || event === '') && (evt.callback == callback || callback == null)) {
                                node.removeEventListener(evt.event, evt.callback, evt.options)
                                node._mQuery.events.splice(i, 1)
                            }
                        } else {
                            if ((evt.event == event || event === '') && evt.scope == scope) {
                                node.removeEventListener(evt.event, evt.callback, evt.options)
                                node._mQuery.events.splice(i, 1)
                            }
                        }
                    }
                }
            })
        })
        return this
    }
    trigger(name, options) {
        let event,
            mevent = ['click', 'dblclick', 'mousedown', 'mouseup', 'mousemove'],
            kevent = ['keydown', 'keyup', 'keypress']
        if (name instanceof Event || name instanceof CustomEvent) {
            // MouseEvent and KeyboardEvent are instances of Event, no need to explicitly add
            event = name
        } else if (mevent.includes(name)) {
            event = new MouseEvent(name, options)
        } else if (kevent.includes(name)) {
            event = new KeyboardEvent(name, options)
        } else {
            event = new Event(name, options)
        }
        this.each(node => { node.dispatchEvent(event) })
        return this
    }
    attr(name, value) {
        if (value === undefined && typeof name == 'string') {
            return this[0] ? this[0].getAttribute(name) : undefined
        } else {
            let obj = {}
            if (typeof name == 'object') obj = name; else obj[name] = value
            this.each(node => {
                Object.entries(obj).forEach(([nm, val]) => { node.setAttribute(nm, val) })
            })
            return this
        }
    }
    removeAttr() {
        this.each(node => {
            Array.from(arguments).forEach(attr => {
                node.removeAttribute(attr)
            })
        })
        return this
    }
    prop(name, value) {
        if (value === undefined && typeof name == 'string') {
            return this[0] ? this[0][name] : undefined
        } else {
            let obj = {}
            if (typeof name == 'object') obj = name; else obj[name] = value
            this.each(node => {
                Object.entries(obj).forEach(([nm, val]) => {
                    let prop = Query._fixProp(nm)
                    node[prop] = val
                    if (prop == 'innerHTML') {
                        Query._scriptConvert(node)
                    }
                })
            })
            return this
        }
    }
    removeProp() {
        this.each(node => {
            Array.from(arguments).forEach(prop => { delete node[Query._fixProp(prop)] })
        })
        return this
    }
    data(key, value) {
        if (key instanceof Object) {
            Object.entries(key).forEach(item => { this.data(item[0], item[1]) })
            return
        }
        if (key && key.indexOf('-') != -1) {
            console.error(`Key "${key}" contains "-" (dash). Dashes are not allowed in property names. Use camelCase instead.`)
        }
        if (arguments.length < 2) {
            if (this[0]) {
                let data = Object.assign({}, this[0].dataset)
                Object.keys(data).forEach(key => {
                    if (data[key].startsWith('[') || data[key].startsWith('{')) {
                        try { data[key] = JSON.parse(data[key]) } catch (e) {}
                    }
                })
                return key ? data[key] : data
            } else {
                return undefined
            }
        } else {
            this.each(node => {
                if (value != null) {
                    node.dataset[key] = value instanceof Object ? JSON.stringify(value) : value
                } else {
                    delete node.dataset[key]
                }
            })
            return this
        }
    }
    removeData(key) {
        if (typeof key == 'string') key = key.split(/[,\s]+/)
        this.each(node => {
            key.forEach(k => { delete node.dataset[k] })
        })
        return this
    }
    show() {
        return this.toggle(true)
    }
    hide() {
        return this.toggle(false)
    }
    toggle(force) {
        return this.each(node => {
            let prev = node.style.display
            let dsp  = getComputedStyle(node).display
            let isHidden = (prev == 'none' || dsp == 'none')
            if (isHidden && (force == null || force === true)) { // show
                let def = node instanceof HTMLTableRowElement
                    ? 'table-row'
                    : node instanceof HTMLTableCellElement
                        ? 'table-cell'
                        : 'block'
                node.style.display = node._mQuery?.prevDisplay ?? (prev == dsp && dsp != 'none' ? '' : def)
                this._save(node, 'prevDisplay', null)
            }
            if (!isHidden && (force == null || force === false)) { // hide
                if (dsp != 'none') this._save(node, 'prevDisplay', dsp)
                node.style.setProperty('display', 'none')
            }
        })
    }
    empty() {
        return this.html('')
    }
    html(html) {
        return this.prop('innerHTML', html)
    }
    text(text) {
        return this.prop('textContent', text)
    }
    val(value) {
        return this.prop('value', value) // must be prop
    }
    change() {
        return this.trigger('change')
    }
    click() {
        return this.trigger('click')
    }
}
// create a new object each time
let query = function (selector, context) {
    // if a function, use as onload event
    if (typeof selector == 'function') {
        if (document.readyState == 'complete') {
            selector()
        } else {
            window.addEventListener('load', selector)
        }
    } else {
        return new Query(selector, context)
    }
}
// str -> doc-fragment
query.html = (str) => { let frag = Query._fragment(str); return query(frag.children, frag) }
query.version = Query.version
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2locale
 *
 * == TODO ==
 *  - add w2utils.lang wrap for all captions in all buttons.
 *  - check transition (also with layout)
 *  - deprecate w2utils.tooltip
 *
 * == 2.0 changes
 *  - CSP - fixed inline events (w2utils.tooltip still has it)
 *  - transition returns a promise
 *  - removed jQuery
 *  - refactores w2utils.message()
 *  - added w2utils.confirm()
 *  - added isPlainObject
 *  - added stripSpaces
 *  - implemented marker
 *  - cssPrefix - deprecated
 *  - w2utils.debounce
 *  - w2utils.prepareParams
 */

// variable that holds all w2ui objects
let w2ui = {}
class Utils {
    constructor () {
        this.version = '2.0.x'
        this.tmp = {}
        this.settings = this.extend({}, {
            'dataType'       : 'HTTPJSON', // can be HTTP, HTTPJSON, RESTFULL, JSON (case sensitive)
            'dateStartYear'  : 1950,  // start year for date-picker
            'dateEndYear'    : 2030,  // end year for date picker
            'macButtonOrder' : false, // if true, Yes on the right side
            'warnNoPhrase'   : false,  // call console.warn if lang() encounters a missing phrase
        }, w2locale, { phrases: null }), // if there are no phrases, then it is original language
        this.i18nCompare = Intl.Collator().compare
        this.hasLocalStorage = testLocalStorage()
        // some internal variables
        this.isMac = /Mac/i.test(navigator.platform)
        this.isMobile = /(iphone|ipod|ipad|mobile|android)/i.test(navigator.userAgent)
        this.isIOS = /(iphone|ipod|ipad)/i.test(navigator.platform)
        this.isAndroid = /(android)/i.test(navigator.userAgent)
        this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
        // Formatters: Primarily used in grid
        this.formatters = {
            'number'(value, params) {
                if (parseInt(params) > 20) params = 20
                if (parseInt(params) < 0) params = 0
                if (value == null || value === '') return ''
                return w2utils.formatNumber(parseFloat(value), params, true)
            },
            'float'(value, params) {
                return w2utils.formatters.number(value, params)
            },
            'int'(value, params) {
                return w2utils.formatters.number(value, 0)
            },
            'money'(value, params) {
                if (value == null || value === '') return ''
                let data = w2utils.formatNumber(Number(value), w2utils.settings.currencyPrecision)
                return (w2utils.settings.currencyPrefix || '') + data + (w2utils.settings.currencySuffix || '')
            },
            'currency'(value, params) {
                return w2utils.formatters.money(value, params)
            },
            'percent'(value, params) {
                if (value == null || value === '') return ''
                return w2utils.formatNumber(value, params || 1) + '%'
            },
            'size'(value, params) {
                if (value == null || value === '') return ''
                return w2utils.formatSize(parseInt(value))
            },
            'date'(value, params) {
                if (params === '') params = w2utils.settings.dateFormat
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, params, true)
                if (dt === false) dt = w2utils.isDate(value, params, true)
                return '<span title="'+ dt +'">' + w2utils.formatDate(dt, params) + '</span>'
            },
            'datetime'(value, params) {
                if (params === '') params = w2utils.settings.datetimeFormat
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, params, true)
                if (dt === false) dt = w2utils.isDate(value, params, true)
                return '<span title="'+ dt +'">' + w2utils.formatDateTime(dt, params) + '</span>'
            },
            'time'(value, params) {
                if (params === '') params = w2utils.settings.timeFormat
                if (params === 'h12') params = 'hh:mi pm'
                if (params === 'h24') params = 'h24:mi'
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, params, true)
                if (dt === false) dt = w2utils.isDate(value, params, true)
                return '<span title="'+ dt +'">' + w2utils.formatTime(value, params) + '</span>'
            },
            'timestamp'(value, params) {
                if (params === '') params = w2utils.settings.datetimeFormat
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, params, true)
                if (dt === false) dt = w2utils.isDate(value, params, true)
                return dt.toString ? dt.toString() : ''
            },
            'gmt'(value, params) {
                if (params === '') params = w2utils.settings.datetimeFormat
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, params, true)
                if (dt === false) dt = w2utils.isDate(value, params, true)
                return dt.toUTCString ? dt.toUTCString() : ''
            },
            'age'(value, params) {
                if (value == null || value === 0 || value === '') return ''
                let dt = w2utils.isDateTime(value, null, true)
                if (dt === false) dt = w2utils.isDate(value, null, true)
                return '<span title="'+ dt +'">' + w2utils.age(value) + (params ? (' ' + params) : '') + '</span>'
            },
            'interval'(value, params) {
                if (value == null || value === 0 || value === '') return ''
                return w2utils.interval(value) + (params ? (' ' + params) : '')
            },
            'toggle'(value, params) {
                return (value ? 'Yes' : '')
            },
            'password'(value, params) {
                let ret = ''
                for (let i = 0; i < value.length; i++) {
                    ret += '*'
                }
                return ret
            }
        }
        return
        function testLocalStorage() {
            // test if localStorage is available, see issue #1282
            let str = 'w2ui_test'
            try {
                localStorage.setItem(str, str)
                localStorage.removeItem(str)
                return true
            } catch (e) {
                return false
            }
        }
    }
    isBin(val) {
        let re = /^[0-1]+$/
        return re.test(val)
    }
    isInt(val) {
        let re = /^[-+]?[0-9]+$/
        return re.test(val)
    }
    isFloat(val) {
        if (typeof val === 'string') {
            val = val.replace(this.settings.groupSymbol, '')
                .replace(this.settings.decimalSymbol, '.')
        }
        return (typeof val === 'number' || (typeof val === 'string' && val !== '')) && !isNaN(Number(val))
    }
    isMoney(val) {
        if (typeof val === 'object' || val === '') return false
        if (this.isFloat(val)) return true
        let se = this.settings
        let re = new RegExp('^'+ (se.currencyPrefix ? '\\' + se.currencyPrefix + '?' : '') +
                            '[-+]?'+ (se.currencyPrefix ? '\\' + se.currencyPrefix + '?' : '') +
                            '[0-9]*[\\'+ se.decimalSymbol +']?[0-9]+'+ (se.currencySuffix ? '\\' + se.currencySuffix + '?' : '') +'$', 'i')
        if (typeof val === 'string') {
            val = val.replace(new RegExp(se.groupSymbol, 'g'), '')
        }
        return re.test(val)
    }
    isHex(val) {
        let re = /^(0x)?[0-9a-fA-F]+$/
        return re.test(val)
    }
    isAlphaNumeric(val) {
        let re = /^[a-zA-Z0-9_-]+$/
        return re.test(val)
    }
    isEmail(val) {
        let email = /^[a-zA-Z0-9._%\-+]+@[а-яА-Яa-zA-Z0-9.-]+\.[а-яА-Яa-zA-Z]+$/
        return email.test(val)
    }
    isIpAddress(val) {
        let re = new RegExp('^' +
            '((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}' +
            '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' +
            '$')
        return re.test(val)
    }
    isDate(val, format, retDate) {
        if (!val) return false
        let dt = 'Invalid Date'
        let month, day, year
        if (format == null) format = this.settings.dateFormat
        if (typeof val.getFullYear === 'function') { // date object
            year  = val.getFullYear()
            month = val.getMonth() + 1
            day   = val.getDate()
        } else if (parseInt(val) == val && parseInt(val) > 0) {
            val   = new Date(parseInt(val))
            year  = val.getFullYear()
            month = val.getMonth() + 1
            day   = val.getDate()
        } else {
            val = String(val)
            // convert month formats
            if (new RegExp('mon', 'ig').test(format)) {
                format = format.replace(/month/ig, 'm').replace(/mon/ig, 'm').replace(/dd/ig, 'd').replace(/[, ]/ig, '/').replace(/\/\//g, '/').toLowerCase()
                val    = val.replace(/[, ]/ig, '/').replace(/\/\//g, '/').toLowerCase()
                for (let m = 0, len = this.settings.fullmonths.length; m < len; m++) {
                    let t = this.settings.fullmonths[m]
                    val   = val.replace(new RegExp(t, 'ig'), (parseInt(m) + 1)).replace(new RegExp(t.substr(0, 3), 'ig'), (parseInt(m) + 1))
                }
            }
            // format date
            let tmp  = val.replace(/-/g, '/').replace(/\./g, '/').toLowerCase().split('/')
            let tmp2 = format.replace(/-/g, '/').replace(/\./g, '/').toLowerCase()
            if (tmp2 === 'mm/dd/yyyy') { month = tmp[0]; day = tmp[1]; year = tmp[2] }
            if (tmp2 === 'm/d/yyyy') { month = tmp[0]; day = tmp[1]; year = tmp[2] }
            if (tmp2 === 'dd/mm/yyyy') { month = tmp[1]; day = tmp[0]; year = tmp[2] }
            if (tmp2 === 'd/m/yyyy') { month = tmp[1]; day = tmp[0]; year = tmp[2] }
            if (tmp2 === 'yyyy/dd/mm') { month = tmp[2]; day = tmp[1]; year = tmp[0] }
            if (tmp2 === 'yyyy/d/m') { month = tmp[2]; day = tmp[1]; year = tmp[0] }
            if (tmp2 === 'yyyy/mm/dd') { month = tmp[1]; day = tmp[2]; year = tmp[0] }
            if (tmp2 === 'yyyy/m/d') { month = tmp[1]; day = tmp[2]; year = tmp[0] }
            if (tmp2 === 'mm/dd/yy') { month = tmp[0]; day = tmp[1]; year = tmp[2] }
            if (tmp2 === 'm/d/yy') { month = tmp[0]; day = tmp[1]; year = parseInt(tmp[2]) + 1900 }
            if (tmp2 === 'dd/mm/yy') { month = tmp[1]; day = tmp[0]; year = parseInt(tmp[2]) + 1900 }
            if (tmp2 === 'd/m/yy') { month = tmp[1]; day = tmp[0]; year = parseInt(tmp[2]) + 1900 }
            if (tmp2 === 'yy/dd/mm') { month = tmp[2]; day = tmp[1]; year = parseInt(tmp[0]) + 1900 }
            if (tmp2 === 'yy/d/m') { month = tmp[2]; day = tmp[1]; year = parseInt(tmp[0]) + 1900 }
            if (tmp2 === 'yy/mm/dd') { month = tmp[1]; day = tmp[2]; year = parseInt(tmp[0]) + 1900 }
            if (tmp2 === 'yy/m/d') { month = tmp[1]; day = tmp[2]; year = parseInt(tmp[0]) + 1900 }
        }
        if (!this.isInt(year)) return false
        if (!this.isInt(month)) return false
        if (!this.isInt(day)) return false
        year  = +year
        month = +month
        day   = +day
        dt    = new Date(year, month - 1, day)
        dt.setFullYear(year)
        // do checks
        if (month == null) return false
        if (String(dt) === 'Invalid Date') return false
        if ((dt.getMonth() + 1 !== month) || (dt.getDate() !== day) || (dt.getFullYear() !== year)) return false
        if (retDate === true) return dt; else return true
    }
    isTime(val, retTime) {
        // Both formats 10:20pm and 22:20
        if (val == null) return false
        let max, am, pm
        // -- process american format
        val      = String(val)
        val      = val.toUpperCase()
        am       = val.indexOf('AM') >= 0
        pm       = val.indexOf('PM') >= 0
        let ampm = (pm || am)
        if (ampm) max = 12; else max = 24
        val = val.replace('AM', '').replace('PM', '').trim()
        // ---
        let tmp = val.split(':')
        let h   = parseInt(tmp[0] || 0), m = parseInt(tmp[1] || 0), s = parseInt(tmp[2] || 0)
        // accept edge case: 3PM is a good timestamp, but 3 (without AM or PM) is NOT:
        if ((!ampm || tmp.length !== 1) && tmp.length !== 2 && tmp.length !== 3) { return false }
        if (tmp[0] === '' || h < 0 || h > max || !this.isInt(tmp[0]) || tmp[0].length > 2) { return false }
        if (tmp.length > 1 && (tmp[1] === '' || m < 0 || m > 59 || !this.isInt(tmp[1]) || tmp[1].length !== 2)) { return false }
        if (tmp.length > 2 && (tmp[2] === '' || s < 0 || s > 59 || !this.isInt(tmp[2]) || tmp[2].length !== 2)) { return false }
        // check the edge cases: 12:01AM is ok, as is 12:01PM, but 24:01 is NOT ok while 24:00 is (midnight; equivalent to 00:00).
        // meanwhile, there is 00:00 which is ok, but 0AM nor 0PM are okay, while 0:01AM and 0:00AM are.
        if (!ampm && max === h && (m !== 0 || s !== 0)) { return false }
        if (ampm && tmp.length === 1 && h === 0) { return false }
        if (retTime === true) {
            if (pm && h !== 12) h += 12 // 12:00pm - is noon
            if (am && h === 12) h += 12 // 12:00am - is midnight
            return {
                hours: h,
                minutes: m,
                seconds: s
            }
        }
        return true
    }
    isDateTime(val, format, retDate) {
        if (typeof val.getFullYear === 'function') { // date object
            if (retDate !== true) return true
            return val
        }
        let intVal = parseInt(val)
        if (intVal === val) {
            if (intVal < 0) return false
            else if (retDate !== true) return true
            else return new Date(intVal)
        }
        let tmp = String(val).indexOf(' ')
        if (tmp < 0) {
            if (String(val).indexOf('T') < 0 || String(new Date(val)) == 'Invalid Date') return false
            else if (retDate !== true) return true
            else return new Date(val)
        } else {
            if (format == null) format = this.settings.datetimeFormat
            let formats = format.split('|')
            let values  = [val.substr(0, tmp), val.substr(tmp).trim()]
            formats[0]  = formats[0].trim()
            if (formats[1]) formats[1] = formats[1].trim()
            // check
            let tmp1 = this.isDate(values[0], formats[0], true)
            let tmp2 = this.isTime(values[1], true)
            if (tmp1 !== false && tmp2 !== false) {
                if (retDate !== true) return true
                tmp1.setHours(tmp2.hours)
                tmp1.setMinutes(tmp2.minutes)
                tmp1.setSeconds(tmp2.seconds)
                return tmp1
            } else {
                return false
            }
        }
    }
    age(dateStr) {
        let d1
        if (dateStr === '' || dateStr == null) return ''
        if (typeof dateStr.getFullYear === 'function') { // date object
            d1 = dateStr
        } else if (parseInt(dateStr) == dateStr && parseInt(dateStr) > 0) {
            d1 = new Date(parseInt(dateStr))
        } else {
            d1 = new Date(dateStr)
        }
        if (String(d1) === 'Invalid Date') return ''
        let d2     = new Date()
        let sec    = (d2.getTime() - d1.getTime()) / 1000
        let amount = ''
        let type   = ''
        if (sec < 0) {
            amount = 0
            type   = 'sec'
        } else if (sec < 60) {
            amount = Math.floor(sec)
            type   = 'sec'
            if (sec < 0) { amount = 0; type = 'sec' }
        } else if (sec < 60*60) {
            amount = Math.floor(sec/60)
            type   = 'min'
        } else if (sec < 24*60*60) {
            amount = Math.floor(sec/60/60)
            type   = 'hour'
        } else if (sec < 30*24*60*60) {
            amount = Math.floor(sec/24/60/60)
            type   = 'day'
        } else if (sec < 365*24*60*60) {
            amount = Math.floor(sec/30/24/60/60*10)/10
            type   = 'month'
        } else if (sec < 365*4*24*60*60) {
            amount = Math.floor(sec/365/24/60/60*10)/10
            type   = 'year'
        } else if (sec >= 365*4*24*60*60) {
            // factor in leap year shift (only older then 4 years)
            amount = Math.floor(sec/365.25/24/60/60*10)/10
            type   = 'year'
        }
        return amount + ' ' + type + (amount > 1 ? 's' : '')
    }
    interval(value) {
        let ret = ''
        if (value < 100) {
            ret = '< 0.01 sec'
        } else if (value < 1000) {
            ret = (Math.floor(value / 10) / 100) + ' sec'
        } else if (value < 10000) {
            ret = (Math.floor(value / 100) / 10) + ' sec'
        } else if (value < 60000) {
            ret = Math.floor(value / 1000) + ' secs'
        } else if (value < 3600000) {
            ret = Math.floor(value / 60000) + ' mins'
        } else if (value < 86400000) {
            ret = Math.floor(value / 3600000 * 10) / 10 + ' hours'
        } else if (value < 2628000000) {
            ret = Math.floor(value / 86400000 * 10) / 10 + ' days'
        } else if (value < 3.1536e+10) {
            ret = Math.floor(value / 2628000000 * 10) / 10 + ' months'
        } else {
            ret = Math.floor(value / 3.1536e+9) / 10 + ' years'
        }
        return ret
    }
    date(dateStr) {
        if (dateStr === '' || dateStr == null || (typeof dateStr === 'object' && !dateStr.getMonth)) return ''
        let d1 = new Date(dateStr)
        if (this.isInt(dateStr)) d1 = new Date(Number(dateStr)) // for unix timestamps
        if (String(d1) === 'Invalid Date') return ''
        let months = this.settings.shortmonths
        let d2     = new Date() // today
        let d3     = new Date()
        d3.setTime(d3.getTime() - 86400000) // yesterday
        let dd1 = months[d1.getMonth()] + ' ' + d1.getDate() + ', ' + d1.getFullYear()
        let dd2 = months[d2.getMonth()] + ' ' + d2.getDate() + ', ' + d2.getFullYear()
        let dd3 = months[d3.getMonth()] + ' ' + d3.getDate() + ', ' + d3.getFullYear()
        let time  = (d1.getHours() - (d1.getHours() > 12 ? 12 :0)) + ':' + (d1.getMinutes() < 10 ? '0' : '') + d1.getMinutes() + ' ' + (d1.getHours() >= 12 ? 'pm' : 'am')
        let time2 = (d1.getHours() - (d1.getHours() > 12 ? 12 :0)) + ':' + (d1.getMinutes() < 10 ? '0' : '') + d1.getMinutes() + ':' + (d1.getSeconds() < 10 ? '0' : '') + d1.getSeconds() + ' ' + (d1.getHours() >= 12 ? 'pm' : 'am')
        let dsp   = dd1
        if (dd1 === dd2) dsp = time
        if (dd1 === dd3) dsp = this.lang('Yesterday')
        return '<span title="'+ dd1 +' ' + time2 +'">'+ dsp +'</span>'
    }
    formatSize(sizeStr) {
        if (!this.isFloat(sizeStr) || sizeStr === '') return ''
        sizeStr = parseFloat(sizeStr)
        if (sizeStr === 0) return 0
        let sizes = ['Bt', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB']
        let i     = parseInt( Math.floor( Math.log(sizeStr) / Math.log(1024) ) )
        return (Math.floor(sizeStr / Math.pow(1024, i) * 10) / 10).toFixed(i === 0 ? 0 : 1) + ' ' + (sizes[i] || '??')
    }
    formatNumber(val, fraction, useGrouping) {
        if (val == null || val === '' || typeof val === 'object') return ''
        let options = {
            minimumFractionDigits: parseInt(fraction),
            maximumFractionDigits: parseInt(fraction),
            useGrouping: !!useGrouping
        }
        if (fraction == null || fraction < 0) {
            options.minimumFractionDigits = 0
            options.maximumFractionDigits = 20
        }
        return parseFloat(val).toLocaleString(this.settings.locale, options)
    }
    formatDate(dateStr, format) { // IMPORTANT dateStr HAS TO BE valid JavaScript Date String
        if (!format) format = this.settings.dateFormat
        if (dateStr === '' || dateStr == null || (typeof dateStr === 'object' && !dateStr.getMonth)) return ''
        let dt = new Date(dateStr)
        if (this.isInt(dateStr)) dt = new Date(Number(dateStr)) // for unix timestamps
        if (String(dt) === 'Invalid Date') return ''
        let year  = dt.getFullYear()
        let month = dt.getMonth()
        let date  = dt.getDate()
        return format.toLowerCase()
            .replace('month', this.settings.fullmonths[month])
            .replace('mon', this.settings.shortmonths[month])
            .replace(/yyyy/g, ('000' + year).slice(-4))
            .replace(/yyy/g, ('000' + year).slice(-4))
            .replace(/yy/g, ('0' + year).slice(-2))
            .replace(/(^|[^a-z$])y/g, '$1' + year) // only y's that are not preceded by a letter
            .replace(/mm/g, ('0' + (month + 1)).slice(-2))
            .replace(/dd/g, ('0' + date).slice(-2))
            .replace(/th/g, (date == 1 ? 'st' : 'th'))
            .replace(/th/g, (date == 2 ? 'nd' : 'th'))
            .replace(/th/g, (date == 3 ? 'rd' : 'th'))
            .replace(/(^|[^a-z$])m/g, '$1' + (month + 1)) // only y's that are not preceded by a letter
            .replace(/(^|[^a-z$])d/g, '$1' + date) // only y's that are not preceded by a letter
    }
    formatTime(dateStr, format) { // IMPORTANT dateStr HAS TO BE valid JavaScript Date String
        if (!format) format = this.settings.timeFormat
        if (dateStr === '' || dateStr == null || (typeof dateStr === 'object' && !dateStr.getMonth)) return ''
        let dt = new Date(dateStr)
        if (this.isInt(dateStr)) dt = new Date(Number(dateStr)) // for unix timestamps
        if (this.isTime(dateStr)) {
            let tmp = this.isTime(dateStr, true)
            dt      = new Date()
            dt.setHours(tmp.hours)
            dt.setMinutes(tmp.minutes)
        }
        if (String(dt) === 'Invalid Date') return ''
        let type = 'am'
        let hour = dt.getHours()
        let h24  = dt.getHours()
        let min  = dt.getMinutes()
        let sec  = dt.getSeconds()
        if (min < 10) min = '0' + min
        if (sec < 10) sec = '0' + sec
        if (format.indexOf('am') !== -1 || format.indexOf('pm') !== -1) {
            if (hour >= 12) type = 'pm'
            if (hour > 12) hour = hour - 12
            if (hour === 0) hour = 12
        }
        return format.toLowerCase()
            .replace('am', type)
            .replace('pm', type)
            .replace('hhh', (hour < 10 ? '0' + hour : hour))
            .replace('hh24', (h24 < 10 ? '0' + h24 : h24))
            .replace('h24', h24)
            .replace('hh', hour)
            .replace('mm', min)
            .replace('mi', min)
            .replace('ss', sec)
            .replace(/(^|[^a-z$])h/g, '$1' + hour) // only y's that are not preceded by a letter
            .replace(/(^|[^a-z$])m/g, '$1' + min) // only y's that are not preceded by a letter
            .replace(/(^|[^a-z$])s/g, '$1' + sec) // only y's that are not preceded by a letter
    }
    formatDateTime(dateStr, format) {
        let fmt
        if (dateStr === '' || dateStr == null || (typeof dateStr === 'object' && !dateStr.getMonth)) return ''
        if (typeof format !== 'string') {
            fmt = [this.settings.dateFormat, this.settings.timeFormat]
        } else {
            fmt    = format.split('|')
            fmt[0] = fmt[0].trim()
            fmt[1] = (fmt.length > 1 ? fmt[1].trim() : this.settings.timeFormat)
        }
        // older formats support
        if (fmt[1] === 'h12') fmt[1] = 'h:m pm'
        if (fmt[1] === 'h24') fmt[1] = 'h24:m'
        return this.formatDate(dateStr, fmt[0]) + ' ' + this.formatTime(dateStr, fmt[1])
    }
    stripSpaces(html) {
        if (html == null) return html
        switch (typeof html) {
            case 'number':
                break
            case 'string':
                html = String(html).replace(/(?:\r\n|\r|\n)/g, ' ').replace(/\s\s+/g, ' ').trim()
                break
            case 'object':
                // does not modify original object, but creates a copy
                if (Array.isArray(html)) {
                    html = this.extend([], html)
                    html.forEach((key, ind) => {
                        html[ind] = this.stripSpaces(key)
                    })
                } else {
                    html = this.extend({}, html)
                    Object.keys(html).forEach(key => {
                        html[key] = this.stripSpaces(html[key])
                    })
                }
                break
        }
        return html
    }
    stripTags(html) {
        if (html == null) return html
        switch (typeof html) {
            case 'number':
                break
            case 'string':
                html = String(html).replace(/<(?:[^>=]|='[^']*'|="[^"]*"|=[^'"][^\s>]*)*>/ig, '')
                break
            case 'object':
                // does not modify original object, but creates a copy
                if (Array.isArray(html)) {
                    html = this.extend([], html)
                    html.forEach((key, ind) => {
                        html[ind] = this.stripTags(key)
                    })
                } else {
                    html = this.extend({}, html)
                    Object.keys(html).forEach(key => {
                        html[key] = this.stripTags(html[key])
                    })
                }
                break
        }
        return html
    }
    encodeTags(html) {
        if (html == null) return html
        switch (typeof html) {
            case 'number':
                break
            case 'string':
                html = String(html).replace(/&/g, '&amp;').replace(/>/g, '&gt;').replace(/</g, '&lt;').replace(/"/g, '&quot;')
                break
            case 'object':
                // does not modify original object, but creates a copy
                if (Array.isArray(html)) {
                    html = this.extend([], html)
                    html.forEach((key, ind) => {
                        html[ind] = this.encodeTags(key)
                    })
                } else {
                    html = this.extend({}, html)
                    Object.keys(html).forEach(key => {
                        html[key] = this.encodeTags(html[key])
                    })
                }
                break
        }
        return html
    }
    decodeTags(html) {
        if (html == null) return html
        switch (typeof html) {
            case 'number':
                break
            case 'string':
                html = String(html).replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
                break
            case 'object':
                // does not modify original object, but creates a copy
                if (Array.isArray(html)) {
                    html = this.extend([], html)
                    html.forEach((key, ind) => {
                        html[ind] = this.decodeTags(key)
                    })
                } else {
                    html = this.extend({}, html)
                    Object.keys(html).forEach(key => {
                        html[key] = this.decodeTags(html[key])
                    })
                }
                break
        }
        return html
    }
    escapeId(id) {
        // This logic is borrowed from jQuery
        if (id === '' || id == null) return ''
        let re = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g
        return (id + '').replace(re, (ch, asCodePoint) => {
            if (asCodePoint) {
                if (ch === '\0') return '\uFFFD'
                return ch.slice( 0, -1 ) + '\\' + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + ' '
            }
            return '\\' + ch
        })
    }
    unescapeId(id) {
        // This logic is borrowed from jQuery
        if (id === '' || id == null) return ''
        let re = /\\[\da-fA-F]{1,6}[\x20\t\r\n\f]?|\\([^\r\n\f])/g
        return id.replace(re, (escape, nonHex) => {
            let high = '0x' + escape.slice( 1 ) - 0x10000
            return nonHex ? nonHex : high < 0
                    ? String.fromCharCode(high + 0x10000 )
                    : String.fromCharCode(high >> 10 | 0xD800, high & 0x3FF | 0xDC00)
        })
    }
    base64encode(input) {
        // Fast Native support in Chrome since 2010
        return btoa(input) // binary to ascii
    }
    base64decode(input) {
        // Fast Native support in Chrome since 2010
        return atob(input) // ascii to binary
    }
    async sha256(str) {
        const utf8 = new TextEncoder().encode(str)
        return crypto.subtle.digest('SHA-256', utf8).then((hashBuffer) => {
            const hashArray = Array.from(new Uint8Array(hashBuffer))
            return hashArray.map((bytes) => bytes.toString(16).padStart(2, '0')).join('')
        })
    }
    transition(div_old, div_new, type, callBack) {
        return new Promise((resolve, reject) => {
            let styles = getComputedStyle(div_old)
            let width  = parseInt(styles.width)
            let height = parseInt(styles.height)
            let time   = 0.5
            if (!div_old || !div_new) {
                console.log('ERROR: Cannot do transition when one of the divs is null')
                return
            }
            div_old.parentNode.style.cssText += 'perspective: 900px; overflow: hidden;'
            div_old.style.cssText            += '; position: absolute; z-index: 1019; backface-visibility: hidden'
            div_new.style.cssText            += '; position: absolute; z-index: 1020; backface-visibility: hidden'
            switch (type) {
                case 'slide-left':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; transform: translate3d('+ width + 'px, 0, 0)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: translate3d(0, 0, 0)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: translate3d(-'+ width +'px, 0, 0)'
                    }, 1)
                    break
                case 'slide-right':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; transform: translate3d(-'+ width +'px, 0, 0)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: translate3d(0px, 0, 0)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: translate3d('+ width +'px, 0, 0)'
                    }, 1)
                    break
                case 'slide-down':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; z-index: 1; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; z-index: 0; transform: translate3d(0, 0, 0)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: translate3d(0, 0, 0)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: translate3d(0, '+ height +'px, 0)'
                    }, 1)
                    break
                case 'slide-up':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; transform: translate3d(0, '+ height +'px, 0)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: translate3d(0, 0, 0)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: translate3d(0, 0, 0)'
                    }, 1)
                    break
                case 'flip-left':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: rotateY(0deg)'
                    div_new.style.cssText += 'overflow: hidden; transform: rotateY(-180deg)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: rotateY(0deg)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: rotateY(180deg)'
                    }, 1)
                    break
                case 'flip-right':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: rotateY(0deg)'
                    div_new.style.cssText += 'overflow: hidden; transform: rotateY(180deg)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: rotateY(0deg)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: rotateY(-180deg)'
                    }, 1)
                    break
                case 'flip-down':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: rotateX(0deg)'
                    div_new.style.cssText += 'overflow: hidden; transform: rotateX(180deg)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: rotateX(0deg)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: rotateX(-180deg)'
                    }, 1)
                    break
                case 'flip-up':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: rotateX(0deg)'
                    div_new.style.cssText += 'overflow: hidden; transform: rotateX(-180deg)'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: rotateX(0deg)'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: rotateX(180deg)'
                    }, 1)
                    break
                case 'pop-in':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0); transform: scale(.8); opacity: 0;'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; transform: scale(1); opacity: 1;'
                        div_old.style.cssText += 'transition: '+ time +'s;'
                    }, 1)
                    break
                case 'pop-out':
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0); transform: scale(1); opacity: 1;'
                    div_new.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0); opacity: 0;'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; opacity: 1;'
                        div_old.style.cssText += 'transition: '+ time +'s; transform: scale(1.7); opacity: 0;'
                    }, 1)
                    break
                default:
                    // init divs
                    div_old.style.cssText += 'overflow: hidden; transform: translate3d(0, 0, 0)'
                    div_new.style.cssText += 'overflow: hidden; translate3d(0, 0, 0); opacity: 0;'
                    query(div_new).show()
                    // -- need a timing function because otherwise not working
                    setTimeout(() => {
                        div_new.style.cssText += 'transition: '+ time +'s; opacity: 1;'
                        div_old.style.cssText += 'transition: '+ time +'s'
                    }, 1)
                    break
            }
            setTimeout(() => {
                if (type === 'slide-down') {
                    query(div_old).css('z-index', '1019')
                    query(div_new).css('z-index', '1020')
                }
                if (div_new) {
                    query(div_new)
                        .css({ 'opacity': '1' })
                        .css({ 'transition': '', 'transform' : '' })
                }
                if (div_old) {
                    query(div_old)
                        .css({ 'opacity': '1' })
                        .css({ 'transition': '', 'transform' : '' })
                }
                if (typeof callBack === 'function') callBack()
                resolve()
            }, time * 1000)
        })
    }
    lock(box, options = {}) {
        if (box == null) return
        if (typeof options == 'string') {
            options = { msg: options }
        }
        if (arguments[2]) {
            options.spinner = arguments[2]
        }
        options = this.extend({
            spinner: false
        }, options)
        // for backward compatibility
        if (box?.[0] instanceof Node) {
            box = Array.isArray(box) ? box : box.get()
        }
        if (!options.msg && options.msg !== 0) options.msg = ''
        this.unlock(box)
        let el = query(box).get(0)
        let pWidth = el.scrollWidth
        let pHeight = el.scrollHeight
        // if it is body and only has absolute elements, its height will be 0, need to lock entire window
        if (el.tagName == 'BODY') {
            if (pWidth < innerWidth) pWidth = innerWidth
            if (pHeight < innerHeight) pHeight = innerHeight
        }
        query(box).prepend(
            `<div class="w2ui-lock" style="height: ${pHeight}px; width: ${pWidth}px"></div>` +
            '<div class="w2ui-lock-msg"></div>'
        )
        let $lock = query(box).find('.w2ui-lock')
        let $mess = query(box).find('.w2ui-lock-msg')
        if (!options.msg) {
            $mess.css({
                'background-color': 'transparent',
                'background-image': 'none',
                'border': '0px',
                'box-shadow': 'none'
            })
        }
        if (options.spinner === true) {
            options.msg = `<div class="w2ui-spinner" ${(!options.msg ? 'style="width: 35px; height: 35px"' : '')}></div>`
                + options.msg
        }
        if (options.msg) {
            $mess.html(options.msg).css('display', 'block')
        } else {
            $mess.remove()
        }
        if (options.opacity != null) {
            $lock.css('opacity', options.opacity)
        }
        $lock.css({ display: 'block' })
        if (options.bgColor) {
            $lock.css({ 'background-color': options.bgColor })
        }
        let styles = getComputedStyle($lock.get(0))
        let opacity = styles.opacity ?? 0.15
        $lock
            .on('mousedown', function() {
                if (typeof options.onClick == 'function') {
                    options.onClick()
                } else {
                    $lock.css({
                        'transition': '.2s',
                        'opacity': opacity * 1.5
                    })
                }
            })
            .on('mouseup', function() {
                if (typeof options.onClick !== 'function') {
                    $lock.css({
                        'transition': '.2s',
                        'opacity': opacity
                    })
                }
            })
            .on('mousewheel', function(event) {
                if (event) {
                    event.stopPropagation()
                    event.preventDefault()
                }
            })
    }
    unlock(box, speed) {
        if (box == null) return
        clearTimeout(box._prevUnlock)
        // for backward compatibility
        if (box?.[0] instanceof Node) {
            box = Array.isArray(box) ? box : box.get()
        }
        if (this.isInt(speed) && speed > 0) {
            query(box).find('.w2ui-lock').css({
                transition: (speed/1000) + 's',
                opacity: 0,
            })
            let _box = query(box).get(0)
            clearTimeout(_box._prevUnlock)
            _box._prevUnlock = setTimeout(() => {
                query(box).find('.w2ui-lock').remove()
            }, speed)
            query(box).find('.w2ui-lock-msg').remove()
        } else {
            query(box).find('.w2ui-lock').remove()
            query(box).find('.w2ui-lock-msg').remove()
        }
    }
    /**
     * Opens a context message, similar in parameters as w2popup.open()
     *
     * Sample Calls
     * w2utils.message({ box: '#div' }, 'message').ok(() => {})
     * w2utils.message({ box: '#div' }, { text: 'message', width: 300 }).ok(() => {})
     * w2utils.message({ box: '#div' }, { text: 'message', actions: ['Save'] }).Save(() => {})
     *
     * Used in w2grid, w2form, w2layout (should be in w2popup too)
     * should be called with .call(...) method
     *
     * @param where = {
     *      box,     // where to open
     *      after,   // title if any, adds title heights
     *      param    // additional parameters, used in layouts for panel
     * }
     * @param options {
     *      width,      // (int), width in px, if negative, then it is maxWidth - width
     *      height,     // (int), height in px, if negative, then it is maxHeight - height
     *      text,       // centered text
     *      body,       // body of the message
     *      buttons,    // buttons of the message
     *      html,       // if body & buttons are not defined, then html is the entire message
     *      focus,      // int or id with a selector, default is 0
     *      hideOn,     // ['esc', 'click'], default is ['esc']
     *      actions,    // array of actions (only if buttons is not defined)
     *      onOpen,     // event when opened
     *      onClose,    // event when closed
     *      onAction,   // event on action
     * }
     */
    message(where, options) {
        let closeTimer, openTimer, edata
        let removeLast = () => {
            let msgs = query(where?.box).find('.w2ui-message')
            if (msgs.length == 0) return // no messages already
            options = msgs.get(0)._msg_options || {}
            if (typeof options?.close == 'function') {
                options.close()
            }
        }
        let closeComplete = (options) => {
            let focus = options.box._msg_prevFocus
            if (query(where.box).find('.w2ui-message').length <= 1) {
                if (where.owner) {
                    where.owner.unlock(where.param, 150)
                } else {
                    this.unlock(where.box, 150)
                }
            } else {
                query(where.box).find(`#w2ui-message-${where.owner?.name}-${options.msgIndex-1}`).css('z-index', 1500)
            }
            if (focus) {
                let msg = query(focus).closest('.w2ui-message')
                if (msg.length > 0) {
                    let opt = msg.get(0)._msg_options
                    opt.setFocus(focus)
                } else {
                    focus.focus()
                }
            } else {
                if (typeof where.owner?.focus == 'function') where.owner.focus()
            }
            query(options.box).remove()
            if (options.msgIndex === 0) {
                head.css('z-index', options.tmp.zIndex)
                query(where.box).css('overflow', options.tmp.overflow)
            }
            // event after
            if (options.trigger) {
                edata.finish()
            }
        }
        if (typeof options == 'string' || typeof options == 'number') {
            options = {
                width : (String(options).length < 300 ? 350 : 550),
                height: (String(options).length < 300 ? 170: 250),
                text  : String(options),
            }
        }
        if (typeof options != 'object') {
            removeLast()
            return
        }
        if (options.text != null) options.body = `<div class="w2ui-centered w2ui-msg-text">${options.text}</div>`
        if (options.width == null) options.width = 350
        if (options.height == null) options.height = 170
        if (options.hideOn == null) options.hideOn = ['esc']
        // mix in events
        if (options.on == null) {
            let opts = options
            options = new w2base()
            w2utils.extend(options, opts) // needs to be w2utils
        }
        options.on('open', (event) => {
            w2utils.bindEvents(query(options.box).find('.w2ui-eaction'), options) // options is w2base object
            query(event.detail.box).find('button, input, textarea, [name=hidden-first]')
                .off('.message')
                .on('keydown.message', function(evt) {
                    if (evt.keyCode == 27 && options.hideOn.includes('esc')) {
                        if (options.cancelAction) {
                            options.action(options.cancelAction)
                        } else {
                            options.close()
                        }
                    }
                })
            // timeout is needed because messages opens over 0.3 seconds
            setTimeout(() => options.setFocus(options.focus), 300)
        })
        options.off('.prom')
        let prom = {
            self: options,
            action(callBack) {
                options.on('action.prom', callBack)
                return prom
            },
            close(callBack) {
                options.on('close.prom', callBack)
                return prom
            },
            open(callBack) {
                options.on('open.prom', callBack)
                return prom
            },
            then(callBack) {
                options.on('open:after.prom', callBack)
                return prom
            }
        }
        if (options.actions == null && options.buttons == null && options.html == null) {
            options.actions = { Ok(event) { event.detail.self.close() }}
        }
        options.off('.buttons')
        if (options.actions != null) {
            options.buttons = ''
            Object.keys(options.actions).forEach((action) => {
                let handler = options.actions[action]
                let btnAction = action
                if (typeof handler == 'function') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction" data-click='["action","${action}","event"]' name="${action}">${action}</button>`
                }
                if (typeof handler == 'object') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction ${handler.class || ''}" name="${action}" data-click='["action","${action}","event"]'
                        style="${handler.style ?? ''}" ${handler.attrs ?? ''}>${handler.text || action}</button>`
                    btnAction = Array.isArray(options.actions) ? handler.text : action
                }
                if (typeof handler == 'string') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction" name="${handler}" data-click='["action","${handler}","event"]'>${handler}</button>`
                    btnAction = handler
                }
                if (typeof btnAction == 'string') {
                    btnAction = btnAction[0].toLowerCase() + btnAction.substr(1).replace(/\s+/g, '')
                }
                prom[btnAction] = function (callBack) {
                    options.on('action.buttons', (event) => {
                        let target = event.detail.action[0].toLowerCase() + event.detail.action.substr(1).replace(/\s+/g, '')
                        if (target == btnAction) callBack(event)
                    })
                    return prom
                }
            })
        }
        // trim if any
        Array('html', 'body', 'buttons').forEach(param => {
            options[param] = String(options[param] ?? '').trim()
        })
        if (options.body !== '' || options.buttons !== '') {
            options.html = `
                <div class="w2ui-message-body">${options.body || ''}</div>
                <div class="w2ui-message-buttons">${options.buttons || ''}</div>
            `
        }
        let styles  = getComputedStyle(query(where.box).get(0))
        let pWidth  = parseFloat(styles.width)
        let pHeight = parseFloat(styles.height)
        let titleHeight = 0
        if (query(where.after).length > 0) {
            styles = getComputedStyle(query(where.after).get(0))
            titleHeight = parseInt(styles.display != 'none' ? parseInt(styles.height) : 0)
        }
        if (options.width > pWidth) options.width = pWidth - 10
        if (options.height > pHeight - titleHeight) options.height = pHeight - 10 - titleHeight
        options.originalWidth  = options.width
        options.originalHeight = options.height
        if (parseInt(options.width) < 0) options.width = pWidth + options.width
        if (parseInt(options.width) < 10) options.width = 10
        if (parseInt(options.height) < 0) options.height = pHeight + options.height - titleHeight
        if (parseInt(options.height) < 10) options.height = 10
        // negative value means margin
        if (options.originalHeight < 0) options.height = pHeight + options.originalHeight - titleHeight
        if (options.originalWidth < 0) options.width = pWidth + options.originalWidth * 2 // x 2 because there is left and right margin
        let head = query(where.box).find(where.after) // needed for z-index manipulations
        if (!options.tmp) {
            options.tmp = {
                zIndex: head.css('z-index'),
                overflow: styles.overflow
            }
        }
        // remove message
        if (options.html === '' && options.body === '' && options.buttons === '') {
            removeLast()
        } else {
            options.msgIndex = query(where.box).find('.w2ui-message').length
            if (options.msgIndex === 0 && typeof this.lock == 'function') {
                query(where.box).css('overflow', 'hidden')
                if (where.owner) { // where.praram is used in the panel
                    where.owner.lock(where.param)
                } else {
                    this.lock(where.box)
                }
            }
            // send back previous messages
            query(where.box).find('.w2ui-message').css('z-index', 1390)
            head.css('z-index', 1501)
            // add message
            let content = `
                <div id="w2ui-message-${where.owner?.name}-${options.msgIndex}" class="w2ui-message" data-mousedown="stop"
                    style="z-index: 1500; left: ${((pWidth - options.width) / 2)}px; top: ${titleHeight}px;
                        width: ${options.width}px; height: ${options.height}px; transform: translateY(-${options.height}px)"
                    ${options.hideOn.includes('click')
                        ? where.param
                            ? `data-click='["message", "${where.param}"]`
                            : 'data-click="message"'
                        : ''}>
                    <span name="hidden-first" tabindex="0" style="position: absolute; top: 0; outline: none"></span>
                    ${options.html}
                    <span name="hidden-last" tabindex="0" style="position: absolute; top: 0; outline: none"></span>
                </div>`
            if (query(where.after).length > 0) {
                query(where.box).find(where.after).after(content)
            } else {
                query(where.box).prepend(content)
            }
            options.box = query(where.box).find(`#w2ui-message-${where.owner?.name}-${options.msgIndex}`)[0]
            w2utils.bindEvents(options.box, this)
            query(options.box)
                .addClass('animating')
            // remember options and prev focus
            options.box._msg_options = options
            options.box._msg_prevFocus = document.activeElement
            // timeout is needs so that callBacks are setup
            setTimeout(() => {
                // before event
                edata = options.trigger('open', { target: this.name, box: options.box, self: options })
                if (edata.isCancelled === true) {
                    query(where.box).find(`#w2ui-message-${where.owner?.name}-${options.msgIndex}`).remove()
                    if (options.msgIndex === 0) {
                        head.css('z-index', options.tmp.zIndex)
                        query(where.box).css('overflow', options.tmp.overflow)
                    }
                    return
                }
                // slide down
                query(options.box).css({
                    transition: '0.3s',
                    transform: 'translateY(0px)'
                })
            }, 0)
            // timeout is needed so that animation can finish
            openTimer = setTimeout(() => {
                // has to be on top of lock
                query(where.box)
                    .find(`#w2ui-message-${where.owner?.name}-${options.msgIndex}`)
                    .removeClass('animating')
                    .css({ 'transition': '0s' })
                // event after
                edata.finish()
            }, 300)
        }
        // action handler
        options.action = (action, event) => {
            let click = options.actions[action]
            if (click instanceof Object && click.onClick) click = click.onClick
            // event before
            let edata = options.trigger('action', { target: this.name, action, self: options,
                originalEvent: event, value: options.input ? options.input.value : null })
            if (edata.isCancelled === true) return
            // default actions
            if (typeof click === 'function') click(edata)
            // event after
            edata.finish()
        }
        options.close = () => {
            edata = options.trigger('close', { target: 'self', box: options.box, self: options })
            if (edata.isCancelled === true) return
            clearTimeout(openTimer)
            if (query(options.box).hasClass('animating')) {
                clearTimeout(closeTimer)
                closeComplete(options)
                return
            }
            // default behavior
            query(options.box)
                .addClass('w2ui-closing animating')
                .css({
                    'transition': '0.15s',
                    'transform': 'translateY(-' + options.height + 'px)'
                })
            if (options.msgIndex !== 0) {
                // previous message
                query(where.box).find(`#w2ui-message-${where.owner?.name}-${options.msgIndex-1}`).css('z-index', 1499)
            }
            closeTimer = setTimeout(() => { closeComplete(options) }, 150)
        }
        options.setFocus = (focus) => {
            // in message or popup
            let cnt = query(where.box).find('.w2ui-message').length - 1
            let box = query(where.box).find(`#w2ui-message-${where.owner?.name}-${cnt}`)
            let sel = 'input, button, select, textarea, [contentEditable], .w2ui-input'
            if (focus != null) {
                let el = isNaN(focus)
                    ? box.find(sel).filter(focus).get(0)
                    : box.find(sel).get(focus)
                el?.focus()
            } else {
                box.find('[name=hidden-first]').get(0)?.focus()
            }
            // clear focus if there are other messages
            query(where.box)
                .find('.w2ui-message')
                .find(sel + ',[name=hidden-first],[name=hidden-last]')
                .off('.keep-focus')
            // keep focus/blur inside popup
            query(box)
                .find(sel + ',[name=hidden-first],[name=hidden-last]')
                .on('blur.keep-focus', function (event) {
                    setTimeout(() => {
                        let focus = document.activeElement
                        let inside = query(box).find(sel).filter(focus).length > 0
                        let name = query(focus).attr('name')
                        if (!inside && focus && focus !== document.body) {
                            query(box).find(sel).get(0)?.focus()
                        }
                        if (name == 'hidden-last') {
                            query(box).find(sel).get(0)?.focus()
                        }
                        if (name == 'hidden-first') {
                            query(box).find(sel).get(-1)?.focus()
                        }
                    }, 1)
                })
        }
        return prom
    }
    /**
     * Shows small notification message at the bottom of the page, or containter that you specify
     * in options.where (could be element or a selector)
     *
     * w2utils.notify('Document saved')
     * w2utils.notify('Mesage sent ${udon}', { actions: { undo: function () {...} }})
     *
     * @param {String/Object} options can be {
     *      text: string,       // message, can be html
     *      where: el/selector, // element or selector where to show, default is document.body
     *      timeout: int,       // timeout when to hide, if 0 - indefinite
     *      error: boolean,     // add error clases
     *      class: string,      // additional class strings
     *      actions: object     // object with action functions, it should correspot to templated text: '... ${action} ...'
     *  }
     * @returns promise
     */
    notify(text, options) {
        return new Promise(resolve => {
            if (typeof text == 'object') {
                options = text
                text = options.text
            }
            options = options || {}
            options.where = options.where ?? document.body
            options.timeout = options.timeout ?? 15_000 // 15 secodns or will be hidden on route change
            if (typeof this.tmp.notify_resolve == 'function') {
                this.tmp.notify_resolve()
                query(this.tmp.notify_where).find('#w2ui-notify').remove()
            }
            this.tmp.notify_resolve = resolve
            this.tmp.notify_where = options.where
            clearTimeout(this.tmp.notify_timer)
            if (text) {
                if (typeof options.actions == 'object') {
                    let actions = {}
                    Object.keys(options.actions).forEach(action => {
                        actions[action] = `<a class="w2ui-notify-link" value="${action}">${action}</a>`
                    })
                    text = this.execTemplate(text, actions)
                }
                let html = `
                    <div id="w2ui-notify">
                        <div class="${options.class} ${options.error ? 'w2ui-notify-error' : ''}">
                            ${text}
                            <span class="w2ui-notify-close w2ui-icon-cross"></span>
                        </div>
                    </div>`
                query(options.where).append(html)
                query(options.where).find('#w2ui-notify').find('.w2ui-notify-close')
                    .on('click', event => {
                        query(options.where).find('#w2ui-notify').remove()
                        resolve()
                    })
                if (options.actions) {
                    query(options.where).find('#w2ui-notify .w2ui-notify-link')
                        .on('click', event => {
                            let value = query(event.target).attr('value')
                            options.actions[value]()
                            query(options.where).find('#w2ui-notify').remove()
                            resolve()
                        })
                }
                if (options.timeout > 0) {
                    this.tmp.notify_timer = setTimeout(() => {
                        query(options.where).find('#w2ui-notify').remove()
                        resolve()
                    }, options.timeout)
                }
            }
        })
    }
    confirm(where, options) {
        if (typeof options == 'string') {
            options = { text: options }
        }
        w2utils.normButtons(options, { yes: 'Yes', no: 'No' })
        let prom = w2utils.message(where, options)
        if (prom) {
            prom.action(event => {
                event.detail.self.close()
            })
        }
        return prom
    }
    /**
     * Normalizes yes, no buttons for confirmation dialog
     *
     * @param {*} options
     * @returns  options
     */
    normButtons(options, btn) {
        options.actions = options.actions ?? {}
        let btns = Object.keys(btn)
        btns.forEach(name => {
            let action = options['btn_' + name]
            if (action) {
                btn[name] = {
                    text: w2utils.lang(action.text ?? ''),
                    class: action.class ?? '',
                    style: action.style ?? '',
                    attrs: action.attrs ?? ''
                }
                delete options['btn_' + name]
            }
            Array('text', 'class', 'style', 'attrs').forEach(suffix => {
                if (options[name + '_' + suffix]) {
                    if (typeof btn[name] == 'string') {
                        btn[name] = { text: btn[name] }
                    }
                    btn[name][suffix] = options[name + '_' + suffix]
                    delete options[name + '_' + suffix]
                }
            })
        })
        if (btns.includes('yes') && btns.includes('no')) {
            if (w2utils.settings.macButtonOrder) {
                w2utils.extend(options.actions, { no: btn.no, yes: btn.yes })
            } else {
                w2utils.extend(options.actions, { yes: btn.yes, no: btn.no })
            }
        }
        if (btns.includes('ok') && btns.includes('cancel')) {
            if (w2utils.settings.macButtonOrder) {
                w2utils.extend(options.actions, { cancel: btn.cancel, ok: btn.ok })
            } else {
                w2utils.extend(options.actions, { ok: btn.ok, cancel: btn.cancel })
            }
        }
        return options
    }
    getSize(el, type) {
        el = query(el) // for backward compatibility
        let ret = 0
        if (el.length > 0) {
            el = el[0]
            let styles = getComputedStyle(el)
            switch (type) {
                case 'width' :
                    ret = parseFloat(styles.width)
                    if (styles.width === 'auto') ret = 0
                    break
                case 'height' :
                    ret = parseFloat(styles.height)
                    if (styles.height === 'auto') ret = 0
                    break
                default:
                    ret = parseFloat(styles[type] ?? 0) || 0
                    break
            }
        }
        return ret
    }
    getStrWidth(str, styles) {
        query('body').append(`
            <div id="_tmp_width" style="position: absolute; top: -9000px; ${styles || ''}">
                ${this.encodeTags(str)}
            </div>`)
        let width = query('#_tmp_width')[0].clientWidth
        query('#_tmp_width').remove()
        return width
    }
    execTemplate(str, replace_obj) {
        if (typeof str !== 'string' || !replace_obj || typeof replace_obj !== 'object') {
            return str
        }
        return str.replace(/\${([^}]+)?}/g, function($1, $2) { return replace_obj[$2]||$2 })
    }
    marker(el, items, options = { onlyFirst: false, wholeWord: false }) {
        if (!Array.isArray(items)) {
            if (items != null && items !== '') {
                items = [items]
            } else {
                items = []
            }
        }
        let ww = options.wholeWord
        query(el).each(el => {
            clearMerkers(el)
            items.forEach(str => {
                if (typeof str !== 'string') str = String(str)
                let replaceValue = (matched) => { // mark new
                    return '<span class="w2ui-marker">' + matched + '</span>'
                }
                // escape regex special chars
                str = str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&').replace(/&/g, '&amp;')
                    .replace(/</g, '&gt;').replace(/>/g, '&lt;')
                let regex  = new RegExp((ww ? '\\b' : '') + str + (ww ? '\\b' : '')+ '(?!([^<]+)?>)',
                    'i' + (!options.onlyFirst ? 'g' : '')) // only outside tags
                el.innerHTML = el.innerHTML.replace(regex, replaceValue)
            })
        })
        function clearMerkers(el) {
            let markerRE = /\<span class=\"w2ui\-marker\"\>((.|\n|\r)*)\<\/span\>/ig
            while (el.innerHTML.indexOf('<span class="w2ui-marker"') !== -1) {
                el.innerHTML = el.innerHTML.replace(markerRE, '$1') // unmark
            }
        }
    }
    lang(phrase, params) {
        if (!phrase || this.settings.phrases == null // if no phrases at all
                || typeof phrase !== 'string' || '<=>='.includes(phrase)) {
            return this.execTemplate(phrase, params)
        }
        let translation = this.settings.phrases[phrase]
        if (translation == null) {
            translation = phrase
            if (this.settings.warnNoPhrase) {
                if (!this.settings.missing) {
                    this.settings.missing = {}
                }
                this.settings.missing[phrase] = '---' // collect phrases for translation, warn once
                this.settings.phrases[phrase] = '---'
                console.log(`Missing translation for "%c${phrase}%c", see %c w2utils.settings.phrases %c with value "---"`,
                    'color: orange', '',
                    'color: #999', '')
            }
        } else if (translation === '---' && !this.settings.warnNoPhrase) {
            translation = phrase
        }
        if (translation === '---') {
            translation = `<span ${this.tooltip(phrase)}>---</span>`
        }
        return this.execTemplate(translation, params)
    }
    locale(locale, keepPhrases, noMerge) {
        return new Promise((resolve, reject) => {
            // if locale is an array we call this function recursively and merge the results
            if (Array.isArray(locale)) {
                this.settings.phrases = {}
                let proms = []
                let files = {}
                locale.forEach((file, ind) => {
                    if (file.length === 5) {
                        file = 'locale/'+ file.toLowerCase() +'.json'
                        locale[ind] = file
                    }
                    proms.push(this.locale(file, true, false))
                })
                Promise.allSettled(proms)
                    .then(res => {
                        // order of files is important to merge
                        res.forEach(r => { if (r.value) files[r.value.file] = r.value.data })
                        locale.forEach(file => {
                            this.settings = this.extend({}, this.settings, files[file])
                        })
                        resolve()
                    })
                return
            }
            if (!locale) locale = 'en-us'
            // if locale is an object, then merge it with w2utils.settings
            if (locale instanceof Object) {
                this.settings = this.extend({}, this.settings, w2locale, locale)
                return
            }
            if (locale.length === 5) {
                locale = 'locale/'+ locale.toLowerCase() +'.json'
            }
            // load from the file
            fetch(locale, { method: 'GET' })
                .then(res => res.json())
                .then(data => {
                    if (noMerge !== true) {
                        if (keepPhrases) {
                            // keep phrases, useful for recursive calls
                            this.settings = this.extend({}, this.settings, data)
                        } else {
                            // clear phrases from language before merging
                            this.settings = this.extend({}, this.settings, w2locale, { phrases: {} }, data)
                        }
                    }
                    resolve({ file: locale, data })
                })
                .catch((err) => {
                    console.log('ERROR: Cannot load locale '+ locale)
                    reject(err)
                })
        })
    }
    scrollBarSize() {
        if (this.tmp.scrollBarSize) return this.tmp.scrollBarSize
        let html = `
            <div id="_scrollbar_width" style="position: absolute; top: -300px; width: 100px; height: 100px; overflow-y: scroll;">
                <div style="height: 120px">1</div>
            </div>
        `
        query('body').append(html)
        this.tmp.scrollBarSize = 100 - query('#_scrollbar_width > div')[0].clientWidth
        query('#_scrollbar_width').remove()
        return this.tmp.scrollBarSize
    }
    checkName(name) {
        if (name == null) {
            console.log('ERROR: Property "name" is required but not supplied.')
            return false
        }
        if (w2ui[name] != null) {
            console.log(`ERROR: Object named "${name}" is already registered as w2ui.${name}.`)
            return false
        }
        if (!this.isAlphaNumeric(name)) {
            console.log('ERROR: Property "name" has to be alpha-numeric (a-z, 0-9, dash and underscore).')
            return false
        }
        return true
    }
    checkUniqueId(id, items, desc, obj) {
        if (!Array.isArray(items)) items = [items]
        let isUnique = true
        items.forEach(item => {
            if (item.id === id) {
                console.log(`ERROR: The item id="${id}" is not unique within the ${desc} "${obj}".`, items)
                isUnique = false
            }
        })
        return isUnique
    }
    /**
     * Takes an object and encodes it into params string to be passed as a url
     * { a: 1, b: 'str'}                => "a=1&b=str"
     * { a: 1, b: { c: 2 }}             => "a=1&b[c]=2"
     * { a: 1, b: {c: { k: 'dfdf' } } } => "a=1&b[c][k]=dfdf"
     */
    encodeParams(obj, prefix = '') {
        let str = ''
        Object.keys(obj).forEach(key => {
            if (str != '') str += '&'
            if (typeof obj[key] == 'object') {
                str += this.encodeParams(obj[key], prefix + key + (prefix ? ']' : '') + '[')
            } else {
                str += `${prefix}${key}${prefix ? ']' : ''}=${obj[key]}`
            }
        })
        return str
    }
    parseRoute(route) {
        let keys = []
        let path = route
            .replace(/\/\(/g, '(?:/')
            .replace(/\+/g, '__plus__')
            .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash, format, key, capture, optional) => {
                keys.push({ name: key, optional: !! optional })
                slash = slash || ''
                return '' + (optional ? '' : slash) + '(?:' + (optional ? slash : '') + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' + (optional || '')
            })
            .replace(/([\/.])/g, '\\$1')
            .replace(/__plus__/g, '(.+)')
            .replace(/\*/g, '(.*)')
        return {
            path  : new RegExp('^' + path + '$', 'i'),
            keys  : keys
        }
    }
    getCursorPosition(input) {
        if (input == null) return null
        let caretOffset = 0
        let doc = input.ownerDocument || input.document
        let win = doc.defaultView || doc.parentWindow
        let sel
        if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
            caretOffset = input.selectionStart
        } else {
            if (win.getSelection) {
                sel = win.getSelection()
                if (sel.rangeCount > 0) {
                    let range         = sel.getRangeAt(0)
                    let preCaretRange = range.cloneRange()
                    preCaretRange.selectNodeContents(input)
                    preCaretRange.setEnd(range.endContainer, range.endOffset)
                    caretOffset = preCaretRange.toString().length
                }
            } else if ( (sel = doc.selection) && sel.type !== 'Control') {
                let textRange         = sel.createRange()
                let preCaretTextRange = doc.body.createTextRange()
                preCaretTextRange.moveToElementText(input)
                preCaretTextRange.setEndPoint('EndToEnd', textRange)
                caretOffset = preCaretTextRange.text.length
            }
        }
        return caretOffset
    }
    setCursorPosition(input, pos, posEnd) {
        if (input == null) return
        let range   = document.createRange()
        let el, sel = window.getSelection()
        if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
            input.setSelectionRange(pos, posEnd ?? pos)
        } else {
            for (let i = 0; i < input.childNodes.length; i++) {
                let tmp = query(input.childNodes[i]).text()
                if (input.childNodes[i].tagName) {
                    tmp = query(input.childNodes[i]).html()
                    tmp = tmp.replace(/&lt;/g, '<')
                        .replace(/&gt;/g, '>')
                        .replace(/&amp;/g, '&')
                        .replace(/&quot;/g, '"')
                        .replace(/&nbsp;/g, ' ')
                }
                if (pos <= tmp.length) {
                    el = input.childNodes[i]
                    if (el.childNodes && el.childNodes.length > 0) el = el.childNodes[0]
                    if (el.childNodes && el.childNodes.length > 0) el = el.childNodes[0]
                    break
                } else {
                    pos -= tmp.length
                }
            }
            if (el == null) return
            if (pos > el.length) pos = el.length
            range.setStart(el, pos)
            if (posEnd) {
                range.setEnd(el, posEnd)
            } else {
                range.collapse(true)
            }
            sel.removeAllRanges()
            sel.addRange(range)
        }
    }
    parseColor(str) {
        if (typeof str !== 'string') return null; else str = str.trim().toUpperCase()
        if (str[0] === '#') str = str.substr(1)
        let color = {}
        if (str.length === 3) {
            color = {
                r: parseInt(str[0] + str[0], 16),
                g: parseInt(str[1] + str[1], 16),
                b: parseInt(str[2] + str[2], 16),
                a: 1
            }
        } else if (str.length === 6) {
            color = {
                r: parseInt(str.substr(0, 2), 16),
                g: parseInt(str.substr(2, 2), 16),
                b: parseInt(str.substr(4, 2), 16),
                a: 1
            }
        } else if (str.length === 8) {
            color = {
                r: parseInt(str.substr(0, 2), 16),
                g: parseInt(str.substr(2, 2), 16),
                b: parseInt(str.substr(4, 2), 16),
                a: Math.round(parseInt(str.substr(6, 2), 16) / 255 * 100) / 100 // alpha channel 0-1
            }
        } else if (str.length > 4 && str.substr(0, 4) === 'RGB(') {
            let tmp = str.replace('RGB', '').replace(/\(/g, '').replace(/\)/g, '').split(',')
            color   = {
                r: parseInt(tmp[0], 10),
                g: parseInt(tmp[1], 10),
                b: parseInt(tmp[2], 10),
                a: 1
            }
        } else if (str.length > 5 && str.substr(0, 5) === 'RGBA(') {
            let tmp = str.replace('RGBA', '').replace(/\(/g, '').replace(/\)/g, '').split(',')
            color   = {
                r: parseInt(tmp[0], 10),
                g: parseInt(tmp[1], 10),
                b: parseInt(tmp[2], 10),
                a: parseFloat(tmp[3])
            }
        } else {
            // word color
            return null
        }
        return color
    }
    // h=0..360, s=0..100, v=0..100
    hsv2rgb(h, s, v, a) {
        let r, g, b, i, f, p, q, t
        if (arguments.length === 1) {
            s = h.s; v = h.v; a = h.a; h = h.h
        }
        h = h / 360
        s = s / 100
        v = v / 100
        i = Math.floor(h * 6)
        f = h * 6 - i
        p = v * (1 - s)
        q = v * (1 - f * s)
        t = v * (1 - (1 - f) * s)
        switch (i % 6) {
            case 0: r = v, g = t, b = p; break
            case 1: r = q, g = v, b = p; break
            case 2: r = p, g = v, b = t; break
            case 3: r = p, g = q, b = v; break
            case 4: r = t, g = p, b = v; break
            case 5: r = v, g = p, b = q; break
        }
        return {
            r: Math.round(r * 255),
            g: Math.round(g * 255),
            b: Math.round(b * 255),
            a: (a != null ? a : 1)
        }
    }
    // r=0..255, g=0..255, b=0..255
    rgb2hsv(r, g, b, a) {
        if (arguments.length === 1) {
            g = r.g; b = r.b; a = r.a; r = r.r
        }
        let max = Math.max(r, g, b), min = Math.min(r, g, b),
            d = max - min,
            h,
            s = (max === 0 ? 0 : d / max),
            v = max / 255
        switch (max) {
            case min: h = 0; break
            case r: h = (g - b) + d * (g < b ? 6: 0); h /= 6 * d; break
            case g: h = (b - r) + d * 2; h /= 6 * d; break
            case b: h = (r - g) + d * 4; h /= 6 * d; break
        }
        return {
            h: Math.round(h * 360),
            s: Math.round(s * 100),
            v: Math.round(v * 100),
            a: (a != null ? a : 1)
        }
    }
    tooltip(html, options) {
        let actions
        let showOn = 'mouseenter'
        let hideOn = 'mouseleave'
        if (typeof html == 'object') {
            options = html
        }
        options = options || {}
        if (typeof html == 'string') {
            options.html = html
        }
        if (options.showOn) {
            showOn = options.showOn
            delete options.showOn
        }
        if (options.hideOn) {
            hideOn = options.hideOn
            delete options.hideOn
        }
        if (!options.name) options.name = 'no-name'
        // base64 is needed to avoid '"<> and other special chars conflicts
        actions = ` on${showOn}="w2tooltip.show(this, `
                + `JSON.parse(w2utils.base64decode('${this.base64encode(JSON.stringify(options))}')))" `
                + `on${hideOn}="w2tooltip.hide('${options.name}')"`
        return actions
    }
    // determins if it is plain Object, not DOM element, nor a function, event, etc.
    isPlainObject(value) {
        if (value == null) { // null or undefined
            return false
        }
        if (Object.prototype.toString.call(value) !== '[object Object]') {
            return false
        }
        if (value.constructor === undefined) {
            return true
        }
        let proto = Object.getPrototypeOf(value)
        return proto === null || proto === Object.prototype
    }
    /**
     * Deep copy of an object or an array. Function, events and HTML elements will not be cloned,
     * you can choose to include them or not, by default they are included.
     * You can also exclude certain elements from final object if used with options: { exclude }
     */
    clone(obj, options) {
        let ret
        options = Object.assign({ functions: true, elements: true, events: true, exclude: [] }, options ?? {})
        if (Array.isArray(obj)) {
            ret = Array.from(obj)
            ret.forEach((value, ind) => {
                ret[ind] = this.clone(value, options)
            })
        } else if (this.isPlainObject(obj)) {
            ret = {}
            Object.assign(ret, obj)
            if (options.exclude) {
                options.exclude.forEach(key => { delete ret[key] }) // delete excluded keys
            }
            Object.keys(ret).forEach(key => {
                ret[key] = this.clone(ret[key], options)
                if (ret[key] === undefined) delete ret[key] // do not include undefined elements
            })
        } else {
            if ((obj instanceof Function && !options.functions)
                    || (obj instanceof Node && !options.elements)
                    || (obj instanceof Event && !options.events)
            ) {
                // do not include these objects, otherwise include them uncloned
            } else {
                // primitive variable or function, event, dom element, etc, -  all these are not cloned
                ret = obj
            }
        }
        return ret
    }
    /**
     * Deep extend an object, if an array, it overwrrites it, cloning objects in the process
     * target, source1, source2, ...
     */
    extend(target, source) {
        if (Array.isArray(target)) {
            if (Array.isArray(source)) {
                target.splice(0, target.length) // empty array but keep the reference
                source.forEach(s => { target.push(this.clone(s)) })
            } else {
                throw new Error('Arrays can be extended with arrays only')
            }
        } else if (target instanceof Node || target instanceof Event) {
            throw new Error('HTML elmenents and events cannot be extended')
        } else if (target && typeof target == 'object' && source != null) {
            if (typeof source != 'object') {
                throw new Error('Object can be extended with other objects only.')
            }
            Object.keys(source).forEach(key => {
                if (target[key] != null && typeof target[key] == 'object'
                        && source[key] != null && typeof source[key] == 'object') {
                    let src = this.clone(source[key])
                    // do not extend HTML elements and events, but overwrite them
                    if (target[key] instanceof Node || target[key] instanceof Event) {
                        target[key] = src
                    } else {
                        // if an array needs to be extended with an object, then convert it to empty object
                        if (Array.isArray(target[key]) && this.isPlainObject(src)) {
                            target[key] = {}
                        }
                        this.extend(target[key], src)
                    }
                } else {
                    target[key] = this.clone(source[key])
                }
            })
        } else if (source != null) {
            throw new Error('Object is not extendable, only {} or [] can be extended.')
        }
        // other arguments
        if (arguments.length > 2) {
            for (let i = 2; i < arguments.length; i++) {
                this.extend(target, arguments[i])
            }
        }
        return target
    }
    /*
     * @author     Lauri Rooden (https://github.com/litejs/natural-compare-lite)
     * @license    MIT License
     */
    naturalCompare(a, b) {
        let i, codeA
            , codeB = 1
            , posA = 0
            , posB = 0
            , alphabet = String.alphabet
        function getCode(str, pos, code) {
            if (code) {
                for (i = pos; code = getCode(str, i), code < 76 && code > 65;) ++i
                return +str.slice(pos - 1, i)
            }
            code = alphabet && alphabet.indexOf(str.charAt(pos))
            return code > -1 ? code + 76 : ((code = str.charCodeAt(pos) || 0), code < 45 || code > 127) ? code
                : code < 46 ? 65 // -
                : code < 48 ? code - 1
                : code < 58 ? code + 18 // 0-9
                : code < 65 ? code - 11
                : code < 91 ? code + 11 // A-Z
                : code < 97 ? code - 37
                : code < 123 ? code + 5 // a-z
                : code - 63
        }

        if ((a+='') != (b+='')) for (;codeB;) {
            codeA = getCode(a, posA++)
            codeB = getCode(b, posB++)
            if (codeA < 76 && codeB < 76 && codeA > 66 && codeB > 66) {
                codeA = getCode(a, posA, posA)
                codeB = getCode(b, posB, posA = i)
                posB  = i
            }
            if (codeA != codeB) return (codeA < codeB) ? -1 : 1
        }
        return 0
    }
    normMenu(menu, el) {
        if (Array.isArray(menu)) {
            menu.forEach((it, m) => {
                if (typeof it === 'string' || typeof it === 'number') {
                    menu[m] = { id: it, text: String(it) }
                } else if (it != null) {
                    if (it.caption != null && it.text == null) it.text = it.caption
                    if (it.text != null && it.id == null) it.id = it.text
                    if (it.text == null && it.id != null) it.text = it.id
                } else {
                    menu[m] = { id: null, text: 'null' }
                }
            })
            return menu
        } else if (typeof menu === 'function') {
            let newMenu = menu.call(this, menu, el)
            return w2utils.normMenu.call(this, newMenu)
        } else if (typeof menu === 'object') {
            return Object.keys(menu).map(key => { return { id: key, text: menu[key] } })
        }
    }
    /**
     * Takes Url object and fetchOptions and changes it in place applying selected user dataType. Since
     * dataType is in w2utils. This method is used in grid, form and tooltip to prepare fetch parameters
     */
    prepareParams(url, fetchOptions, defDataType) {
        let dataType = defDataType ?? w2utils.settings.dataType
        let postParams = fetchOptions.body
        switch (dataType) {
            case 'HTTPJSON':
                postParams = { request: postParams }
                if (['PUT', 'DELETE'].includes(fetchOptions.method)) {
                    fetchOptions.method = 'POST'
                }
                body2params()
                break
            case 'HTTP':
                if (['PUT', 'DELETE'].includes(fetchOptions.method)) {
                    fetchOptions.method = 'POST'
                }
                body2params()
                break
            case 'RESTFULL':
                if (['PUT', 'DELETE'].includes(fetchOptions.method)) {
                    fetchOptions.headers['Content-Type'] = 'application/json'
                } else {
                    body2params()
                }
                break
            case 'JSON':
                if (fetchOptions.method == 'GET') {
                    postParams = { request: postParams }
                    body2params()
                } else {
                    fetchOptions.headers['Content-Type'] = 'application/json'
                    fetchOptions.method = 'POST'
                }
                break
        }
        fetchOptions.body = typeof fetchOptions.body == 'string' ? fetchOptions.body : JSON.stringify(fetchOptions.body)
        return fetchOptions
        function body2params() {
            Object.keys(postParams).forEach(key => {
                let param = postParams[key]
                if (typeof param == 'object') param = JSON.stringify(param)
                url.searchParams.append(key, param)
            })
            delete fetchOptions.body
        }
    }
    bindEvents(selector, subject) {
        // format is
        // <div ... data-<event>='["<method>","param1","param2",...]'> -- should be valid JSON (no undefined)
        // <div ... data-<event>="<method>|param1|param2">
        // -- can have "event", "this", "stop", "stopPrevent", "alert" - as predefined objects
        if (selector.length == 0) return
        // for backward compatibility
        if (selector?.[0] instanceof Node) {
            selector = Array.isArray(selector) ? selector : selector.get()
        }
        query(selector).each((el) => {
            let actions = query(el).data()
            Object.keys(actions).forEach(name => {
                let events = ['click', 'dblclick', 'mouseenter', 'mouseleave', 'mouseover', 'mouseout', 'mousedown', 'mousemove', 'mouseup',
                    'contextmenu', 'focus', 'focusin', 'focusout', 'blur', 'input', 'change', 'keydown', 'keyup', 'keypress']
                if (events.indexOf(String(name).toLowerCase()) == -1) {
                    return
                }
                let params = actions[name]
                if (typeof params == 'string') {
                    params = params.split('|').map(key => {
                        if (key === 'true') key = true
                        if (key === 'false') key = false
                        if (key === 'undefined') key = undefined
                        if (key === 'null') key = null
                        if (parseFloat(key) == key) key = parseFloat(key)
                        let quotes = ['\'', '"', '`']
                        if (typeof key == 'string' && quotes.includes(key[0]) && quotes.includes(key[key.length-1])) {
                            key = key.substring(1, key.length-1)
                        }
                        return key
                    })
                }
                let method = params[0]
                params = params.slice(1) // should be new array
                query(el)
                    .off(name + '.w2utils-bind')
                    .on(name + '.w2utils-bind', function(event) {
                        switch (method) {
                            case 'alert':
                                alert(params[0]) // for testing purposes
                                break
                            case 'stop':
                                event.stopPropagation()
                                break
                            case 'prevent':
                                event.preventDefault()
                                break
                            case 'stopPrevent':
                                event.stopPropagation()
                                event.preventDefault()
                                return false
                                break
                            default:
                                if (subject[method] == null) {
                                    throw new Error(`Cannot dispatch event as the method "${method}" does not exist.`)
                                }
                                subject[method].apply(subject, params.map((key, ind) => {
                                    switch (String(key).toLowerCase()) {
                                        case 'event':
                                            return event
                                        case 'this':
                                            return this
                                        default:
                                            return key
                                    }
                                }))
                        }
                    })
            })
        })
    }
    debounce(func, wait = 250) {
        let timeout
        return (...args) => {
            clearTimeout(timeout)
            timeout = setTimeout(() => { func(...args) }, wait)
        }
    }
}
var w2utils = new Utils() // eslint-disable-line -- needs to be functional/module scope variable
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base
 *
 * == 2.0 changes
 *  - CSP - fixed inline events
 *  - removed jQuery dependency
 *  - popup.open - returns promise like object
 *  - popup.confirm - refactored
 *  - popup.message - refactored
 *  - removed popup.options.mutliple
 *  - refactores w2alert, w2confirm, w2prompt
 *  - add w2popup.open().on('')
 *  - removed w2popup.restoreTemplate
 *  - deprecated onMsgOpen and onMsgClose
 *  - deprecated options.bgColor
 *  - rename focus -> setFocus
 *  - added center() // will auto center on window resize
 *  - close(immediate), also refactored if popup is closed when opening
 */

class Dialog extends w2base {
    constructor() {
        super()
        this.defaults   = {
            title: '',
            text: '',           // just a text (will be centered)
            body: '',
            buttons: '',
            width: 450,
            height: 250,
            focus: null,        // brings focus to the element, can be a number or selector
            actions: null,      // actions object
            style: '',          // style of the message div
            speed: 0.3,
            modal: false,
            maximized: false,   // this is a flag to show the state - to open the popup maximized use openMaximized instead
            keyboard: true,     // will close popup on esc if not modal
            showClose: true,
            showMax: false,
            transition: null,
            openMaximized: false,
            moved: false
        }
        this.name       = 'popup'
        this.status     = 'closed' // string that describes current status
        this.onOpen     = null
        this.onClose    = null
        this.onMax      = null
        this.onMin      = null
        this.onToggle   = null
        this.onKeydown  = null
        this.onAction   = null
        this.onMove     = null
        this.tmp        = {}
        // event handler for resize
        this.handleResize = (event) => {
            // if it was moved by the user, do not auto resize
            if (!this.options.moved) {
                this.center(undefined, undefined, true)
            }
        }
    }
    /**
     * Sample calls
     * - w2popup.open('ddd').ok(() => { w2popup.close() })
     * - w2popup.open('ddd', { height: 120 }).ok(() => { w2popup.close() })
     * - w2popup.open({ body: 'text', title: 'caption', actions: ["Close"] }).close(() => { w2popup.close() })
     * - w2popup.open({ body: 'text', title: 'caption', actions: { Close() { w2popup.close() }} })
     */
    open(options) {
        let self = this
        if (this.status == 'closing' || query('#w2ui-popup').hasClass('animating')) {
            // if called when previous is closing
            this.close(true)
        }
        // get old options and merge them
        let old_options = this.options
        if (['string', 'number'].includes(typeof options)) {
            options = w2utils.extend({
                title: 'Notification',
                body: `<div class="w2ui-centered">${options}</div>`,
                actions: { Ok() { self.close() }},
                cancelAction: 'ok'
            }, arguments[1] ?? {})
        }
        if (options.text != null) options.body = `<div class="w2ui-centered w2ui-msg-text">${options.text}</div>`
        options = Object.assign({}, this.defaults, old_options, { title: '', body : '' }, options, { maximized: false })
        this.options = options
        // if new - reset event handlers
        if (query('#w2ui-popup').length === 0) {
            this.off('*')
            Object.keys(this).forEach(key => {
                if (key.startsWith('on') && key != 'on') this[key] = null
            })
        }
        // reassign events
        Object.keys(options).forEach(key => {
            if (key.startsWith('on') && key != 'on' && options[key]) {
                this[key] = options[key]
            }
        })
        options.width  = parseInt(options.width)
        options.height = parseInt(options.height)
        let edata, msg, tmp
        let { top, left } = this.center()
        let prom = {
            self: this,
            action(callBack) {
                self.on('action.prom', callBack)
                return prom
            },
            close(callBack) {
                self.on('close.prom', callBack)
                return prom
            },
            then(callBack) {
                self.on('open:after.prom', callBack)
                return prom
            }
        }
        // convert action arrays into buttons
        if (options.actions != null && !options.buttons) {
            options.buttons = ''
            Object.keys(options.actions).forEach((action) => {
                let handler = options.actions[action]
                let btnAction = action
                if (typeof handler == 'function') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction" data-click='["action","${action}","event"]'>${action}</button>`
                }
                if (typeof handler == 'object') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction ${handler.class || ''}" name="${action}" data-click='["action","${action}","event"]'
                        style="${handler.style}" ${handler.attrs}>${handler.text || action}</button>`
                    btnAction = Array.isArray(options.actions) ? handler.text : action
                }
                if (typeof handler == 'string') {
                    options.buttons += `<button class="w2ui-btn w2ui-eaction" data-click='["action","${handler}","event"]'>${handler}</button>`
                    btnAction = handler
                }
                if (typeof btnAction == 'string') {
                    btnAction = btnAction[0].toLowerCase() + btnAction.substr(1).replace(/\s+/g, '')
                }
                prom[btnAction] = function (callBack) {
                    self.on('action.buttons', (event) => {
                        let target = event.detail.action[0].toLowerCase() + event.detail.action.substr(1).replace(/\s+/g, '')
                        if (target == btnAction) callBack(event)
                    })
                    return prom
                }
            })
        }
        // check if message is already displayed
        if (query('#w2ui-popup').length === 0) {
            // trigger event
            edata = this.trigger('open', { target: 'popup', present: false })
            if (edata.isCancelled === true) return
            this.status = 'opening'
            // output message
            w2utils.lock(document.body, {
                opacity: 0.3,
                onClick: options.modal ? null : () => { this.close() }
            })
            let btn = ''
            if (options.showClose) {
                btn += `<div class="w2ui-popup-button w2ui-popup-close">
                            <span class="w2ui-icon w2ui-icon-cross w2ui-eaction" data-mousedown="stop" data-click="close"></span>
                        </div>`
            }
            if (options.showMax) {
                btn += `<div class="w2ui-popup-button w2ui-popup-max">
                            <span class="w2ui-icon w2ui-icon-box w2ui-eaction" data-mousedown="stop" data-click="toggle"></span>
                        </div>`
            }
            // first insert just body
            let styles = `
                left: ${left}px;
                top: ${top}px;
                width: ${parseInt(options.width)}px;
                height: ${parseInt(options.height)}px;
                transition: ${options.speed}s
            `
            msg = `<div id="w2ui-popup" class="w2ui-popup w2ui-anim-open animating" style="${w2utils.stripSpaces(styles)}"></div>`
            query('body').append(msg)
            query('#w2ui-popup')[0]._w2popup = {
                self: this,
                created: new Promise((resolve) => { this._promCreated = resolve }),
                opened: new Promise((resolve) => { this._promOpened = resolve }),
                closing: new Promise((resolve) => { this._promClosing = resolve }),
                closed: new Promise((resolve) => { this._promClosed = resolve }),
            }
            // then content
            styles = `${!options.title ? 'top: 0px !important;' : ''} ${!options.buttons ? 'bottom: 0px !important;' : ''}`
            msg = `
                <span name="hidden-first" tabindex="0" style="position: absolute; top: -100px"></span>
                <div class="w2ui-popup-title-btns">${btn}</div>
                <div class="w2ui-popup-title" style="${!options.title ? 'display: none' : ''}"></div>
                <div class="w2ui-box" style="${styles}">
                    <div class="w2ui-popup-body ${!options.title || ' w2ui-popup-no-title'}
                        ${!options.buttons || ' w2ui-popup-no-buttons'}" style="${options.style}">
                    </div>
                </div>
                <div class="w2ui-popup-buttons" style="${!options.buttons ? 'display: none' : ''}"></div>
                <span name="hidden-last" tabindex="0" style="position: absolute; top: -100px"></span>
            `
            query('#w2ui-popup').html(msg)
            if (options.title) query('#w2ui-popup .w2ui-popup-title').append(w2utils.lang(options.title))
            if (options.buttons) query('#w2ui-popup .w2ui-popup-buttons').append(options.buttons)
            if (options.body) query('#w2ui-popup .w2ui-popup-body').append(options.body)
            // allow element to render
            setTimeout(() => {
                query('#w2ui-popup')
                    .css('transition', options.speed + 's')
                    .removeClass('w2ui-anim-open')
                w2utils.bindEvents('#w2ui-popup .w2ui-eaction', this)
                query('#w2ui-popup').find('.w2ui-popup-body').show()
                this._promCreated()
            }, 1)
            // clean transform
            clearTimeout(this._timer)
            this._timer = setTimeout(() => {
                this.status = 'open'
                self.setFocus(options.focus)
                // event after
                edata.finish()
                this._promOpened()
                query('#w2ui-popup').removeClass('animating')
            }, options.speed * 1000)
        } else {
            // trigger event
            edata = this.trigger('open', { target: 'popup', present: true })
            if (edata.isCancelled === true) return
            // check if size changed
            this.status = 'opening'
            if (old_options != null) {
                if (!old_options.maximized && (old_options.width != options.width || old_options.height != options.height)) {
                    this.resize(options.width, options.height)
                }
                options.prevSize  = options.width + 'px:' + options.height + 'px'
                options.maximized = old_options.maximized
            }
            // show new items
            let cloned = query('#w2ui-popup .w2ui-box').get(0).cloneNode(true)
            query(cloned).removeClass('w2ui-box').addClass('w2ui-box-temp').find('.w2ui-popup-body').empty().append(options.body)
            query('#w2ui-popup .w2ui-box').after(cloned)
            if (options.buttons) {
                query('#w2ui-popup .w2ui-popup-buttons').show().html('').append(options.buttons)
                query('#w2ui-popup .w2ui-popup-body').removeClass('w2ui-popup-no-buttons')
                query('#w2ui-popup .w2ui-box, #w2ui-popup .w2ui-box-temp').css('bottom', '')
            } else {
                query('#w2ui-popup .w2ui-popup-buttons').hide().html('')
                query('#w2ui-popup .w2ui-popup-body').addClass('w2ui-popup-no-buttons')
                query('#w2ui-popup .w2ui-box, #w2ui-popup .w2ui-box-temp').css('bottom', '0px')
            }
            if (options.title) {
                query('#w2ui-popup .w2ui-popup-title')
                    .show()
                    .html((options.showClose
                        ? `<div class="w2ui-popup-button w2ui-popup-close">
                                <span class="w2ui-icon w2ui-icon-cross w2ui-eaction" data-mousedown="stop" data-click="close"></span>
                            </div>`
                        : '') +
                        (options.showMax
                        ? `<div class="w2ui-popup-button w2ui-popup-max">
                                <span class="w2ui-icon w2ui-icon-box w2ui-eaction" data-mousedown="stop" data-click="toggle"></span>
                            </div>`
                        : ''))
                    .append(options.title)
                query('#w2ui-popup .w2ui-popup-body').removeClass('w2ui-popup-no-title')
                query('#w2ui-popup .w2ui-box, #w2ui-popup .w2ui-box-temp').css('top', '')
            } else {
                query('#w2ui-popup .w2ui-popup-title').hide().html('')
                query('#w2ui-popup .w2ui-popup-body').addClass('w2ui-popup-no-title')
                query('#w2ui-popup .w2ui-box, #w2ui-popup .w2ui-box-temp').css('top', '0px')
            }
            // transition
            let div_old = query('#w2ui-popup .w2ui-box')[0]
            let div_new = query('#w2ui-popup .w2ui-box-temp')[0]
            query('#w2ui-popup').addClass('animating')
            w2utils.transition(div_old, div_new, options.transition, () => {
                // clean up
                query(div_old).remove()
                query(div_new).removeClass('w2ui-box-temp').addClass('w2ui-box')
                let $body = query(div_new).find('.w2ui-popup-body')
                if ($body.length == 1) {
                    $body[0].style.cssText = options.style
                    $body.show()
                }
                // focus on first button
                self.setFocus(options.focus)
                query('#w2ui-popup').removeClass('animating')
            })
            // call event onOpen
            this.status = 'open'
            edata.finish()
            w2utils.bindEvents('#w2ui-popup .w2ui-eaction', this)
            query('#w2ui-popup').find('.w2ui-popup-body').show()
        }
        if (options.openMaximized) {
            this.max()
        }
        // save new options
        options._last_focus = document.activeElement
        // keyboard events
        if (options.keyboard) {
            query(document.body).on('keydown', (event) => {
                this.keydown(event)
            })
        }
        query(window).on('resize', this.handleResize)
        // initialize move
        tmp = {
            resizing : false,
            mvMove   : mvMove,
            mvStop   : mvStop
        }
        query('#w2ui-popup .w2ui-popup-title').on('mousedown', function(event) {
            if (!self.options.maximized) mvStart(event)
        })
        return prom
        // handlers
        function mvStart(evt) {
            if (!evt) evt = window.event
            self.status = 'moving'
            let rect = query('#w2ui-popup').get(0).getBoundingClientRect()
            Object.assign(tmp, {
                resizing: true,
                isLocked: query('#w2ui-popup > .w2ui-lock').length == 1 ? true : false,
                x       : evt.screenX,
                y       : evt.screenY,
                pos_x   : rect.x,
                pos_y   : rect.y,
            })
            if (!tmp.isLocked) self.lock({ opacity: 0 })
            query(document.body)
                .on('mousemove.w2ui-popup', tmp.mvMove)
                .on('mouseup.w2ui-popup', tmp.mvStop)
            if (evt.stopPropagation) evt.stopPropagation(); else evt.cancelBubble = true
            if (evt.preventDefault) evt.preventDefault(); else return false
        }
        function mvMove(evt) {
            if (tmp.resizing != true) return
            if (!evt) evt = window.event
            tmp.div_x = evt.screenX - tmp.x
            tmp.div_y = evt.screenY - tmp.y
            // trigger event
            let edata = self.trigger('move', { target: 'popup', div_x: tmp.div_x, div_y: tmp.div_y, originalEvent: evt })
            if (edata.isCancelled === true) return
            // default behavior
            query('#w2ui-popup').css({
                'transition': 'none',
                'transform' : 'translate3d('+ tmp.div_x +'px, '+ tmp.div_y +'px, 0px)'
            })
            self.options.moved = true
            // event after
            edata.finish()
        }
        function mvStop(evt) {
            if (tmp.resizing != true) return
            if (!evt) evt = window.event
            self.status = 'open'
            tmp.div_x      = (evt.screenX - tmp.x)
            tmp.div_y      = (evt.screenY - tmp.y)
            query('#w2ui-popup')
                .css({
                    'left': (tmp.pos_x + tmp.div_x) + 'px',
                    'top' : (tmp.pos_y + tmp.div_y) + 'px'
                })
                .css({
                    'transition': 'none',
                    'transform' : 'translate3d(0px, 0px, 0px)'
                })
            tmp.resizing = false
            query(document.body).off('.w2ui-popup')
            if (!tmp.isLocked) self.unlock()
        }
    }
    load(options) {
        return new Promise((resolve, reject) => {
            if (typeof options == 'string') {
                options = { url: options }
            }
            if (options.url == null) {
                console.log('ERROR: The url is not defined.')
                reject('The url is not defined')
                return
            }
            this.status = 'loading'
            let [url, selector] = String(options.url).split('#')
            if (url) {
                fetch(url).then(res => res.text()).then(html => {
                    resolve(this.template(html, selector, options))
                })
            }
        })
    }
    template(data, id, options = {}) {
        let html
        try {
            html = query(data)
        } catch (e) {
            html = query.html(data)
        }
        if (id) html = html.filter('#' + id)
        Object.assign(options, {
            width: parseInt(query(html).css('width')),
            height: parseInt(query(html).css('height')),
            title: query(html).find('[rel=title]').html(),
            body: query(html).find('[rel=body]').html(),
            buttons: query(html).find('[rel=buttons]').html(),
            style: query(html).find('[rel=body]').get(0).style.cssText,
        })
        return this.open(options)
    }
    action(action, event) {
        let click = this.options.actions[action]
        if (click instanceof Object && click.onClick) click = click.onClick
        // event before
        let edata = this.trigger('action', { action, target: 'popup', self: this,
            originalEvent: event, value: this.input ? this.input.value : null })
        if (edata.isCancelled === true) return
        // default actions
        if (typeof click === 'function') click.call(this, event)
        // event after
        edata.finish()
    }
    keydown(event) {
        if (this.options && !this.options.keyboard) return
        // trigger event
        let edata = this.trigger('keydown', { target: 'popup', originalEvent: event })
        if (edata.isCancelled === true) return
        // default behavior
        switch (event.keyCode) {
            case 27:
                event.preventDefault()
                if (query('#w2ui-popup .w2ui-message').length == 0) {
                    if (this.options.cancelAction) {
                        this.action(this.options.cancelAction)
                    } else {
                        this.close()
                    }
                }
                break
        }
        // event after
        edata.finish()
    }
    close(immediate) {
        // trigger event
        let edata = this.trigger('close', { target: 'popup' })
        if (edata.isCancelled === true) return
        let cleanUp = () => {
            // return template
            query('#w2ui-popup').remove()
            // restore active
            if (this.options._last_focus && this.options._last_focus.length > 0) this.options._last_focus.focus()
            this.status = 'closed'
            this.options = {}
            // event after
            edata.finish()
            this._promClosed()
        }
        if (query('#w2ui-popup').length === 0 || this.status == 'closed') { // already closed
            return
        }
        if (this.status == 'opening') { // if it is opening
            immediate = true
        }
        if (this.status == 'closing' && immediate === true) {
            cleanUp()
            clearTimeout(this.tmp.closingTimer)
            w2utils.unlock(document.body, 0)
            return
        }
        // default behavior
        this.status = 'closing'
        query('#w2ui-popup')
            .css('transition', this.options.speed + 's')
            .addClass('w2ui-anim-close animating')
        w2utils.unlock(document.body, 300)
        this._promClosing()
        if (immediate) {
            cleanUp()
        } else {
            this.tmp.closingTimer = setTimeout(cleanUp, this.options.speed * 1000)
        }
        // remove keyboard events
        if (this.options.keyboard) {
            query(document.body).off('keydown', this.keydown)
        }
        query(window).off('resize', this.handleResize)
    }
    toggle() {
        let edata = this.trigger('toggle', { target: 'popup' })
        if (edata.isCancelled === true) return
        // default action
        if (this.options.maximized === true) this.min(); else this.max()
        // event after
        setTimeout(() => {
            edata.finish()
        }, (this.options.speed * 1000) + 50)
    }
    max() {
        if (this.options.maximized === true) return
        // trigger event
        let edata = this.trigger('max', { target: 'popup' })
        if (edata.isCancelled === true) return
        // default behavior
        this.status = 'resizing'
        let rect = query('#w2ui-popup').get(0).getBoundingClientRect()
        this.options.prevSize = rect.width + ':' + rect.height
        // do resize
        this.resize(10000, 10000, () => {
            this.status    = 'open'
            this.options.maximized = true
            edata.finish()
        })
    }
    min() {
        if (this.options.maximized !== true) return
        let size = this.options.prevSize.split(':')
        // trigger event
        let edata = this.trigger('min', { target: 'popup' })
        if (edata.isCancelled === true) return
        // default behavior
        this.status = 'resizing'
        // do resize
        this.options.maximized = false
        this.resize(parseInt(size[0]), parseInt(size[1]), () => {
            this.status = 'open'
            this.options.prevSize  = null
            edata.finish()
        })
    }
    clear() {
        query('#w2ui-popup .w2ui-popup-title').html('')
        query('#w2ui-popup .w2ui-popup-body').html('')
        query('#w2ui-popup .w2ui-popup-buttons').html('')
    }
    reset() {
        this.open(this.defaults)
    }
    message(options) {
        return w2utils.message({
            owner: this,
            box  : query('#w2ui-popup').get(0),
            after: '.w2ui-popup-title'
        }, options)
    }
    confirm(options) {
        return w2utils.confirm({
            owner: this,
            box  : query('#w2ui-popup'),
            after: '.w2ui-popup-title'
        }, options)
    }
    setFocus(focus) {
        let box = query('#w2ui-popup')
        let sel = 'input, button, select, textarea, [contentEditable], .w2ui-input'
        if (focus != null) {
            let el = isNaN(focus)
                ? box.find(sel).filter(focus).get(0)
                : box.find(sel).get(focus)
            el?.focus()
        } else {
            let el = box.find('[name=hidden-first]').get(0)
            if (el) el.focus()
        }
        // keep focus/blur inside popup
        query(box).find(sel + ',[name=hidden-first],[name=hidden-last]')
            .off('.keep-focus')
            .on('blur.keep-focus', function (event) {
                setTimeout(() => {
                    let focus = document.activeElement
                    let inside = query(box).find(sel).filter(focus).length > 0
                    let name = query(focus).attr('name')
                    if (!inside && focus && focus !== document.body) {
                        query(box).find(sel).get(0)?.focus()
                    }
                    if (name == 'hidden-last') {
                        query(box).find(sel).get(0)?.focus()
                    }
                    if (name == 'hidden-first') {
                        query(box).find(sel).get(-1)?.focus()
                    }
                }, 1)
            })
    }
    lock(msg, showSpinner) {
        let args = Array.from(arguments)
        args.unshift(query('#w2ui-popup'))
        w2utils.lock(...args)
    }
    unlock(speed) {
        w2utils.unlock(query('#w2ui-popup'), speed)
    }
    center(width, height, force) {
        let maxW, maxH
        if (window.innerHeight == undefined) {
            maxW = parseInt(document.documentElement.offsetWidth)
            maxH = parseInt(document.documentElement.offsetHeight)
        } else {
            maxW = parseInt(window.innerWidth)
            maxH = parseInt(window.innerHeight)
        }
        width = parseInt(width ?? this.options.width)
        height = parseInt(height ?? this.options.height)
        if (this.options.maximized === true) {
            width = maxW
            height = maxH
        }
        if (maxW - 10 < width) width = maxW - 10
        if (maxH - 10 < height) height = maxH - 10
        let top  = (maxH - height) / 2
        let left = (maxW - width) / 2
        if (force) {
            query('#w2ui-popup').css({
                'transition': 'none',
                'top'   : top + 'px',
                'left'  : left + 'px',
                'width' : width + 'px',
                'height': height + 'px'
            })
            this.resizeMessages() // then messages resize nicely
        }
        return { top, left, width, height }
    }
    resize(newWidth, newHeight, callBack) {
        let self = this
        if (this.options.speed == null) this.options.speed = 0
        // calculate new position
        let { top, left, width, height } = this.center(newWidth, newHeight)
        let speed = this.options.speed
        query('#w2ui-popup').css({
            'transition': `${speed}s width, ${speed}s height, ${speed}s left, ${speed}s top`,
            'top'   : top + 'px',
            'left'  : left + 'px',
            'width' : width + 'px',
            'height': height + 'px'
        })
        let tmp_int = setInterval(() => { self.resizeMessages() }, 10) // then messages resize nicely
        setTimeout(() => {
            clearInterval(tmp_int)
            self.resizeMessages()
            if (typeof callBack == 'function') callBack()
        }, (this.options.speed * 1000) + 50) // give extra 50 ms
    }
    // internal function
    resizeMessages() {
        // see if there are messages and resize them
        query('#w2ui-popup .w2ui-message').each(msg => {
            let mopt = msg._msg_options
            let popup = query('#w2ui-popup')
            if (parseInt(mopt.width) < 10) mopt.width = 10
            if (parseInt(mopt.height) < 10) mopt.height = 10
            let rect = popup[0].getBoundingClientRect()
            let titleHeight = parseInt(popup.find('.w2ui-popup-title')[0].clientHeight)
            let pWidth      = parseInt(rect.width)
            let pHeight     = parseInt(rect.height)
            // re-calc width
            mopt.width = mopt.originalWidth
            if (mopt.width > pWidth - 10) {
                mopt.width = pWidth - 10
            }
            // re-calc height
            mopt.height = mopt.originalHeight
            if (mopt.height > pHeight - titleHeight - 5) {
                mopt.height = pHeight - titleHeight - 5
            }
            if (mopt.originalHeight < 0) mopt.height = pHeight + mopt.originalHeight - titleHeight
            if (mopt.originalWidth < 0) mopt.width = pWidth + mopt.originalWidth * 2 // x 2 because there is left and right margin
            query(msg).css({
                left    : ((pWidth - mopt.width) / 2) + 'px',
                width   : mopt.width + 'px',
                height  : mopt.height + 'px'
            })
        })
    }
}
function w2alert(msg, title, callBack) {
    let prom
    let options = {
        title: w2utils.lang(title ?? 'Notification'),
        body: `<div class="w2ui-centered w2ui-msg-text">${msg}</div>`,
        showClose: false,
        actions: ['Ok'],
        cancelAction: 'ok'
    }
    if (query('#w2ui-popup').length > 0 && w2popup.status != 'closing') {
        prom = w2popup.message(options)
    } else {
        prom = w2popup.open(options)
    }
    prom.ok((event) => {
        if (typeof event.detail.self?.close == 'function') {
            event.detail.self.close()
        }
        if (typeof callBack == 'function') callBack()
    })
    return prom
}
function w2confirm(msg, title, callBack) {
    let prom
    let options = msg
    if (['string', 'number'].includes(typeof options)) {
        options = { msg: options }
    }
    if (options.msg) {
        options.body = `<div class="w2ui-centered w2ui-msg-text">${options.msg}</div>`,
        delete options.msg
    }
    w2utils.extend(options, {
        title: w2utils.lang(title ?? 'Confirmation'),
        showClose: false,
        modal: true,
        cancelAction: 'no'
    })
    w2utils.normButtons(options, { yes: 'Yes', no: 'No' })
    if (query('#w2ui-popup').length > 0 && w2popup.status != 'closing') {
        prom = w2popup.message(options)
    } else {
        prom = w2popup.open(options)
    }
    prom.self
        .off('.confirm')
        .on('action:after.confirm', (event) => {
            if (typeof event.detail.self?.close == 'function') {
                event.detail.self.close()
            }
            if (typeof callBack == 'function') callBack(event.detail.action)
        })
    return prom
}
function w2prompt(label, title, callBack) {
    let prom
    let options = label
    if (['string', 'number'].includes(typeof options)) {
        options = { label: options }
    }
    if (options.label) {
        options.focus = 0
        options.body = (options.textarea
            ? `<div class="w2ui-prompt textarea">
                 <div>${options.label}</div>
                 <textarea id="w2prompt" class="w2ui-input" ${options.attrs ?? ''}
                    data-keydown="keydown|event" data-keyup="change|event">${options.value??''}</textarea>
               </div>`
            : `<div class="w2ui-prompt w2ui-centered">
                 <label>${options.label}</label>
                 <input id="w2prompt" class="w2ui-input" ${options.attrs ?? ''}
                    data-keydown="keydown|event" data-keyup="change|event" value="${options.value??''}">
               </div>`
        )
    }
    w2utils.extend(options, {
        title: w2utils.lang(title ?? 'Notification'),
        showClose: false,
        modal: true,
        cancelAction: 'cancel'
    })
    w2utils.normButtons(options, { ok: 'Ok', cancel: 'Cancel' })
    if (query('#w2ui-popup').length > 0 && w2popup.status != 'closing') {
        prom = w2popup.message(options)
    } else {
        prom = w2popup.open(options)
    }
    if (prom.self.box) {
        prom.self.input = query(prom.self.box).find('#w2prompt').get(0)
    } else {
        prom.self.input = query('#w2ui-popup .w2ui-popup-body #w2prompt').get(0)
    }
    if (options.value !== null) {
        prom.self.input.select()
    }
    prom.change = function (callback) {
        prom.self.on('change', callback)
        return this
    }
    prom.self
        .off('.prompt')
        .on('open:after.prompt', (event) => {
            let box = event.detail.box ? event.detail.box : query('#w2ui-popup .w2ui-popup-body').get(0)
            w2utils.bindEvents(query(box).find('#w2prompt'), {
                keydown(evt) {
                    if (evt.keyCode == 27) evt.stopPropagation()
                },
                change(evt) {
                    let edata = prom.self.trigger('change', { target: 'prompt', originalEvent: evt })
                    if (edata.isCancelled === true) return
                    if (evt.keyCode == 13 && evt.ctrlKey) {
                        prom.self.action('Ok', evt)
                    }
                    if (evt.keyCode == 27) {
                        prom.self.action('Cancel', evt)
                    }
                    edata.finish()
                }
            })
            query(box).find('.w2ui-eaction').trigger('keyup')
        })
        .on('action:after.prompt', (event) => {
            if (typeof event.detail.self?.close == 'function') {
                event.detail.self.close()
            }
            if (typeof callBack == 'function') callBack(event.detail.action)
        })
    return prom
}
let w2popup = new Dialog()
/**
 * Part of w2ui 2.0 library
 * - Dependencies: mQuery, w2utils, w2base
 *
 * 2.0 Changes
 * - multiple tooltips to the same anchor
 *
 */

class Tooltip {
    // no need to extend w2base, as each individual tooltip extends it
    static active = {} // all defined tooltips
    constructor() {
        this.defaults = {
            name            : null,     // name for the overlay, otherwise input id is used
            html            : '',       // text or html
            style           : '',       // additional style for the overlay
            class           : '',       // add class for w2ui-tooltip-body
            position        : 'top|bottom',   // can be left, right, top, bottom
            align           : '',       // can be: both, both:XX left, right, both, top, bottom
            anchor          : null,     // element it is attached to, if anchor is body, then it is context menu
            anchorClass     : '',       // add class for anchor when tooltip is shown
            anchorStyle     : '',       // add style for anchor when tooltip is shown
            autoShow        : false,    // if autoShow true, then tooltip will show on mouseEnter and hide on mouseLeave
            autoShowOn      : null,     // when options.autoShow = true, mouse event to show on
            autoHideOn      : null,     // when options.autoShow = true, mouse event to hide on
            arrowSize       : 8,        // size of the carret
            margin          : 0,        // extra margin from the anchor
            screenMargin    : 2,        // min margin from screen to tooltip
            autoResize      : true,     // auto resize based on content size and available size
            margin          : 1,        // distance from the anchor
            offsetX         : 0,        // delta for left coordinate
            offsetY         : 0,        // delta for top coordinate
            maxWidth        : null,     // max width
            maxHeight       : null,     // max height
            watchScroll     : null,     // attach to onScroll event // TODO:
            watchResize     : null,     // attach to onResize event // TODO:
            hideOn          : null,     // events when to hide tooltip, ['click', 'change', 'key', 'focus', 'blur'],
            onThen          : null,     // called when displayed
            onShow          : null,     // callBack when shown
            onHide          : null,     // callBack when hidden
            onUpdate        : null,     // callback when tooltip gets updated
            onMove          : null      // callback when tooltip is moved
        }
    }
    static observeRemove = new MutationObserver((mutations) => {
        let cnt = 0
        Object.keys(Tooltip.active).forEach(name => {
            let overlay = Tooltip.active[name]
            if (overlay.displayed) {
                if (!overlay.anchor || !overlay.anchor.isConnected) {
                    overlay.hide()
                } else {
                    cnt++
                }
            }
        })
        // remove observer, as there is no active tooltips
        if (cnt === 0) {
            Tooltip.observeRemove.disconnect()
        }
    })
    trigger(event, data) {
        if (arguments.length == 2) {
            let type = event
            event = data
            data.type = type
        }
        if (event.overlay) {
            return event.overlay.trigger(event)
        } else {
            console.log('ERROR: cannot find overlay where to trigger events')
        }
    }
    get(name) {
        if (arguments.length == 0) {
            return Object.keys(Tooltip.active)
        } else if (name === true) {
            return Tooltip.active
        } else {
            return Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        }
    }
    attach(anchor, text) {
        let options, overlay
        let self = this
        if (arguments.length == 0) {
            return
        } else if (arguments.length == 1 && anchor.anchor) {
            options = anchor
            anchor = options.anchor
        } else if (arguments.length === 2 && typeof text === 'string') {
            options = { anchor, html: text }
            text = options.html
        } else if (arguments.length === 2 && text != null && typeof text === 'object') {
            options = text
            text = options.html
        }
        options = w2utils.extend({}, this.defaults, options || {})
        if (!text && options.text) text = options.text
        if (!text && options.html) text = options.html
        // anchor is func var
        delete options.anchor
        // define tooltip
        let name = (options.name ? options.name : anchor.id)
        if (anchor == document || anchor == document.body) {
            anchor = document.body
            name = 'context-menu'
        }
        if (!name) {
            name = 'noname-' + Object.keys(Tooltip.active).length
            console.log('NOTICE: name property is not defined for tooltip, could lead to too many instances')
        }
        // clean name as it is used as id and css selector
        name = name.replace(/[\s\.#]/g, '_')
        if (Tooltip.active[name]) {
            overlay = Tooltip.active[name]
            overlay.prevOptions = overlay.options
            overlay.options = options // do not merge or extend, otherwiser menu items get merged too
            // overlay.options = w2utils.extend({}, overlay.options, options)
            overlay.anchor  = anchor // as HTML elements are not copied
            if (overlay.prevOptions.html != overlay.options.html || overlay.prevOptions.class != overlay.options.class
                    || overlay.prevOptions.style != overlay.options.style) {
                overlay.needsUpdate = true
            }
            options = overlay.options // it was recreated
        } else {
            overlay = new w2base()
            Object.assign(overlay, {
                id: 'w2overlay-' + name, name, options, anchor,
                displayed: false,
                tmp: {
                    observeResize: new ResizeObserver(() => {
                        this.resize(overlay.name)
                    })
                },
                hide() {
                    self.hide(name)
                }
            })
            Tooltip.active[name] = overlay
        }
        // move events on to overlay layer
        Object.keys(overlay.options).forEach(key => {
            let val = overlay.options[key]
            if (key.startsWith('on') && typeof val == 'function') {
                overlay[key] = val
                delete overlay.options[key]
            }
        })
        // add event for auto show/hide
        if (options.autoShow === true) {
            options.autoShowOn = options.autoShowOn ?? 'mouseenter'
            options.autoHideOn = options.autoHideOn ?? 'mouseleave'
            options.autoShow = false
        }
        if (options.autoShowOn) {
            let scope = 'autoShow-' + overlay.name
            query(anchor)
                .off(`.${scope}`)
                .on(`${options.autoShowOn}.${scope}`, event => {
                    self.show(overlay.name)
                    event.stopPropagation()
                })
            delete options.autoShowOn
        }
        if (options.autoHideOn) {
            let scope = 'autoHide-' + overlay.name
            query(anchor)
                .off(`.${scope}`)
                .on(`${options.autoHideOn}.${scope}`, event => {
                    self.hide(overlay.name)
                    event.stopPropagation()
                })
            delete options.autoHideOn
        }
        overlay.off('.attach')
        let ret = {
            overlay,
            then: (callback) => {
                overlay.on('show:after.attach', event => { callback(event) })
                return ret
            },
            show: (callback) => {
                overlay.on('show.attach', event => { callback(event) })
                return ret
            },
            hide: (callback) => {
                overlay.on('hide.attach', event => { callback(event) })
                return ret
            },
            update: (callback) => {
                overlay.on('update.attach', event => { callback(event) })
                return ret
            },
            move: (callback) => {
                overlay.on('move.attach', event => { callback(event) })
                return ret
            }
        }
        return ret
    }
    update(name, html) {
        let overlay = Tooltip.active[name]
        if (overlay) {
            overlay.needsUpdate = true
            overlay.options.html = html
            this.show(name)
        } else {
            console.log(`Tooltip "${name}" is not displayed. Cannot update it.`)
        }
    }
    show(name) {
        if (name instanceof HTMLElement || name instanceof Object) {
            let options = name
            if (name instanceof HTMLElement) {
                options = arguments[1] || {}
                options.anchor = name
            }
            let ret = this.attach(options)
            query(ret.overlay.anchor)
                .off('.autoShow-' + ret.overlay.name)
                .off('.autoHide-' + ret.overlay.name)
            // need a timer, so that events would be preperty set
            setTimeout(() => {
                this.show(ret.overlay.name)
                if (this.initControls) {
                    this.initControls(ret.overlay)
                }
            }, 1)
            return ret
        }
        let edata
        let self = this
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        if (!overlay) return
        let options = overlay.options
        if (!overlay || (overlay.displayed && !overlay.needsUpdate)) {
            this.resize(overlay?.name)
            return
        }
        let position = options.position.split('|')
        let isVertical = ['top', 'bottom'].includes(position[0])
        // enforce nowrap only when align=both and vertical
        let overlayStyles = (options.align == 'both' && isVertical ? '' : 'white-space: nowrap;')
        if (options.maxWidth && w2utils.getStrWidth(options.html, '') > options.maxWidth) {
            overlayStyles = 'width: '+ options.maxWidth + 'px; white-space: inherit; overflow: auto;'
        }
        overlayStyles += ' max-height: '+ (options.maxHeight ? options.maxHeight : window.innerHeight - 40) + 'px;'
        // if empty content - then hide it
        if (options.html === '' || options.html == null) {
            self.hide(name)
            return
        } else if (overlay.box) {
            // if already present, update it
            edata = this.trigger('update', { target: name, overlay })
            if (edata.isCancelled === true) {
                // restore previous options
                if (overlay.prevOptions) {
                    overlay.options = overlay.prevOptions
                    delete overlay.prevOptions
                }
                return
            }
            query(overlay.box)
                .find('.w2ui-overlay-body')
                .attr('style', (options.style || '') + '; ' + overlayStyles)
                .removeClass() // removes all classes
                .addClass('w2ui-overlay-body ' + options.class)
                .html(options.html)
            this.resize(overlay.name)
        } else {
            // event before
            edata = this.trigger('show', { target: name, overlay })
            if (edata.isCancelled === true) return
            // normal processing
            query('body').append(
                // pointer-events will be re-enabled leter
                `<div id="${overlay.id}" name="${name}" style="display: none; pointer-events: none" class="w2ui-overlay"
                        data-click="stop" data-focusin="stop">
                    <style></style>
                    <div class="w2ui-overlay-body ${options.class}" style="${options.style || ''}; ${overlayStyles}">
                        ${options.html}
                    </div>
                </div>`)
            overlay.box = query('#'+w2utils.escapeId(overlay.id))[0]
            overlay.displayed = true
            let names = query(overlay.anchor).data('tooltipName') ?? []
            names.push(name)
            query(overlay.anchor).data('tooltipName', names) // make available to element overlay attached to
            w2utils.bindEvents(overlay.box, {})
            // remember anchor's original styles
            overlay.tmp.originalCSS = ''
            if (query(overlay.anchor).length > 0) {
                overlay.tmp.originalCSS = query(overlay.anchor)[0].style.cssText
            }
            this.resize(overlay.name)
        }
        if (options.anchorStyle) {
            overlay.anchor.style.cssText += ';' + options.anchorStyle
        }
        if (options.anchorClass) {
            // do not add w2ui-focus to body
            if (!(options.anchorClass == 'w2ui-focus' && overlay.anchor == document.body)) {
                query(overlay.anchor).addClass(options.anchorClass)
            }
        }
        // add on hide events
        if (typeof options.hideOn == 'string') options.hideOn = [options.hideOn]
        if (!Array.isArray(options.hideOn)) options.hideOn = []
        // initial scroll
        Object.assign(overlay.tmp, {
            scrollLeft: document.body.scrollLeft,
            scrollTop: document.body.scrollTop
        })
        addHideEvents()
        addWatchEvents(document.body)
        // first show empty tooltip, so it will popup up in the right position
        query(overlay.box).show()
        overlay.tmp.observeResize.observe(overlay.box)
        // observer element removal from DOM
        Tooltip.observeRemove.observe(document.body, { subtree: true, childList: true })
        // then insert html and it will adjust
        query(overlay.box)
            .css('opacity', 1)
            .find('.w2ui-overlay-body')
            .html(options.html)
        /**
         * pointer-events: none is needed to avoid cases when popup is shown right under the cursor
         * or it will trigger onmouseout, onmouseleave and other events.
         */
        setTimeout(() => { query(overlay.box).css({ 'pointer-events': 'auto' }).data('ready', 'yes') }, 100)
        delete overlay.needsUpdate
        // expose overlay to DOM element
        overlay.box.overlay = overlay
        // event after
        if (edata) edata.finish()
        return { overlay }
        function addWatchEvents(el) {
            let scope = 'tooltip-' + overlay.name
            let queryEl = el
            if (el.tagName == 'BODY') {
                queryEl = el.ownerDocument
            }
            query(queryEl)
                .off(`.${scope}`)
                .on(`scroll.${scope}`, event => {
                    Object.assign(overlay.tmp, {
                        scrollLeft: el.scrollLeft,
                        scrollTop: el.scrollTop
                    })
                    self.resize(overlay.name)
                })
        }
        function addHideEvents() {
            let hide = (event) => { self.hide(overlay.name) }
            let $anchor = query(overlay.anchor)
            let scope = 'tooltip-' + overlay.name
            // document click
            query('body').off(`.${scope}`)
            if (options.hideOn.includes('doc-click')) {
                if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
                    // otherwise hides on click to focus
                    $anchor
                        .off(`.${scope}-doc`)
                        .on(`click.${scope}-doc`, (event) => { event.stopPropagation() })
                }
                query('body').on(`click.${scope}`, hide)
            }
            if (options.hideOn.includes('focus-change')) {
                query('body')
                    .on(`focusin.${scope}`, (e) => {
                        if (document.activeElement != overlay.anchor) {
                            self.hide(overlay.name)
                        }
                    })
            }
            if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
                $anchor.off(`.${scope}`)
                options.hideOn.forEach(event => {
                    if (['doc-click', 'focus-change'].indexOf(event) == -1) {
                        $anchor.on(`${event}.${scope}`, { once: true }, hide)
                    }
                })
            }
        }
    }
    hide(name) {
        let overlay
        if (arguments.length == 0) {
            // hide all tooltips
            Object.keys(Tooltip.active).forEach(name => { this.hide(name) })
            return
        }
        if (name instanceof HTMLElement) {
            let names = query(name).data('tooltipName') ?? []
            names.forEach(name => { this.hide(name) })
            return
        }
        if (typeof name == 'string') {
            name = name.replace(/[\s\.#]/g, '_')
            overlay = Tooltip.active[name]
        }
        if (!overlay || !overlay.box) return
        delete Tooltip.active[name]
        // event before
        let edata = this.trigger('hide', { target: name, overlay })
        if (edata.isCancelled === true) return
        let scope = 'tooltip-' + overlay.name
        // normal processing
        overlay.tmp.observeResize?.disconnect()
        if (overlay.options.watchScroll) {
            query(overlay.options.watchScroll)
                .off('.w2scroll-' + overlay.name)
        }
        // if no active tooltip then disable observeRemove
        let cnt = 0
        Object.keys(Tooltip.active).forEach(key => {
            let overlay = Tooltip.active[key]
            if (overlay.displayed) {
                cnt++
            }
        })
        if (cnt == 0) {
            Tooltip.observeRemove.disconnect()
        }
        query('body').off(`.${scope}`)   // hide to click event here
        query(document).off(`.${scope}`) // scroll event here
        // remove element
        overlay.box.remove()
        overlay.box = null
        overlay.displayed = false
        // remove name from anchor properties
        let names = query(overlay.anchor).data('tooltipName') ?? []
        let ind = names.indexOf(overlay.name)
        if (ind != -1) names.splice(names.indexOf(overlay.name), 1)
        if (names.length == 0) {
            query(overlay.anchor).removeData('tooltipName')
        } else {
            query(overlay.anchor).data('tooltipName', names)
        }
        // restore original CSS
        overlay.anchor.style.cssText = overlay.tmp.originalCSS
        query(overlay.anchor)
            .off(`.${scope}`)
            .removeClass(overlay.options.anchorClass)
        // event after
        edata.finish()
    }
    resize(name) {
        if (arguments.length == 0) {
            Object.keys(Tooltip.active).forEach(key => {
                let overlay = Tooltip.active[key]
                if (overlay.displayed) this.resize(overlay.name)
            })
            return
        }
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        let pos = this.getPosition(overlay.name)
        let newPos = pos.left + 'x' + pos.top
        let edata
        if (overlay.tmp.lastPos != newPos) {
            edata = this.trigger('move', { target: name, overlay, pos })
        }
        query(overlay.box)
            .css({
                left: pos.left + 'px',
                top : pos.top + 'px'
            })
            .then(query => {
                if (pos.width != null) {
                    query.css('width', pos.width + 'px')
                         .find('.w2ui-overlay-body')
                         .css('width', '100%')
                }
                if (pos.height != null) {
                    query.css('height', pos.height + 'px')
                         .find('.w2ui-overlay-body')
                         .css('height', '100%')
                }
            })
            .find('.w2ui-overlay-body')
            .removeClass('w2ui-arrow-right w2ui-arrow-left w2ui-arrow-top w2ui-arrow-bottom')
            .addClass(pos.arrow.class)
            .closest('.w2ui-overlay')
            .find('style')
            .text(pos.arrow.style)
        if (overlay.tmp.lastPos != newPos && edata) {
            overlay.tmp.lastPos = newPos
            edata.finish()
        }
    }
    getPosition(name) {
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        if (!overlay || !overlay.box) {
            return
        }
        let options = overlay.options
        if (overlay.tmp.resizedY || overlay.tmp.resizedX) {
            query(overlay.box).css({ width: '', height: '', scroll: 'auto' })
        }
        let scrollSize = w2utils.scrollBarSize()
        let hasScrollBarX = !(document.body.scrollWidth == document.body.clientWidth)
        let hasScrollBarY = !(document.body.scrollHeight == document.body.clientHeight)
        let max = {
            width: window.innerWidth - (hasScrollBarY ? scrollSize : 0),
            height: window.innerHeight - (hasScrollBarX ? scrollSize : 0)
        }
        let position   = options.position == 'auto' ? 'top|bottom|right|left'.split('|') : options.position.split('|')
        let isVertical = ['top', 'bottom'].includes(position[0])
        let content    = overlay.box.getBoundingClientRect()
        let anchor     = overlay.anchor.getBoundingClientRect()
        if (overlay.anchor == document.body) {
            // context menu
            let { x, y, width, height } = options.originalEvent
            anchor = { left: x - 2, top: y - 4, width, height, arrow: 'none' }
        }
        let arrowSize = options.arrowSize
        if (anchor.arrow == 'none') arrowSize = 0
        // space available
        let available = { // tipsize adjustment should be here, not in max.width/max.height
            top: anchor.top,
            bottom: max.height - (anchor.top + anchor.height) - + (hasScrollBarX ? scrollSize : 0),
            left: anchor.left,
            right: max.width - (anchor.left + anchor.width) + (hasScrollBarY ? scrollSize : 0),
        }
        // size of empty tooltip
        if (content.width < 22) content.width = 22
        if (content.height < 14) content.height = 14
        let left, top, width, height // tooltip position
        let found = ''
        let arrow = {
            offset: 0,
            class: '',
            style: `#${overlay.id} { --tip-size: ${arrowSize}px; }`
        }
        let adjust   = { left: 0, top: 0 }
        let bestFit  = { posX: '', x: 0, posY: '', y: 0 }
        // find best position
        position.forEach(pos => {
            if (['top', 'bottom'].includes(pos)) {
                if (!found && (content.height + arrowSize/1.893) < available[pos]) { // 1.893 = 1 + sin(90)
                    found = pos
                }
                if (available[pos] > bestFit.y) {
                    Object.assign(bestFit, { posY: pos, y: available[pos] })
                }
            }
            if (['left', 'right'].includes(pos)) {
                if (!found && (content.width + arrowSize/1.893) < available[pos]) { // 1.893 = 1 + sin(90)
                    found = pos
                }
                if (available[pos] > bestFit.x) {
                    Object.assign(bestFit, { posX: pos, x: available[pos] })
                }
            }
        })
        // if not found, use best (greatest available space) position
        if (!found) {
            if (isVertical) {
                found = bestFit.posY
            } else {
                found = bestFit.posX
            }
        }
        if (options.autoResize) {
            if (['top', 'bottom'].includes(found)) {
                if (content.height > available[found]) {
                    height = available[found]
                    overlay.tmp.resizedY = true
                } else {
                    overlay.tmp.resizedY = false
                }
            }
            if (['left', 'right'].includes(found)) {
                if (content.width > available[found]) {
                    width = available[found]
                    overlay.tmp.resizedX = true
                } else {
                    overlay.tmp.resizedX = false
                }
            }
        }
        usePosition(found)
        if (isVertical) anchorAlignment()
        screenAdjust()
        let extraTop = (found == 'top' ? -options.margin : (found == 'bottom' ? options.margin : 0))
        let extraLeft = (found == 'left' ? -options.margin : (found == 'right' ? options.margin : 0))
        // adjust for scrollbar
        top = Math.floor((top + parseFloat(options.offsetY) + parseFloat(extraTop)) * 100) / 100
        left = Math.floor((left + parseFloat(options.offsetX) + parseFloat(extraLeft)) * 100) / 100
        return { left, top, arrow, adjust, width, height, pos: found }
        function usePosition(pos) {
            arrow.class = anchor.arrow ? anchor.arrow : `w2ui-arrow-${pos}`
            switch (pos) {
                case 'top': {
                    left = anchor.left + (anchor.width - (width ?? content.width)) / 2
                    top = anchor.top - (height ?? content.height) - arrowSize / 1.5 + 1
                    break
                }
                case 'bottom': {
                    left = anchor.left + (anchor.width - (width ?? content.width)) / 2
                    top = anchor.top + anchor.height + arrowSize / 1.25 + 1
                    break
                }
                case 'left': {
                    left = anchor.left - (width ?? content.width) - arrowSize / 1.2 - 1
                    top = anchor.top + (anchor.height - (height ?? content.height)) / 2
                    break
                }
                case 'right': {
                    left = anchor.left + anchor.width + arrowSize / 1.2 + 1
                    top = anchor.top + (anchor.height - (height ?? content.height)) / 2
                    break
                }
            }
        }
        function anchorAlignment() {
            // top/bottom alignments
            if (options.align == 'left') {
                adjust.left = anchor.left - left
                left = anchor.left
            }
            if (options.align == 'right') {
                adjust.left = (anchor.left + anchor.width - (width ?? content.width)) - left
                left = anchor.left + anchor.width - (width ?? content.width)
            }
            if (['top', 'bottom'].includes(found) && options.align.startsWith('both')) {
                let minWidth = options.align.split(':')[1] ?? 50
                if (anchor.width >= minWidth) {
                    left = anchor.left
                    width = anchor.width
                }
            }
            // left/right alignments
            if (options.align == 'top') {
                adjust.top = anchor.top - top
                top = anchor.top
            }
            if (options.align == 'bottom') {
                adjust.top = (anchor.top + anchor.height - (height ?? content.height)) - top
                top = anchor.top + anchor.height - (height ?? content.height)
            }
            if (['left', 'right'].includes(found) && options.align.startsWith('both')) {
                let minHeight = options.align.split(':')[1] ?? 50
                if (anchor.height >= minHeight) {
                    top = anchor.top
                    height = anchor.height
                }
            }
        }
        function screenAdjust() {
            let adjustArrow
            // adjust tip if needed after alignment
            if ((['left', 'right'].includes(options.align) && anchor.width < (width ?? content.width))
                || (['top', 'bottom'].includes(options.align) && anchor.height < (height ?? content.height))
            ) {
                adjustArrow = true
            }
            // if off screen then adjust
            let minLeft = (found == 'right' ? arrowSize : options.screenMargin)
            let minTop  = (found == 'bottom' ? arrowSize : options.screenMargin)
            let maxLeft = max.width - (width ?? content.width) - (found == 'left' ? arrowSize : options.screenMargin)
            let maxTop  = max.height - (height ?? content.height) - (found == 'top' ? arrowSize : options.screenMargin) + 3
            // adjust X
            if (['top', 'bottom'].includes(found) || options.autoResize) {
                if (left < minLeft) {
                    adjustArrow = true
                    adjust.left -= left
                    left = minLeft
                }
                if (left > maxLeft) {
                    adjustArrow = true
                    adjust.left -= left - maxLeft
                    left += maxLeft - left
                }
            }
            // adjust Y
            if (['left', 'right'].includes(found) || options.autoResize) {
                if (top < minTop) {
                    adjustArrow = true
                    adjust.top -= top
                    top = minTop
                }
                if (top > maxTop) {
                    adjustArrow = true
                    adjust.top -= top - maxTop
                    top += maxTop - top
                }
            }
            // moves carret to adjust it with element width
            if (adjustArrow) {
                let aType = isVertical ? 'left' : 'top'
                let sType = isVertical ? 'width' : 'height'
                arrow.offset = -adjust[aType]
                let maxOffset = content[sType] / 2 - arrowSize
                if (Math.abs(arrow.offset) > maxOffset + arrowSize) {
                    arrow.class = '' // no arrow
                }
                if (Math.abs(arrow.offset) > maxOffset) {
                    arrow.offset = arrow.offset < 0 ? -maxOffset : maxOffset
                }
                arrow.style = w2utils.stripSpaces(`#${overlay.id} .w2ui-overlay-body:after,
                            #${overlay.id} .w2ui-overlay-body:before {
                                --tip-size: ${arrowSize}px;
                                margin-${aType}: ${arrow.offset}px;
                            }`)
            }
        }
    }
}
class ColorTooltip extends Tooltip {
    constructor() {
        super()
        this.palette = [
            ['000000', '333333', '555555', '777777', '888888', '999999', 'AAAAAA', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'F7F7F7', 'FFFFFF'],
            ['FF011B', 'FF9838', 'FFC300', 'FFFD59', '86FF14', '14FF7A', '2EFFFC', '2693FF', '006CE7', '9B24F4', 'FF21F5', 'FF0099'],
            ['FFEAEA', 'FCEFE1', 'FCF4DC', 'FFFECF', 'EBFFD9', 'D9FFE9', 'E0FFFF', 'E8F4FF', 'ECF4FC', 'EAE6F4', 'FFF5FE', 'FCF0F7'],
            ['F4CCCC', 'FCE5CD', 'FFF1C2', 'FFFDA1', 'D5FCB1', 'B5F7D0', 'BFFFFF', 'D6ECFF', 'CFE2F3', 'D9D1E9', 'FFE3FD', 'FFD9F0'],
            ['EA9899', 'F9CB9C', 'FFE48C', 'F7F56F', 'B9F77E', '84F0B1', '83F7F7', 'B5DAFF', '9FC5E8', 'B4A7D6', 'FAB9F6', 'FFADDE'],
            ['E06666', 'F6B26B', 'DEB737', 'E0DE51', '8FDB48', '52D189', '4EDEDB', '76ACE3', '6FA8DC', '8E7CC3', 'E07EDA', 'F26DBD'],
            ['CC0814', 'E69138', 'AB8816', 'B5B20E', '6BAB30', '27A85F', '1BA8A6', '3C81C7', '3D85C6', '674EA7', 'A14F9D', 'BF4990'],
            ['99050C', 'B45F17', '80650E', '737103', '395E14', '10783D', '13615E', '094785', '0A5394', '351C75', '780172', '782C5A']
        ]
        this.defaults = w2utils.extend({}, this.defaults, {
            advanced    : false,
            transparent : true,
            position    : 'top|bottom',
            class       : 'w2ui-white',
            color       : '',
            liveUpdate  : true,
            arrowSize   : 12,
            autoResize  : false,
            anchorClass : 'w2ui-focus',
            autoShowOn  : 'focus',
            hideOn      : ['doc-click', 'focus-change'],
            onSelect    : null,
            onLiveUpdate: null
        })
    }
    attach(anchor, text) {
        let options
        if (arguments.length == 1 && anchor.anchor) {
            options = anchor
            anchor = options.anchor
        } else if (arguments.length === 2 && text != null && typeof text === 'object') {
            options = text
            options.anchor = anchor
        }
        let prevHideOn = options.hideOn
        options = w2utils.extend({}, this.defaults, options || {})
        if (prevHideOn) {
            options.hideOn = prevHideOn
        }
        options.style += '; padding: 0;'
        // add remove transparent color
        if (options.transparent && this.palette[0][1] == '333333') {
            this.palette[0].splice(1, 1)
            this.palette[0].push('')
        }
        if (!options.transparent && this.palette[0][1] != '333333') {
            this.palette[0].splice(1, 0, '333333')
            this.palette[0].pop()
        }
        if (options.color) options.color = String(options.color).toUpperCase()
        if (typeof options.color === 'string' && options.color.substr(0,1) === '#') options.color = options.color.substr(1)
        // needed for keyboard navigation
        this.index = [-1, -1]
        let ret = super.attach(options)
        let overlay = ret.overlay
        overlay.options.html = this.getColorHTML(overlay.name, options)
        overlay.on('show.attach', event => {
            let overlay = event.detail.overlay
            let anchor  = overlay.anchor
            let options = overlay.options
            if (['INPUT', 'TEXTAREA'].includes(anchor.tagName) && !options.color && anchor.value) {
                overlay.tmp.initColor = anchor.value
            }
            delete overlay.newColor
        })
        overlay.on('show:after.attach', event => {
            if (ret.overlay?.box) {
                let actions = query(ret.overlay.box).find('.w2ui-eaction')
                w2utils.bindEvents(actions, this)
                this.initControls(ret.overlay)
            }
        })
        overlay.on('update:after.attach', event => {
            if (ret.overlay?.box) {
                let actions = query(ret.overlay.box).find('.w2ui-eaction')
                w2utils.bindEvents(actions, this)
                this.initControls(ret.overlay)
            }
        })
        overlay.on('hide.attach', event => {
            let overlay = event.detail.overlay
            let anchor  = overlay.anchor
            let color   = overlay.newColor ?? overlay.options.color ?? ''
            if (['INPUT', 'TEXTAREA'].includes(anchor.tagName) && anchor.value != color) {
                anchor.value = color
            }
            let edata = this.trigger('select', { color, target: overlay.name, overlay })
            if (edata.isCancelled === true) return
            // event after
            edata.finish()
        })
        ret.liveUpdate = (callback) => {
            overlay.on('liveUpdate.attach', (event) => { callback(event) })
            return ret
        }
        ret.select = (callback) => {
            overlay.on('select.attach', (event) => { callback(event) })
            return ret
        }
        return ret
    }
    // regular panel handler, adds selection class
    select(color, name) {
        let target
        this.index = [-1, -1]
        if (typeof name != 'string') {
            target = name.target
            this.index = query(target).attr('index').split(':')
            name = query(target).closest('.w2ui-overlay').attr('name')
        }
        let overlay = this.get(name)
        // event before
        let edata = this.trigger('liveUpdate', { color, target: name, overlay, param: arguments[1] })
        if (edata.isCancelled === true) return
        // if anchor is input - live update
        if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName) && overlay.options.liveUpdate) {
            query(overlay.anchor).val(color)
        }
        overlay.newColor = color
        query(overlay.box).find('.w2ui-selected').removeClass('w2ui-selected')
        if (target) {
            query(target).addClass('w2ui-selected')
        }
        // event after
        edata.finish()
    }
    // used for keyboard navigation, if any
    nextColor(direction) { // TODO: check it
        let pal = this.palette
        switch (direction) {
            case 'up':
                this.index[0]--
                break
            case 'down':
                this.index[0]++
                break
            case 'right':
                this.index[1]++
                break
            case 'left':
                this.index[1]--
                break
        }
        if (this.index[0] < 0) this.index[0] = 0
        if (this.index[0] > pal.length - 2) this.index[0] = pal.length - 2
        if (this.index[1] < 0) this.index[1] = 0
        if (this.index[1] > pal[0].length - 1) this.index[1] = pal[0].length - 1
        return pal[this.index[0]][this.index[1]]
    }
    tabClick(index, name) {
        if (typeof name != 'string') {
            name = query(name.target).closest('.w2ui-overlay').attr('name')
        }
        let overlay = this.get(name)
        let tab = query(overlay.box).find(`.w2ui-color-tab:nth-child(${index})`)
        query(overlay.box).find('.w2ui-color-tab').removeClass('w2ui-selected')
        query(tab).addClass('w2ui-selected')
        query(overlay.box)
            .find('.w2ui-tab-content')
            .hide()
            .closest('.w2ui-colors')
            .find('.tab-'+ index)
            .show()
    }
    // generate HTML with color pallent and controls
    getColorHTML(name, options) {
        let html = `
            <div class="w2ui-colors">
                <div class="w2ui-tab-content tab-1">`
        for (let i = 0; i < this.palette.length; i++) {
            html += '<div class="w2ui-color-row">'
            for (let j = 0; j < this.palette[i].length; j++) {
                let color = this.palette[i][j]
                let border = ''
                if (color === 'FFFFFF') border = '; border: 1px solid #efefef'
                html += `
                    <div class="w2ui-color w2ui-eaction ${color === '' ? 'w2ui-no-color' : ''} ${options.color == color ? 'w2ui-selected' : ''}"
                        style="background-color: #${color + border};" name="${color}" index="${i}:${j}"
                        data-mousedown="select|'${color}'|event" data-mouseup="hide|${name}">&nbsp;
                    </div>`
            }
            html += '</div>'
            if (i < 2) html += '<div style="height: 8px"></div>'
        }
        html += '</div>'
        // advanced tab
        html += `
            <div class="w2ui-tab-content tab-2" style="display: none">
                <div class="color-info">
                    <div class="color-preview-bg"><div class="color-preview"></div><div class="color-original"></div></div>
                    <div class="color-part">
                        <span>H</span> <input class="w2ui-input" name="h" maxlength="3" max="360" tabindex="101">
                        <span>R</span> <input class="w2ui-input" name="r" maxlength="3" max="255" tabindex="104">
                    </div>
                    <div class="color-part">
                        <span>S</span> <input class="w2ui-input" name="s" maxlength="3" max="100" tabindex="102">
                        <span>G</span> <input class="w2ui-input" name="g" maxlength="3" max="255" tabindex="105">
                    </div>
                    <div class="color-part">
                        <span>V</span> <input class="w2ui-input" name="v" maxlength="3" max="100" tabindex="103">
                        <span>B</span> <input class="w2ui-input" name="b" maxlength="3" max="255" tabindex="106">
                    </div>
                    <div class="color-part opacity">
                        <span>${w2utils.lang('Opacity')}</span>
                        <input class="w2ui-input" name="a" maxlength="5" max="1" tabindex="107">
                    </div>
                </div>
                <div class="palette" name="palette">
                    <div class="palette-bg"></div>
                    <div class="value1 move-x move-y"></div>
                </div>
                <div class="rainbow" name="rainbow">
                    <div class="value2 move-x"></div>
                </div>
                <div class="alpha" name="alpha">
                    <div class="alpha-bg"></div>
                    <div class="value2 move-x"></div>
                </div>
            </div>`
        // color tabs on the bottom
        html += `
            <div class="w2ui-color-tabs">
                <div class="w2ui-color-tab selected w2ui-eaction" data-click="tabClick|1|event|this"><span class="w2ui-icon w2ui-icon-colors"></span></div>
                <div class="w2ui-color-tab w2ui-eaction" data-click="tabClick|2|event|this"><span class="w2ui-icon w2ui-icon-settings"></span></div>
                <div style="padding: 5px; width: 100%; text-align: right;">
                    ${(typeof options.html == 'string' ? options.html : '')}
                </div>
            </div>`
        return html
    }
    // bind advanced tab controls
    initControls(overlay) {
        let initial // used for mouse events
        let self = this
        let options = overlay.options
        let rgb = w2utils.parseColor(options.color || overlay.tmp.initColor)
        if (rgb == null) {
            rgb = { r: 140, g: 150, b: 160, a: 1 }
        }
        let hsv = w2utils.rgb2hsv(rgb)
        if (options.advanced === true) {
            this.tabClick(2, overlay.name)
        }
        setColor(hsv, true, true)
        // even for rgb, hsv inputs
        query(overlay.box).find('input')
            .off('.w2color')
            .on('change.w2color', (event) => {
                let el = query(event.target)
                let val = parseFloat(el.val())
                let max = parseFloat(el.attr('max'))
                if (isNaN(val)) {
                    val = 0
                    el.val(0)
                }
                if (max > 1) val = parseInt(val) // trancate fractions
                if (max > 0 && val > max) {
                    el.val(max)
                    val = max
                }
                if (val < 0) {
                    el.val(0)
                    val = 0
                }
                let name  = el.attr('name')
                let color = {}
                if (['r', 'g', 'b', 'a'].indexOf(name) !== -1) {
                    rgb[name] = val
                    hsv = w2utils.rgb2hsv(rgb)
                } else if (['h', 's', 'v'].indexOf(name) !== -1) {
                    color[name] = val
                }
                setColor(color, true)
            })
        // click on original color resets it
        query(overlay.box).find('.color-original')
            .off('.w2color')
            .on('click.w2color', (event) => {
                let tmp = w2utils.parseColor(query(event.target).css('background-color'))
                if (tmp != null) {
                    rgb = tmp
                    hsv = w2utils.rgb2hsv(rgb)
                    setColor(hsv, true)
                }
            })
        // color sliders events
        let mDown = `${!w2utils.isIOS ? 'mousedown' : 'touchstart'}.w2color`
        let mUp   = `${!w2utils.isIOS ? 'mouseup' : 'touchend'}.w2color`
        let mMove = `${!w2utils.isIOS ? 'mousemove' : 'touchmove'}.w2color`
        query(overlay.box).find('.palette, .rainbow, .alpha')
            .off('.w2color')
            .on(`${mDown}.w2color`, mouseDown)
        return
        function setColor(color, fullUpdate, initial) {
            if (color.h != null) hsv.h = color.h
            if (color.s != null) hsv.s = color.s
            if (color.v != null) hsv.v = color.v
            if (color.a != null) { rgb.a = color.a; hsv.a = color.a }
            rgb = w2utils.hsv2rgb(hsv)
            let newColor = 'rgba('+ rgb.r +','+ rgb.g +','+ rgb.b +','+ rgb.a +')'
            let cl       = [
                Number(rgb.r).toString(16).toUpperCase(),
                Number(rgb.g).toString(16).toUpperCase(),
                Number(rgb.b).toString(16).toUpperCase(),
                (Math.round(Number(rgb.a)*255)).toString(16).toUpperCase()
            ]
            cl.forEach((item, ind) => { if (item.length === 1) cl[ind] = '0' + item })
            newColor = cl[0] + cl[1] + cl[2] + cl[3]
            if (rgb.a === 1) {
                newColor = cl[0] + cl[1] + cl[2]
            }
            query(overlay.box).find('.color-preview').css('background-color', '#' + newColor)
            query(overlay.box).find('input').each(el => {
                if (el.name) {
                    if (rgb[el.name] != null) el.value = rgb[el.name]
                    if (hsv[el.name] != null) el.value = hsv[el.name]
                    if (el.name === 'a') el.value = rgb.a
                }
            })
            // if it is in pallette
            if (initial) {
                let color = overlay.tmp?.initColor || newColor
                query(overlay.box).find('.color-original')
                    .css('background-color', '#'+color)
                query(overlay.box).find('.w2ui-colors .w2ui-selected')
                    .removeClass('w2ui-selected')
                query(overlay.box).find(`.w2ui-colors [name="${color}"]`)
                    .addClass('w2ui-selected')
                // if has transparent color, open advanced tab
                if (newColor.length == 8) {
                    self.tabClick(2, overlay.name)
                }
            } else {
                self.select(newColor, overlay.name)
            }
            if (fullUpdate) {
                updateSliders()
                refreshPalette()
            }
        }
        function updateSliders() {
            let el1 = query(overlay.box).find('.palette .value1')
            let el2 = query(overlay.box).find('.rainbow .value2')
            let el3 = query(overlay.box).find('.alpha .value2')
            let offset1 = parseInt(el1[0].clientWidth) / 2
            let offset2 = parseInt(el2[0].clientWidth) / 2
            el1.css({
                'left': (hsv.s * 150 / 100 - offset1) + 'px',
                'top': ((100 - hsv.v) * 125 / 100 - offset1) + 'px'
            })
            el2.css('left', (hsv.h/(360/150) - offset2) + 'px')
            el3.css('left', (rgb.a*150 - offset2) + 'px')
        }
        function refreshPalette() {
            let cl  = w2utils.hsv2rgb(hsv.h, 100, 100)
            let rgb = `${cl.r},${cl.g},${cl.b}`
            query(overlay.box).find('.palette')
                .css('background-image', `linear-gradient(90deg, rgba(${rgb},0) 0%, rgba(${rgb},1) 100%)`)
        }
        function mouseDown(event) {
            let el = query(this).find('.value1, .value2')
            let offset = parseInt(el.prop('clientWidth')) / 2
            if (el.hasClass('move-x')) el.css({ left: (event.offsetX - offset) + 'px' })
            if (el.hasClass('move-y')) el.css({ top: (event.offsetY - offset) + 'px' })
            initial = {
                el    : el,
                x      : event.pageX,
                y      : event.pageY,
                width  : el.prop('parentNode').clientWidth,
                height : el.prop('parentNode').clientHeight,
                left   : parseInt(el.css('left')),
                top    : parseInt(el.css('top'))
            }
            mouseMove(event)
            query('body')
                .off('.w2color')
                .on(mMove, mouseMove)
                .on(mUp, mouseUp)
        }
        function mouseUp(event) {
            query('body').off('.w2color')
        }
        function mouseMove(event) {
            let el    = initial.el
            let divX   = event.pageX - initial.x
            let divY   = event.pageY - initial.y
            let newX   = initial.left + divX
            let newY   = initial.top + divY
            let offset = parseInt(el.prop('clientWidth')) / 2
            if (newX < -offset) newX = -offset
            if (newY < -offset) newY = -offset
            if (newX > initial.width - offset) newX = initial.width - offset
            if (newY > initial.height - offset) newY = initial.height - offset
            if (el.hasClass('move-x')) el.css({ left : newX + 'px' })
            if (el.hasClass('move-y')) el.css({ top : newY + 'px' })
            // move
            let name = query(el.get(0).parentNode).attr('name')
            let x    = parseInt(el.css('left')) + offset
            let y    = parseInt(el.css('top')) + offset
            if (name === 'palette') {
                setColor({
                    s: Math.round(x / initial.width * 100),
                    v: Math.round(100 - (y / initial.height * 100))
                })
            }
            if (name === 'rainbow') {
                let h = Math.round(360 / 150 * x)
                setColor({ h: h })
                refreshPalette()
            }
            if (name === 'alpha') {
                setColor({ a: parseFloat(Number(x / 150).toFixed(2)) })
            }
        }
    }
}
class MenuTooltip extends Tooltip {
    constructor() {
        super()
        // ITEM STRUCTURE
        // item : {
        //   id       : null,
        //   text     : '',
        //   style    : '',
        //   icon     : '',
        //   count    : '',
        //   tooltip  : '',
        //   hotkey   : '',
        //   remove   : false,
        //   items    : []
        //   indent   : 0,
        //   type     : null,    // check/radio
        //   group    : false,   // groupping for checks
        //   expanded : false,
        //   hidden   : false,
        //   checked  : null,
        //   disabled : false
        //   ...
        // }
        this.defaults = w2utils.extend({}, this.defaults, {
            type        : 'normal',    // can be normal, radio, check
            items       : [],
            index       : null,        // current selected
            render      : null,
            spinner     : false,
            msgNoItems  : w2utils.lang('No items found'),
            topHTML     : '',
            menuStyle   : '',
            filter      : false,
            markSearch  : false,
            match       : 'contains',   // is, begins, ends, contains
            search      : false,        // top search TODO: Check
            altRows     : false,
            arrowSize   : 10,
            align       : 'left',
            position    : 'bottom|top',
            class       : 'w2ui-white',
            anchorClass : 'w2ui-focus',
            autoShowOn  : 'focus',
            hideOn      : ['doc-click', 'focus-change', 'select'], // also can 'item-remove'
            onSelect    : null,
            onSubMenu   : null,
            onRemove    : null
        })
    }
    attach(anchor, text) {
        let options
        if (arguments.length == 1 && anchor.anchor) {
            options = anchor
            anchor = options.anchor
        } else if (arguments.length === 2 && text != null && typeof text === 'object') {
            options = text
            options.anchor = anchor
        }
        let prevHideOn = options.hideOn
        options = w2utils.extend({}, this.defaults, options || {})
        if (prevHideOn) {
            options.hideOn = prevHideOn
        }
        options.style += '; padding: 0;'
        if (options.items == null) {
            options.items = []
        }
        options.html = this.getMenuHTML(options)
        let ret = super.attach(options)
        let overlay = ret.overlay
        overlay.on('show:after.attach, update:after.attach', event => {
            if (ret.overlay?.box) {
                let search = ''
                // reset selected and active chain
                overlay.selected = null
                overlay.options.items = w2utils.normMenu(overlay.options.items)
                if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
                    search = overlay.anchor.value
                    overlay.selected = overlay.anchor.dataset.selectedIndex
                }
                let actions = query(ret.overlay.box).find('.w2ui-eaction')
                w2utils.bindEvents(actions, this)
                this.applyFilter(overlay.name, null, search)
                    .then(data => {
                        overlay.tmp.searchCount = data.count
                        overlay.tmp.search = data.search
                        this.refreshSearch(overlay.name)
                        this.initControls(ret.overlay)
                        this.refreshIndex(overlay.name)
                    })
            }
        })
        overlay.on('hide:after.attach', event => {
            w2tooltip.hide(overlay.name + '-tooltip')
        })
        ret.select = (callback) => {
            overlay.on('select.attach', (event) => { callback(event) })
            return ret
        }
        ret.remove = (callback) => {
            overlay.on('remove.attach', (event) => { callback(event) })
            return ret
        }
        ret.subMenu = (callback) => {
            overlay.on('subMenu.attach', (event) => { callback(event) })
            return ret
        }
        return ret
    }
    update(name, items) {
        let overlay = Tooltip.active[name]
        if (overlay) {
            let options = overlay.options
            if (options.items != items) {
                options.items = items
            }
            let menuHTML = this.getMenuHTML(options)
            if (options.html != menuHTML) {
                options.html = menuHTML
                overlay.needsUpdate = true
                this.show(name)
            }
        } else {
            console.log(`Tooltip "${name}" is not displayed. Cannot update it.`)
        }
    }
    initControls(overlay) {
        query(overlay.box).find('.w2ui-menu:not(.w2ui-sub-menu)')
            .off('.w2menu')
            .on('mouseDown.w2menu', { delegate: '.w2ui-menu-item' }, event => {
                let dt = event.delegate.dataset
                this.menuDown(overlay, event, dt.index, dt.parents)
            })
            .on((w2utils.isIOS ? 'touchStart' : 'click') + '.w2menu', { delegate: '.w2ui-menu-item' }, event => {
                let dt = event.delegate.dataset
                this.menuClick(overlay, event, parseInt(dt.index), dt.parents)
            })
            .find('.w2ui-menu-item')
            .off('.w2menu')
            .on('mouseEnter.w2menu', event => {
                let dt = event.target.dataset
                let tooltip = overlay.options.items[dt.index]?.tooltip
                if (tooltip) {
                    w2tooltip.show({
                        name: overlay.name + '-tooltip',
                        anchor: event.target,
                        html: tooltip,
                        position: 'right|left',
                        hideOn: ['doc-click']
                    })
                }
            })
            .on('mouseLeave.w2menu', event => {
                w2tooltip.hide(overlay.name + '-tooltip')
            })
        if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
            query(overlay.anchor)
                .off('.w2menu')
                .on('input.w2menu', event => {
                    // if user types, clear selection
                    // let dt = event.target.dataset
                    // delete dt.selected
                    // delete dt.selectedIndex
                })
                .on('keyup.w2menu', event => {
                    event._searchType = 'filter'
                    this.keyUp(overlay, event)
                })
        }
        if (overlay.options.search) {
            query(overlay.box).find('#menu-search')
                .off('.w2menu')
                .on('keyup.w2menu', event => {
                    event._searchType = 'search'
                    this.keyUp(overlay, event)
                })
        }
    }
    getCurrent(name, id) {
        let overlay  = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        let options  = overlay.options
        let selected = (id ? id : overlay.selected ?? '').split('-')
        let last     = selected.length-1
        let index    = selected[last]
        let parents  = selected.slice(0, selected.length-1).join('-')
        index = w2utils.isInt(index) ? parseInt(index) : 0
        // items
        let items = options.items
        selected.forEach((id, ind) => {
            // do not go to the last one
            if (ind < selected.length - 1) {
                items = items[id].items
            }
        })
        return { last, index, items, item: items[index], parents }
    }
    getMenuHTML(options, items, subMenu, parentIndex) {
        if (options.spinner) {
            return `
            <div class="w2ui-menu">
                <div class="w2ui-no-items">
                    <div class="w2ui-spinner"></div>
                    ${w2utils.lang('Loading...')}
                </div>
            </div>`
        }
        if (!parentIndex) parentIndex = []
        if (items == null) {
            items = options.items
        }
        if (!Array.isArray(items)) items = []
        let count = 0
        let icon = null
        let topHTML = ''
        if (!subMenu && options.search) {
            topHTML += `
                <div class="w2ui-menu-search">
                    <span class="w2ui-icon w2ui-icon-search"></span>
                    <input id="menu-search" class="w2ui-input" type="text"/>
                </div>`
            items.forEach(item => item.hidden = false)
        }
        if (!subMenu && options.topHTML) {
            topHTML += `<div class="w2ui-menu-top">${options.topHTML}</div>`
        }
        let menu_html = `
            ${topHTML}
            <div class="w2ui-menu ${(subMenu ? 'w2ui-sub-menu' : '')}" ${!subMenu ? `style="${options.menuStyle}"` : ''}
                data-parent="${parentIndex}">
        `
        items.forEach((mitem, f) => {
            icon = mitem.icon
            let index = (parentIndex.length > 0 ? parentIndex.join('-') + '-' : '') + f
            if (icon == null) icon = null // icon might be undefined
            if (['radio', 'check'].indexOf(options.type) != -1 && !Array.isArray(mitem.items) && mitem.group !== false) {
                if (mitem.checked === true) icon = 'w2ui-icon-check'; else icon = 'w2ui-icon-empty'
            }
            if (mitem.hidden !== true) {
                let txt  = mitem.text
                let icon_dsp = ''
                let subMenu_dsp = ''
                if (typeof options.render === 'function') txt = options.render(mitem, options)
                if (typeof txt == 'function') txt = txt(mitem, options)
                if (icon) {
                    if (String(icon).slice(0, 1) !== '<') {
                        icon = `<span class="w2ui-icon ${icon}"></span>`
                    }
                    icon_dsp = `<div class="menu-icon">${icon}</span></div>`
                }
                // render only if non-empty
                if (mitem.type !== 'break' && txt != null && txt !== '' && String(txt).substr(0, 2) != '--') {
                    let classes = ['w2ui-menu-item']
                    if (options.altRows == true) {
                        classes.push(count % 2 === 0 ? 'w2ui-even' : 'w2ui-odd')
                    }
                    let colspan = 1
                    if (icon_dsp === '') colspan++
                    if (mitem.count == null && mitem.hotkey == null && mitem.remove !== true && mitem.items == null) colspan++
                    if (mitem.tooltip == null && mitem.hint != null) mitem.tooltip = mitem.hint // for backward compatibility
                    let count_dsp = ''
                    if (mitem.remove === true) {
                        count_dsp = '<span class="remove">x</span>'
                    } else if (mitem.items != null) {
                        let _items = []
                        if (typeof mitem.items == 'function') {
                            _items = mitem.items(mitem)
                        } else if (Array.isArray(mitem.items)) {
                            _items = mitem.items
                        }
                        count_dsp   = '<span></span>'
                        subMenu_dsp = `
                            <div class="w2ui-sub-menu-box" style="${mitem.expanded ? '' : 'display: none'}">
                                ${this.getMenuHTML(options, _items, true, parentIndex.concat(f))}
                            </div>`
                    } else {
                        if (mitem.count != null) count_dsp += '<span>' + mitem.count + '</span>'
                        if (mitem.hotkey != null) count_dsp += '<span class="hotkey">' + mitem.hotkey + '</span>'
                    }
                    if (mitem.disabled === true) classes.push('w2ui-disabled')
                    if (mitem._noSearchInside === true) classes.push('w2ui-no-search-inside')
                    if (subMenu_dsp !== '') {
                        classes.push('has-sub-menu')
                        if (mitem.expanded) {
                            classes.push('expanded')
                        } else {
                            classes.push('collapsed')
                        }
                    }
                    menu_html += `
                        <div index="${index}" class="${classes.join(' ')}" style="${mitem.style ? mitem.style : ''}"
                            data-index="${f}" data-parents="${parentIndex.join('-')}">
                                <div style="width: ${(subMenu ? 20 : 0) + parseInt(mitem.indent ?? 0)}px"></div>
                                ${icon_dsp}
                                <div class="menu-text" colspan="${colspan}">${w2utils.lang(txt)}</div>
                                <div class="menu-extra">${count_dsp}</div>
                        </div>
                        ${subMenu_dsp}`
                    count++
                } else {
                    // horizontal line
                    let divText = (txt ?? '').replace(/^-+/g, '')
                    menu_html  += `
                        <div index="${index}" class="w2ui-menu-divider ${divText != '' ? 'has-text' : ''}">
                            <div class="line"></div>
                            ${divText ? `<div class="text">${divText}</div>` : ''}
                        </div>`
                }
            }
            items[f] = mitem
        })
        if (count === 0 && options.msgNoItems) {
            menu_html += `
                <div class="w2ui-no-items">
                    ${w2utils.lang(options.msgNoItems)}
                </div>`
        }
        menu_html += '</div>'
        return menu_html
    }
    // Refreshed only selected item highligh, used in keyboard navigation
    refreshIndex(name) {
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        if (!overlay) return
        if (!overlay.displayed) {
            this.show(overlay.name)
        }
        let view = query(overlay.box).find('.w2ui-overlay-body').get(0)
        let search = query(overlay.box).find('.w2ui-menu-search, .w2ui-menu-top').get(0)
        query(overlay.box).find('.w2ui-menu-item.w2ui-selected')
            .removeClass('w2ui-selected')
        let el = query(overlay.box).find(`.w2ui-menu-item[index="${overlay.selected}"]`)
            .addClass('w2ui-selected')
            .get(0)
        if (el) {
            if (el.offsetTop + el.clientHeight > view.clientHeight + view.scrollTop) {
                el.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' })
            }
            if (el.offsetTop < view.scrollTop + (search ? search.clientHeight : 0)) {
                el.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'end' })
            }
        }
    }
    // show/hide searched items
    refreshSearch(name) {
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        if (!overlay) return
        if (!overlay.displayed) {
            this.show(overlay.name)
        }
        query(overlay.box).find('.w2ui-no-items').hide()
        query(overlay.box).find('.w2ui-menu-item, .w2ui-menu-divider').each(el => {
            let cur = this.getCurrent(name, el.getAttribute('index'))
            if (cur.item?.hidden) {
                query(el).hide()
            } else {
                let search = overlay.tmp?.search
                if (search && overlay.options.markSearch) {
                    w2utils.marker(el, search, { onlyFirst: overlay.options.match == 'begins' })
                }
                query(el).show()
            }
        })
        // hide empty menus
        query(overlay.box).find('.w2ui-sub-menu').each(sub => {
            let hasItems = query(sub).find('.w2ui-menu-item').get().some(el => {
                return el.style.display != 'none' ? true : false
            })
            let parent = this.getCurrent(name, sub.dataset.parent)
            // only if parent is expaneded
            if (parent.item.expanded) {
                if (!hasItems) {
                    query(sub).parent().hide()
                } else {
                    query(sub).parent().show()
                }
            }
        })
        // show empty message
        if (overlay.tmp.searchCount == 0 || overlay.options?.items?.length == 0) {
            if (query(overlay.box).find('.w2ui-no-items').length == 0) {
                query(overlay.box).find('.w2ui-menu:not(.w2ui-sub-menu)').append(`
                    <div class="w2ui-no-items">
                        ${w2utils.lang(overlay.options.msgNoItems)}
                    </div>`)
            }
            query(overlay.box).find('.w2ui-no-items').show()
        }
    }
    /**
     * Loops through the items and markes item.hidden = true for those that need to be hidden, and item.hidden = false
     * for those that are visible. Return a promise (since items can be on the server) with the number of visible items.
     */
    applyFilter(name, items, search, debounce) {
        let count = 0
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        let options = overlay.options
        let resolve, reject
        let prom = new Promise((res, rej) => {
            resolve = res
            reject = rej
        })
        if (search == null) {
            if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
                search = overlay.anchor.value
            } else {
                search = ''
            }
        }
        let selectedIds = []
        if (options.selected) {
            if (Array.isArray(options.selected)) {
                selectedIds = options.selected.map(item => {
                    return item?.id ?? item
                })
            } else if (options.selected?.id) {
                selectedIds = [options.selected.id]
            }
        }
        overlay.tmp.activeChain = null
        // if url is defined, get items from it
        let remote = overlay.tmp.remote ?? { hasMore: true, emtpySet: false, search: null, total: -1 }
        if (items == null && options.url && remote.hasMore && remote.search !== search) {
            let proceed = true
            // only when items == null because it is case of nested items
            let msg = w2utils.lang('Loading...')
            if (search.length < options.minLength && remote.emptySet !== true) {
                msg = w2utils.lang('${count} letters or more...', { count: options.minLength })
                proceed = false
                if (search === '') {
                    msg = w2utils.lang(options.msgSearch)
                }
            }
            query(overlay.box).find('.w2ui-no-items').html(msg)
            remote.search = search
            options.items = []
            overlay.tmp.remote = remote
            if (proceed) {
                this.request(overlay, search, debounce)
                    .then(remoteItems => {
                        this.update(name, remoteItems)
                        this.applyFilter(name, null, search).then(data => {
                            resolve(data)
                        })
                    })
                    .catch(error => {
                        console.log('Server Request error', error)
                    })
            }
            return prom
        }
        let edata
        // only trigger search event when data is present and for the top level
        if (items == null) {
            edata = this.trigger('search', { search, overlay, prom, resolve, reject })
            if (edata.isCancelled === true) {
                return prom
            }
        }
        if (items == null) {
            items = overlay.options.items
        }
        if (options.filter === false) {
            resolve({ count: -1, search })
            return prom
        }
        items.forEach(item => {
            let prefix = ''
            let suffix = ''
            if (['is', 'begins', 'begins with'].indexOf(options.match) !== -1) prefix = '^'
            if (['is', 'ends', 'ends with'].indexOf(options.match) !== -1) suffix = '$'
            try {
                let re = new RegExp(prefix + search + suffix, 'i')
                if (re.test(item.text) || item.text === '...') {
                    item.hidden = false
                } else {
                    item.hidden = true
                }
            } catch (e) {}
            // do not show selected items
            if (options.hideSelected && selectedIds.includes(item.id)) {
                item.hidden = true
            }
            // search nested items
            if (Array.isArray(item.items) && item.items.length > 0) {
                delete item._noSearchInside
                this.applyFilter(name, item.items, search).then(data => {
                    let subCount = data.count
                    if (subCount > 0) {
                        count += subCount
                        if (item.hidden) item._noSearchInside = true
                        // only expand items if search is not empty
                        if (search) item.expanded = true
                        item.hidden = false
                    }
                    })
            }
            if (item.hidden !== true) count++
        })
        resolve({ count, search })
        edata?.finish()
        return prom
    }
    request(overlay, search, debounce) {
        let options = overlay.options
        let remote = overlay.tmp.remote
        let resolve, reject // promise functions
        if ((options.items.length === 0 && remote.total !== 0)
            || (remote.total == options.cacheMax && search.length > remote.search.length)
            || (search.length >= remote.search.length && search.substr(0, remote.search.length) !== remote.search)
            || (search.length < remote.search.length))
        {
            // Aabort previous request if any
            if (remote.controller) {
                remote.controller.abort()
            }
            remote.loading = true
            clearTimeout(remote.timeout)
            remote.timeout = setTimeout(() => {
                let url = options.url
                let postData = { search, max: options.cacheMax }
                Object.assign(postData, options.postData)
                // trigger event
                let edata = this.trigger('request', {
                    search, overlay, url, postData,
                    httpMethod: options.method ?? 'GET',
                    httpHeaders: {}
                })
                if (edata.isCancelled === true) return
                // if event updated url and postData, use it
                url = new URL(edata.detail.url, location)
                let fetchOptions = w2utils.prepareParams(url, {
                    method: edata.detail.httpMethod,
                    headers: edata.detail.httpHeaders,
                    body: edata.detail.postData
                })
                // Create new abort controller
                remote.controller = new AbortController()
                fetchOptions.signal = remote.controller.signal
                // send request
                fetch(url, fetchOptions)
                    .then(resp => resp.json())
                    .then(data => {
                        remote.controller = null
                        // trigger event
                        let edata = overlay.trigger('load', { search: postData.search, overlay, data })
                        if (edata.isCancelled === true) return
                        // default behavior
                        data = edata.detail.data
                        if (typeof data === 'string') data = JSON.parse(data)
                        // if server just returns array
                        if (Array.isArray(data)) {
                            data = { records: data }
                        }
                        // needed for backward compatibility
                        if (data.records == null && data.items != null) {
                            data.records = data.items
                            delete data.items
                        }
                        // handles Golang marshal of empty arrays to null
                        if (!data.error && data.records == null) {
                            data.records = []
                        }
                        if (!Array.isArray(data.records)) {
                            console.error('ERROR: server did not return proper data structure', '\n',
                                ' - it should return', { records: [{ id: 1, text: 'item' }] }, '\n',
                                ' - or just an array ', [{ id: 1, text: 'item' }], '\n',
                                ' - or if errorr ', { error: true, message: 'error message' })
                            return
                        }
                        // remove all extra items if more then needed for cache
                        if (data.records.length >= options.cacheMax) {
                            data.records.splice(options.cacheMax, data.records.length)
                            remote.hasMore = true
                        } else {
                            remote.hasMore = false
                        }
                        // map id and text
                        if (options.recId == null && options.recid != null) options.recId = options.recid // since lower-case recid is used in grid
                        if (options.recId || options.recText) {
                            data.records.forEach((item) => {
                                if (typeof options.recId === 'string') item.id = item[options.recId]
                                if (typeof options.recId === 'function') item.id = options.recId(item)
                                if (typeof options.recText === 'string') item.text = item[options.recText]
                                if (typeof options.recText === 'function') item.text = options.recText(item)
                            })
                        }
                        // remember stats
                        remote.loading = false
                        remote.search = search
                        remote.total = data.records.length
                        remote.lastError = ''
                        remote.emptySet = (search === '' && data.records.length === 0 ? true : false)
                        // event after
                        edata.finish()
                        resolve(w2utils.normMenu(data.records))
                    })
                    .catch(error => {
                        let edata = this.trigger('error', { overlay, search, error })
                        if (edata.isCancelled === true) return
                        // default behavior
                        if (error?.name !== 'AbortError') {
                            console.error('ERROR: Server communication failed.', '\n',
                                ' - it should return', { records: [{ id: 1, text: 'item' }] }, '\n',
                                ' - or just an array ', [{ id: 1, text: 'item' }], '\n',
                                ' - or if errorr ', { error: true, message: 'error message' })
                        }
                        // reset stats
                        remote.loading = false
                        remote.search = ''
                        remote.total = -1
                        remote.emptySet = true
                        remote.lastError = (edata.detail.error || 'Server communication failed')
                        options.items = []
                        // event after
                        edata.finish()
                        reject()
                    })
                // event after
                edata.finish()
            }, debounce ? (options.debounce ?? 350) : 0)
        }
        return new Promise((res, rej) => {
            resolve = res
            reject = rej
        })
    }
    /**
     * Builds an array of item ids that sequencial in navigation with up/down keys.
     * Skips hidden and disabled items and goes into nested structures.
     */
    getActiveChain(name, items, parents = [], res = [], noSave) {
        let overlay = Tooltip.active[name.replace(/[\s\.#]/g, '_')]
        if (overlay.tmp.activeChain != null) {
            return overlay.tmp.activeChain
        }
        if (items == null) items = overlay.options.items
        items.forEach((item, ind) => {
            if (!item.hidden && !item.disabled && !item?.text?.startsWith('--')) {
                res.push(parents.concat([ind]).join('-'))
                if (Array.isArray(item.items) && item.items.length > 0 && item.expanded) {
                    parents.push(ind)
                    this.getActiveChain(name, item.items, parents, res, true)
                    parents.pop()
                }
            }
        })
        if (noSave == null) {
            overlay.tmp.activeChain = res
        }
        return res
    }
    menuDown(overlay, event, index, parentIndex) {
        let options = overlay.options
        let items   = options.items
        let icon    = query(event.delegate).find('.w2ui-icon')
        let menu    = query(event.target).closest('.w2ui-menu:not(.w2ui-sub-menu)')
        if (typeof parentIndex == 'string' && parentIndex !== '') {
            let ids = parentIndex.split('-')
            ids.forEach(id => {
                items = items[id].items
            })
        }
        let item = items[index]
        if (item.disabled) {
            return
        }
        let uncheck = (items, parent) => {
            items.forEach((other, ind) => {
                if (other.id == item.id) return
                if (other.group === item.group && other.checked) {
                    menu
                        .find(`.w2ui-menu-item[index="${(parent ? parent + '-' : '') + ind}"] .w2ui-icon`)
                        .removeClass('w2ui-icon-check')
                        .addClass('w2ui-icon-empty')
                    items[ind].checked = false
                }
                if (Array.isArray(other.items)) {
                    uncheck(other.items, ind)
                }
            })
        }
        if ((options.type === 'check' || options.type === 'radio') && item.group !== false
                    && !query(event.target).hasClass('remove')
                    && !query(event.target).closest('.w2ui-menu-item').hasClass('has-sub-menu')) {
            item.checked = options.type == 'radio' ? true : !item.checked
            if (item.checked) {
                if (options.type === 'radio') {
                    query(event.target).closest('.w2ui-menu').find('.w2ui-icon')
                        .removeClass('w2ui-icon-check')
                        .addClass('w2ui-icon-empty')
                }
                if (options.type === 'check' && item.group != null) {
                    uncheck(options.items)
                }
                icon.removeClass('w2ui-icon-empty').addClass('w2ui-icon-check')
            } else if (options.type === 'check') {
                icon.removeClass('w2ui-icon-check').addClass('w2ui-icon-empty')
            }
        }
        // highlight record
        if (!query(event.target).hasClass('remove')) {
            menu.find('.w2ui-menu-item').removeClass('w2ui-selected')
            query(event.delegate).addClass('w2ui-selected')
        }
    }
    menuClick(overlay, event, index, parentIndex) {
        let options  = overlay.options
        let items    = options.items
        let $item    = query(event.delegate).closest('.w2ui-menu-item')
        let keepOpen = options.hideOn.includes('select') ? false : true
        if (event.shiftKey || event.metaKey || event.ctrlKey) {
            keepOpen = true
        }
        if (typeof parentIndex == 'string' && parentIndex !== '') {
            let ids = parentIndex.split('-')
            ids.forEach(id => {
                items = items[id].items
            })
        } else {
            parentIndex = null
        }
        if (typeof items == 'function') {
            items = items({ overlay, index, parentIndex, event })
        }
        let item = items[index]
        if (item.disabled && !query(event.target).hasClass('remove')) {
            return
        }
        let edata
        if (query(event.target).hasClass('remove')) {
            edata = this.trigger('remove', { originalEvent: event, target: overlay.name,
                overlay, item, index, parentIndex, el: $item[0] })
            if (edata.isCancelled === true) {
                return
            }
            keepOpen = !options.hideOn.includes('item-remove')
            $item.remove()
        } else if ($item.hasClass('has-sub-menu')) {
            edata = this.trigger('subMenu', { originalEvent: event, target: overlay.name,
                overlay, item, index, parentIndex, el: $item[0] })
            if (edata.isCancelled === true) {
                return
            }
            keepOpen = true
            if ($item.hasClass('expanded')) {
                item.expanded = false
                $item.removeClass('expanded').addClass('collapsed')
                query($item.get(0).nextElementSibling).hide()
                overlay.selected = parseInt($item.attr('index'))
            } else {
                item.expanded = true
                $item.addClass('expanded').removeClass('collapsed')
                query($item.get(0).nextElementSibling).show()
                overlay.selected = parseInt($item.attr('index'))
            }
        } else {
            // find items that are selected
            let selected = this.findChecked(options.items)
            overlay.selected = parseInt($item.attr('index'))
            edata = this.trigger('select', { originalEvent: event, target: overlay.name,
                overlay, item, index, parentIndex, selected, keepOpen, el: $item[0] })
            if (edata.isCancelled === true) {
                return
            }
            if (item.keepOpen != null) {
                keepOpen = item.keepOpen
            }
            if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
                overlay.anchor.dataset.selected = item.id
                overlay.anchor.dataset.selectedIndex = overlay.selected
            }
        }
        if (!keepOpen) {
            this.hide(overlay.name)
        }
        // if (['INPUT', 'TEXTAREA'].includes(overlay.anchor.tagName)) {
        //     overlay.anchor.focus()
        // }
        // event after
        edata.finish()
    }
    findChecked(items) {
        let found = []
        items.forEach(item => {
            if (item.checked) found.push(item)
            if (Array.isArray(item.items)) {
                found = found.concat(this.findChecked(item.items))
            }
        })
        return found
    }
    keyUp(overlay, event) {
        let options = overlay.options
        let search = event.target.value
        let filter = true
        let refreshIndex = false
        switch (event.keyCode) {
            case 46:  // delete
            case 8: { // backspace
                // if search empty and delete is clicked, do not filter nor show overlay
                if (search === '' && !overlay.displayed) filter = false
                break
            }
            case 13: { // enter
                if (!overlay.displayed || !overlay.selected) return
                let { index, parents } = this.getCurrent(overlay.name)
                event.delegate = query(overlay.box).find('.w2ui-selected').get(0)
                // reset active chain for folders
                this.menuClick(overlay, event, parseInt(index), parents)
                filter = false
                break
            }
            case 27: { // escape
                filter = false
                if (overlay.displayed) {
                    this.hide(overlay.name)
                } else {
                    // clear selected
                    let el = overlay.anchor
                    if (['INPUT', 'TEXTAREA'].includes(el.tagName)) {
                        el.value = ''
                        delete el.dataset.selected
                        delete el.dataset.selectedIndex
                    }
                }
                break
            }
            case 37: { // left
                if (!overlay.displayed) return
                let { item, index, parents } = this.getCurrent(overlay.name)
                // collapse parent if any
                if (parents) {
                    item    = options.items[parents]
                    index   = parseInt(parents)
                    parents = ''
                    refreshIndex = true
                }
                if (Array.isArray(item?.items) && item.items.length > 0 && item.expanded) {
                    event.delegate = query(overlay.box).find(`.w2ui-menu-item[index="${index}"]`).get(0)
                    overlay.selected = index
                    this.menuClick(overlay, event, parseInt(index), parents)
                }
                filter = false
                break
            }
            case 39: { // right
                if (!overlay.displayed) return
                let { item, index, parents } = this.getCurrent(overlay.name)
                if (Array.isArray(item?.items) && item.items.length > 0 && !item.expanded) {
                    event.delegate = query(overlay.box).find('.w2ui-selected').get(0)
                    this.menuClick(overlay, event, parseInt(index), parents)
                }
                filter = false
                break
            }
            case 38: { // up
                if (!overlay.displayed) {
                    break
                }
                let chain = this.getActiveChain(overlay.name)
                if (overlay.selected == null || overlay.selected?.length == 0) {
                    overlay.selected = chain[chain.length-1]
                } else {
                    let ind = chain.indexOf(overlay.selected)
                    // selected not in chain of items
                    if (ind == -1) {
                        overlay.selected = chain[chain.length-1]
                    }
                    // not first item
                    if (ind > 0) {
                        overlay.selected = chain[ind - 1]
                    }
                }
                filter = false
                refreshIndex = true
                event.preventDefault()
                break
            }
            case 40: { // down
                if (!overlay.displayed) {
                    break
                }
                let chain = this.getActiveChain(overlay.name)
                if (overlay.selected == null || overlay.selected?.length == 0) {
                    overlay.selected = chain[0]
                } else {
                    let ind = chain.indexOf(overlay.selected)
                    // selected not in chain of items
                    if (ind == -1) {
                        overlay.selected = chain[0]
                    }
                    // not the last item
                    if (ind < chain.length - 1) {
                        overlay.selected = chain[ind + 1]
                    }
                }
                filter = false
                refreshIndex = true
                event.preventDefault()
                break
            }
        }
        // filter
        if (filter && overlay.displayed
                && ((options.filter && event._searchType == 'filter') || (options.search && event._searchType == 'search'))) {
            this.applyFilter(overlay.name, null, search, true)
                .then(data => {
                    overlay.tmp.searchCount = data.count
                    overlay.tmp.search = data.search
                    // if selected is not in searched items
                    if (data.count === 0 || !this.getActiveChain(overlay.name).includes(overlay.selected)) {
                        overlay.selected = null
                    }
                    this.refreshSearch(overlay.name)
                })
        }
        if (refreshIndex) {
            this.refreshIndex(overlay.name)
        }
    }
}
class DateTooltip extends Tooltip {
    constructor() {
        super()
        let td = new Date()
        this.daysCount = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
        this.today = td.getFullYear() + '/' + (Number(td.getMonth()) + 1) + '/' + td.getDate()
        this.defaults = w2utils.extend({}, this.defaults, {
            position      : 'top|bottom',
            class         : 'w2ui-calendar',
            type          : 'date', // can be date/time/datetime
            format        : '',
            value         : '', // initial date (in w2utils.settings format)
            start         : null,
            end           : null,
            blockDates    : [], // array of blocked dates
            blockWeekdays : [], // blocked weekdays 0 - sunday, 1 - monday, etc
            colored       : {}, // ex: { '3/13/2022': 'bg-color|text-color' }
            arrowSize     : 12,
            autoResize    : false,
            anchorClass   : 'w2ui-focus',
            autoShowOn    : 'focus',
            hideOn        : ['doc-click', 'focus-change'],
            onSelect      : null
        })
    }
    attach(anchor, text) {
        let options
        if (arguments.length == 1 && anchor.anchor) {
            options = anchor
            anchor = options.anchor
        } else if (arguments.length === 2 && text != null && typeof text === 'object') {
            options = text
            options.anchor = anchor
        }
        let prevHideOn = options.hideOn
        options = w2utils.extend({}, this.defaults, options || {})
        if (prevHideOn) {
            options.hideOn = prevHideOn
        }
        if (!options.format) {
            let df = w2utils.settings.dateFormat
            let tf = w2utils.settings.timeFormat
            if (options.type == 'date') {
                options.format = df
            } else if (options.type == 'time') {
                options.format = tf
            } else {
                options.format = df + '|' + tf
            }
        }
        let cal = options.type == 'time' ? this.getHourHTML(options) : this.getMonthHTML(options)
        options.style += '; padding: 0;'
        options.html = cal.html
        let ret = super.attach(options)
        let overlay = ret.overlay
        Object.assign(overlay.tmp, cal)
        overlay.on('show.attach', event => {
            let overlay = event.detail.overlay
            let anchor  = overlay.anchor
            let options = overlay.options
            if (['INPUT', 'TEXTAREA'].includes(anchor.tagName) && !options.value && anchor.value) {
                overlay.tmp.initValue = anchor.value
            }
            delete overlay.newValue
            delete overlay.newDate
        })
        overlay.on('show:after.attach', event => {
            if (ret.overlay?.box) {
                this.initControls(ret.overlay)
            }
        })
        overlay.on('update:after.attach', event => {
            if (ret.overlay?.box) {
                this.initControls(ret.overlay)
            }
        })
        overlay.on('hide.attach', event => {
            let overlay = event.detail.overlay
            let anchor  = overlay.anchor
            if (overlay.newValue != null) {
                if (overlay.newDate) {
                    overlay.newValue = overlay.newDate + ' ' + overlay.newValue
                }
                if (['INPUT', 'TEXTAREA'].includes(anchor.tagName) && anchor.value != overlay.newValue) {
                    anchor.value = overlay.newValue
                }
                let edata = this.trigger('select', { date: overlay.newValue, target: overlay.name, overlay })
                if (edata.isCancelled === true) return
                // event after
                edata.finish()
            }
        })
        ret.select = (callback) => {
            overlay.on('select.attach', (event) => { callback(event) })
            return ret
        }
        return ret
    }
    initControls(overlay) {
        let options = overlay.options
        let moveMonth = (inc) => {
            let { month, year } = overlay.tmp
            month += inc
            if (month > 12) {
                month = 1
                year++
            }
            if (month < 1 ) {
                month = 12
                year--
            }
            let cal = this.getMonthHTML(options, month, year)
            Object.assign(overlay.tmp, cal)
            query(overlay.box).find('.w2ui-overlay-body').html(cal.html)
            this.initControls(overlay)
        }
        let checkJump = (event, dblclick) => {
            query(event.target).parent().find('.w2ui-jump-month, .w2ui-jump-year')
                .removeClass('w2ui-selected')
            query(event.target).addClass('w2ui-selected')
            let dt = new Date()
            let { jumpMonth, jumpYear } = overlay.tmp
            if (dblclick) {
                if (jumpYear == null) jumpYear = dt.getFullYear()
                if (jumpMonth == null) jumpMonth = dt.getMonth() + 1
            }
            if (jumpMonth && jumpYear) {
                let cal = this.getMonthHTML(options, jumpMonth, jumpYear)
                Object.assign(overlay.tmp, cal)
                query(overlay.box).find('.w2ui-overlay-body').html(cal.html)
                overlay.tmp.jump = false
                this.initControls(overlay)
            }
        }
        // events for next/prev buttons and title
        query(overlay.box).find('.w2ui-cal-title')
            .off('.calendar')
            // click on title
            .on('click.calendar', event => {
                Object.assign(overlay.tmp, { jumpYear: null, jumpMonth: null })
                if (overlay.tmp.jump) {
                    let { month, year } = overlay.tmp
                    let cal = this.getMonthHTML(options, month, year)
                    query(overlay.box).find('.w2ui-overlay-body').html(cal.html)
                    overlay.tmp.jump = false
                } else {
                    query(overlay.box).find('.w2ui-overlay-body .w2ui-cal-days')
                        .replace(this.getYearHTML())
                    let el = query(overlay.box).find(`[name="${overlay.tmp.year}"]`).get(0)
                    if (el) el.scrollIntoView(true)
                    overlay.tmp.jump = true
                }
                this.initControls(overlay)
                event.stopPropagation()
            })
            // prev button
            .find('.w2ui-cal-previous')
            .off('.calendar')
            .on('click.calendar', event => {
                moveMonth(-1)
                event.stopPropagation()
            })
            .parent()
            // next button
            .find('.w2ui-cal-next')
            .off('.calendar')
            .on('click.calendar', event => {
                moveMonth(1)
                event.stopPropagation()
            })
        // now button
        query(overlay.box).find('.w2ui-cal-now')
            .off('.calendar')
            .on('click.calendar', event => {
                if (options.type == 'datetime') {
                    if (overlay.newDate) {
                        overlay.newValue = w2utils.formatTime(new Date(), options.format.split('|')[1])
                    } else {
                        overlay.newValue = w2utils.formatDateTime(new Date(), options.format)
                    }
                } else if (options.type == 'date') {
                    overlay.newValue = w2utils.formatDate(new Date(), options.format)
                } else if (options.type == 'time') {
                    overlay.newValue = w2utils.formatTime(new Date(), options.format)
                }
                this.hide(overlay.name)
            })
        // events for dates
        query(overlay.box)
            .off('.calendar')
            .on('click.calendar', { delegate: '.w2ui-day.w2ui-date' }, event => {
                if (options.type == 'datetime') {
                    overlay.newDate = query(event.target).attr('date')
                    query(overlay.box).find('.w2ui-overlay-body').html(this.getHourHTML(overlay.options).html)
                    this.initControls(overlay)
                } else {
                    overlay.newValue = query(event.target).attr('date')
                    this.hide(overlay.name)
                }
            })
            // click on month
            .on('click.calendar', { delegate: '.w2ui-jump-month' }, event => {
                overlay.tmp.jumpMonth = parseInt(query(event.target).attr('name'))
                checkJump(event)
            })
            // double click on month
            .on('dblclick.calendar', { delegate: '.w2ui-jump-month' }, event => {
                overlay.tmp.jumpMonth = parseInt(query(event.target).attr('name'))
                checkJump(event, true)
            })
            // click on year
            .on('click.calendar', { delegate: '.w2ui-jump-year' }, event => {
                overlay.tmp.jumpYear = parseInt(query(event.target).attr('name'))
                checkJump(event)
            })
            // dbl click on year
            .on('dblclick.calendar', { delegate: '.w2ui-jump-year' }, event => {
                overlay.tmp.jumpYear = parseInt(query(event.target).attr('name'))
                checkJump(event, true)
            })
            // click on hour
            .on('click.calendar', { delegate: '.w2ui-time.hour' }, event => {
                let hour = query(event.target).attr('hour')
                let min  = this.str2min(options.value) % 60
                if (overlay.tmp.initValue && !options.value) {
                    min = this.str2min(overlay.tmp.initValue) % 60
                }
                if (options.noMinutes) {
                    overlay.newValue = this.min2str(hour * 60, options.format)
                    this.hide(overlay.name)
                } else {
                    overlay.newValue = hour + ':' + min
                    let html = this.getMinHTML(hour, options).html
                    query(overlay.box).find('.w2ui-overlay-body').html(html)
                    this.initControls(overlay)
                }
            })
            // click on minute
            .on('click.calendar', { delegate: '.w2ui-time.min' }, event => {
                let hour = Math.floor(this.str2min(overlay.newValue) / 60)
                let time = (hour * 60) + parseInt(query(event.target).attr('min'))
                overlay.newValue = this.min2str(time, options.format)
                this.hide(overlay.name)
            })
    }
    getMonthHTML(options, month, year) {
        let days = w2utils.settings.fulldays.slice() // creates copy of the array
        let sdays = w2utils.settings.shortdays.slice() // creates copy of the array
        if (w2utils.settings.weekStarts !== 'M') {
            days.unshift(days.pop())
            sdays.unshift(sdays.pop())
        }
        let td = new Date()
        let dayLengthMil = 1000 * 60 * 60 * 24
        let selected = options.type === 'datetime'
            ? w2utils.isDateTime(options.value, options.format, true)
            : w2utils.isDate(options.value, options.format, true)
        let selected_dsp = w2utils.formatDate(selected)
        // normalize date
        if (month == null || year == null) {
            year  = selected ? selected.getFullYear() : td.getFullYear()
            month = selected ? selected.getMonth() + 1 : td.getMonth() + 1
        }
        if (month > 12) { month -= 12; year++ }
        if (month < 1 || month === 0) { month += 12; year-- }
        if (year/4 == Math.floor(year/4)) { this.daysCount[1] = 29 } else { this.daysCount[1] = 28 }
        options.current = month + '/' + year
        // start with the required date
        td = new Date(year, month-1, 1)
        let weekDay = td.getDay()
        let weekDays = ''
        let st = w2utils.settings.weekStarts
        for (let i = 0; i < sdays.length; i++) {
            let isSat = (st == 'M' && i == 5) || (st != 'M' && i == 6) ? true : false
            let isSun = (st == 'M' && i == 6) || (st != 'M' && i == 0) ? true : false
            weekDays += `<div class="w2ui-day w2ui-weekday ${isSat ? 'w2ui-sunday' : ''} ${isSun ? 'w2ui-saturday' : ''}">${sdays[i]}</div>`
        }
        let html = `
            <div class="w2ui-cal-title">
                <div class="w2ui-cal-previous">
                    <div></div>
                </div>
                <div class="w2ui-cal-next">
                    <div></div>
                </div>
                ${w2utils.settings.fullmonths[month-1]}, ${year}
                <span class="arrow-down"></span>
            </div>
            <div class="w2ui-cal-days">
                ${weekDays}
        `
        let DT = new Date(`${year}/${month}/1`) // first of month
        /**
         * Move to noon, instead of midnight. If not, then the date when time saving happens
         * will be duplicated in the calendar
         */
        DT = new Date(DT.getTime() + dayLengthMil * 0.5)
        let weekday = DT.getDay()
        if (w2utils.settings.weekStarts == 'M') weekDay--
        if (weekday > 0) {
            DT = new Date(DT.getTime() - (weekDay * dayLengthMil))
        }
        for (let ci = 0; ci < 42; ci++) {
            let className = []
            let dt = `${DT.getFullYear()}/${DT.getMonth()+1}/${DT.getDate()}`
            if (DT.getDay() === 6) className.push('w2ui-saturday')
            if (DT.getDay() === 0) className.push('w2ui-sunday')
            if (DT.getMonth() + 1 !== month) className.push('outside')
            if (dt == this.today) className.push('w2ui-today')
            let dspDay = DT.getDate()
            let col    = ''
            let bgcol  = ''
            let tmp_dt, tmp_dt_fmt
            if (options.type === 'datetime') {
                tmp_dt     = w2utils.formatDateTime(dt, options.format)
                tmp_dt_fmt = w2utils.formatDate(dt, w2utils.settings.dateFormat)
            } else {
                tmp_dt     = w2utils.formatDate(dt, options.format)
                tmp_dt_fmt = tmp_dt
            }
            if (options.colored && options.colored[tmp_dt_fmt] !== undefined) { // if there is predefined colors for dates
                let tmp = options.colored[tmp_dt_fmt].split('|')
                bgcol   = 'background-color: ' + tmp[0] + ';'
                col     = 'color: ' + tmp[1] + ';'
            }
            html += `<div class="w2ui-day ${this.inRange(tmp_dt, options, true)
                            ? 'w2ui-date ' + (tmp_dt_fmt == selected_dsp ? 'w2ui-selected' : '')
                            : 'w2ui-blocked'
                        } ${className.join(' ')}"
                       style="${col + bgcol}" date="${tmp_dt_fmt}" data-date="${DT.getTime()}">
                            ${dspDay}
                    </div>`
            DT = new Date(DT.getTime() + dayLengthMil)
        }
        html += '</div>'
        if (options.btnNow) {
            let label = w2utils.lang('Today' + (options.type == 'datetime' ? ' & Now' : ''))
            html += `<div class="w2ui-cal-now">${label}</div>`
        }
        return { html, month, year }
    }
    getYearHTML() {
        let mhtml = ''
        let yhtml = ''
        for (let m = 0; m < w2utils.settings.fullmonths.length; m++) {
            mhtml += `<div class="w2ui-jump-month" name="${m+1}">${w2utils.settings.shortmonths[m]}</div>`
        }
        for (let y = w2utils.settings.dateStartYear; y <= w2utils.settings.dateEndYear; y++) {
            yhtml += `<div class="w2ui-jump-year" name="${y}">${y}</div>`
        }
        return `<div class="w2ui-cal-jump">
            <div id="w2ui-jump-month">${mhtml}</div>
            <div id="w2ui-jump-year">${yhtml}</div>
        </div>`
    }
    getHourHTML(options) {
        options = options ?? {}
        if (!options.format) options.format = w2utils.settings.timeFormat
        let h24 = (options.format.indexOf('h24') > -1)
        let value = options.value ? options.value : (options.anchor ? options.anchor.value : '')
        let tmp = []
        for (let a = 0; a < 24; a++) {
            let time = (a >= 12 && !h24 ? a - 12 : a) + ':00' + (!h24 ? (a < 12 ? ' am' : ' pm') : '')
            if (a == 12 && !h24) time = '12:00 pm'
            if (!tmp[Math.floor(a/8)]) tmp[Math.floor(a/8)] = ''
            let tm1 = this.min2str(this.str2min(time))
            let tm2 = this.min2str(this.str2min(time) + 59)
            if (options.type === 'datetime') {
                let dt = w2utils.isDateTime(value, options.format, true)
                let fm = options.format.split('|')[0].trim()
                tm1    = w2utils.formatDate(dt, fm) + ' ' + tm1
                tm2    = w2utils.formatDate(dt, fm) + ' ' + tm2
            }
            let valid = this.inRange(tm1, options) || this.inRange(tm2, options)
            tmp[Math.floor(a/8)] += `<span hour="${a}"
                class="hour ${valid ? 'w2ui-time ' : 'w2ui-blocked'}">${time}</span>`
        }
        let html = `<div class="w2ui-calendar">
            <div class="w2ui-time-title">${w2utils.lang('Select Hour')}</div>
            <div class="w2ui-cal-time">
                <div class="w2ui-cal-column">${tmp[0]}</div>
                <div class="w2ui-cal-column">${tmp[1]}</div>
                <div class="w2ui-cal-column">${tmp[2]}</div>
            </div>
            ${options.btnNow ? `<div class="w2ui-cal-now">${w2utils.lang('Now')}</div>` : '' }
        </div>`
        return { html }
    }
    getMinHTML(hour, options) {
        if (hour == null) hour = 0
        options = options ?? {}
        if (!options.format) options.format = w2utils.settings.timeFormat
        let h24 = (options.format.indexOf('h24') > -1)
        let value = options.value ? options.value : (options.anchor ? options.anchor.value : '')
        let tmp = []
        for (let a = 0; a < 60; a += 5) {
            let time = (hour > 12 && !h24 ? hour - 12 : hour) + ':' + (a < 10 ? 0 : '') + a + ' ' + (!h24 ? (hour < 12 ? 'am' : 'pm') : '')
            let tm   = time
            let ind  = a < 20 ? 0 : (a < 40 ? 1 : 2)
            if (!tmp[ind]) tmp[ind] = ''
            if (options.type === 'datetime') {
                let dt = w2utils.isDateTime(value, options.format, true)
                let fm = options.format.split('|')[0].trim()
                tm = w2utils.formatDate(dt, fm) + ' ' + tm
            }
            tmp[ind] += `<span min="${a}" class="min ${(this.inRange(tm, options) ? 'w2ui-time ' : 'w2ui-blocked')}">${time}</span>`
        }
        let html = `<div class="w2ui-calendar">
            <div class="w2ui-time-title">${w2utils.lang('Select Minute')}</div>
            <div class="w2ui-cal-time">
                <div class="w2ui-cal-column">${tmp[0]}</div>
                <div class="w2ui-cal-column">${tmp[1]}</div>
                <div class="w2ui-cal-column">${tmp[2]}</div>
            </div>
            ${options.btnNow ? `<div class="w2ui-cal-now">${w2utils.lang('Now')}</div>` : '' }
        </div>`
        return { html }
    }
    // checks if date is in range (loost at start, end, blockDates, blockWeekdays)
    inRange(str, options, dateOnly) {
        let inRange = false
        if (options.type === 'date') {
            let dt = w2utils.isDate(str, options.format, true)
            if (dt) {
                // enable range
                if (options.start || options.end) {
                    let st      = (typeof options.start === 'string' ? options.start : query(options.start).val())
                    let en      = (typeof options.end === 'string' ? options.end : query(options.end).val())
                    let start   = w2utils.isDate(st, options.format, true)
                    let end     = w2utils.isDate(en, options.format, true)
                    let current = new Date(dt)
                    if (!start) start = current
                    if (!end) end = current
                    if (current >= start && current <= end) inRange = true
                } else {
                    inRange = true
                }
                // block predefined dates
                if (Array.isArray(options.blockDates) && options.blockDates.includes(str)) inRange = false
                // block weekdays
                if (Array.isArray(options.blockWeekdays) && options.blockWeekdays.includes(dt.getDay())) inRange = false
            }
        } else if (options.type === 'time') {
            if (options.start || options.end) {
                let tm  = this.str2min(str)
                let tm1 = this.str2min(options.start)
                let tm2 = this.str2min(options.end)
                if (!tm1) tm1 = tm
                if (!tm2) tm2 = tm
                if (tm >= tm1 && tm <= tm2) inRange = true
            } else {
                inRange = true
            }
        } else if (options.type === 'datetime') {
            let dt = w2utils.isDateTime(str, options.format, true)
            if (dt) {
                let format = options.format.split('|').map(format => format.trim())
                if (dateOnly) {
                    let date = w2utils.formatDate(dt, format[0])
                    let opts = w2utils.extend({}, options, { type: 'date', format: format[0] })
                    if (this.inRange(date, opts)) inRange = true
                } else {
                    let time = w2utils.formatTime(dt, format[1])
                    let opts =  { type: 'time', format: format[1], start: options.startTime, end: options.endTime }
                    if (this.inRange(time, opts)) inRange = true
                }
            }
        }
        return inRange
    }
    // converts time into number of minutes since midnight -- '11:50am' => 710
    str2min(str) {
        if (typeof str !== 'string') return null
        let tmp = str.split(':')
        if (tmp.length === 2) {
            tmp[0] = parseInt(tmp[0])
            tmp[1] = parseInt(tmp[1])
            if (str.indexOf('pm') !== -1 && tmp[0] !== 12) tmp[0] += 12
            if (str.includes('am') && tmp[0] == 12) tmp[0] = 0 // 12:00am - is midnight
        } else {
            return null
        }
        return tmp[0] * 60 + tmp[1]
    }
    // converts minutes since midnight into time str -- 710 => '11:50am'
    min2str(time, format) {
        let ret = ''
        if (time >= 24 * 60) time = time % (24 * 60)
        if (time < 0) time = 24 * 60 + time
        let hour = Math.floor(time/60)
        let min = ((time % 60) < 10 ? '0' : '') + (time % 60)
        if (!format) { format = w2utils.settings.timeFormat}
        if (format.indexOf('h24') !== -1) {
            ret = hour + ':' + min
        } else {
            ret = (hour <= 12 ? hour : hour - 12) + ':' + min + ' ' + (hour >= 12 ? 'pm' : 'am')
        }
        return ret
    }
}
let w2tooltip = new Tooltip()
let w2menu    = new MenuTooltip()
let w2color   = new ColorTooltip()
let w2date    = new DateTooltip()
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tooltip, w2color, w2menu
 *
 * == TODO ==
 *  - tab navigation (index state)
 *  - vertical toolbar
 *  - w2menu on second click of tb button should hide
 *  - button display groups for each show/hide, possibly add state: { single: t/f, multiple: t/f, type: 'font' }
 *  - item.count - should just support html, so a custom block can be created, such as a colored line
 *
 * == 2.0 changes
 *  - CSP - fixed inline events
 *  - removed jQuery dependency
 *  - item.icon - can be class or <custom-icon-component> or <svg>
 *  - new w2tooltips and w2menu
 *  - scroll returns promise
 *  - added onMouseEntter, onMouseLeave, onMouseDown, onMouseUp events
 *  - add(..., skipRefresh), insert(..., skipRefresh)
 */

class w2toolbar extends w2base {
    constructor(options) {
        super(options.name)
        this.box           = null // DOM Element that holds the element
        this.name          = null // unique name for w2ui
        this.routeData     = {} // data for dynamic routes
        this.items         = []
        this.right         = '' // HTML text on the right of toolbar
        this.tooltip       = 'top|left'// can be top, bottom, left, right
        this.onClick       = null
        this.onMouseDown   = null
        this.onMouseUp     = null
        this.onMouseEnter  = null // mouse enter the button event
        this.onMouseLeave  = null
        this.onRender      = null
        this.onRefresh     = null
        this.onResize      = null
        this.onDestroy     = null
        this.item_template = {
            id: null, // command to be sent to all event handlers
            type: 'button', // button, check, radio, drop, menu, menu-radio, menu-check, break, html, spacer
            text: null,
            html: '',
            tooltip: null,  // w2toolbar.tooltip should be
            count: null,
            hidden: false,
            disabled: false,
            checked: false, // used for radio buttons
            icon: null,
            route: null,    // if not null, it is route to go
            arrow: null,    // arrow down for drop/menu types
            style: null,    // extra css style for caption
            group: null,    // used for radio buttons
            items: null,    // for type menu* it is an array of items in the menu
            selected: null, // used for menu-check, menu-radio
            color: null,    // color value - used in color pickers
            overlay: {      // additional options for overlay
                anchorClass: ''
            },
            onClick: null,
            onRefresh: null
        }
        this.last = {
            badge: {}
        }
        // mix in options, w/o items
        let items = options.items
        delete options.items
        Object.assign(this, options)
        // add item via method to makes sure item_template is applied
        if (Array.isArray(items)) this.add(items, true)
        // need to reassign back to keep it in config
        options.items = items
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
    }
    add(items, skipRefresh) {
        this.insert(null, items, skipRefresh)
    }
    insert(id, items, skipRefresh) {
        if (!Array.isArray(items)) items = [items]
        items.forEach((item, idx, arr) => {
            if (typeof item === 'string') {
                item = arr[idx] = { id: item, text: item }
            }
            // checks
            let valid = ['button', 'check', 'radio', 'drop', 'menu', 'menu-radio', 'menu-check', 'color', 'text-color', 'html',
                'break', 'spacer', 'new-line']
            if (!valid.includes(String(item.type))) {
                console.log('ERROR: The parameter "type" should be one of the following:', valid, `, but ${item.type} is supplied.`, item)
                return
            }
            if (item.id == null && !['break', 'spacer', 'new-line'].includes(item.type)) {
                console.log('ERROR: The parameter "id" is required but not supplied.', item)
                return
            }
            if (item.type == null) {
                console.log('ERROR: The parameter "type" is required but not supplied.', item)
                return
            }
            if (!w2utils.checkUniqueId(item.id, this.items, 'toolbar', this.name)) return
            // add item
            let newItem = w2utils.extend({}, this.item_template, item)
            if (newItem.type == 'menu-check') {
                if (!Array.isArray(newItem.selected)) newItem.selected = []
                if (Array.isArray(newItem.items)) {
                    newItem.items.forEach(it => {
                        if (typeof it === 'string') {
                            it = arr[idx] = { id: it, text: it }
                        }
                        if (it.checked && !newItem.selected.includes(it.id)) newItem.selected.push(it.id)
                        if (!it.checked && newItem.selected.includes(it.id)) it.checked = true
                        if (it.checked == null) it.checked = false
                    })
                }
            } else if (newItem.type == 'menu-radio') {
                if (Array.isArray(newItem.items)) {
                    newItem.items.forEach((it, idx, arr) => {
                        if (typeof it === 'string') {
                            it = arr[idx] = { id: it, text: it }
                        }
                        if (it.checked && newItem.selected == null) newItem.selected = it.id; else it.checked = false
                        if (!it.checked && newItem.selected == it.id) it.checked = true
                        if (it.checked == null) it.checked = false
                    })
                }
            }
            if (id == null) {
                this.items.push(newItem)
            } else {
                let middle = this.get(id, true)
                this.items = this.items.slice(0, middle).concat([newItem], this.items.slice(middle))
            }
            newItem.line = newItem.line ?? 1
            if (skipRefresh !== true) this.refresh(newItem.id)
        })
        if (skipRefresh !== true) this.resize()
    }
    remove() {
        let effected = 0
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it || String(item).indexOf(':') != -1) return
            effected++
            // remove from screen
            query(this.box).find('#tb_'+ this.name +'_item_'+ w2utils.escapeId(it.id)).remove()
            // remove from array
            let ind = this.get(it.id, true)
            if (ind != null) this.items.splice(ind, 1)
        })
        this.resize()
        return effected
    }
    set(id, newOptions) {
        let item = this.get(id)
        if (item == null) return false
        Object.assign(item, newOptions)
        this.refresh(String(id).split(':')[0])
        return true
    }
    get(id, returnIndex) {
        if (arguments.length === 0) {
            let all = []
            for (let i1 = 0; i1 < this.items.length; i1++) if (this.items[i1].id != null) all.push(this.items[i1].id)
            return all
        }
        let tmp = String(id).split(':')
        for (let i2 = 0; i2 < this.items.length; i2++) {
            let it = this.items[i2]
            // find a menu item
            if (['menu', 'menu-radio', 'menu-check'].includes(it.type) && tmp.length == 2 && it.id == tmp[0]) {
                let subItems = it.items
                if (typeof subItems == 'function') subItems = subItems(this)
                for (let i = 0; i < subItems.length; i++) {
                    let item = subItems[i]
                    if (item.id == tmp[1] || (item.id == null && item.text == tmp[1])) {
                        if (returnIndex == true) return i; else return item
                    }
                    if (Array.isArray(item.items)) {
                        for (let j = 0; j < item.items.length; j++) {
                            if (item.items[j].id == tmp[1] || (item.items[j].id == null && item.items[j].text == tmp[1])) {
                                if (returnIndex == true) return i; else return item.items[j]
                            }
                        }
                    }
                }
            } else if (it.id == tmp[0]) {
                if (returnIndex == true) return i2; else return it
            }
        }
        return null
    }
    setCount(id, count, className, style) {
        let btn = query(this.box).find(`#tb_${this.name}_item_${w2utils.escapeId(id)} .w2ui-tb-count > span`)
        if (btn.length > 0) {
            btn.removeClass()
                .addClass(className ?? '')
                .text(count)
                .get(0).style.cssText = style ?? ''
            this.last.badge[id] = {
                className: className ?? '',
                style: style ?? ''
            }
            let item = this.get(id)
            item.count = count
        } else {
            this.set(id, { count: count })
            this.setCount(...arguments) // to update styles
        }
    }
    show() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it) return
            it.hidden = false
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it); this.resize() }) }, 15) // needs timeout
        return effected
    }
    hide() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it) return
            it.hidden = true
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it); this.tooltipHide(it); this.resize() }) }, 15) // needs timeout
        return effected
    }
    enable() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it) return
            it.disabled = false
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it) }) }, 15) // needs timeout
        return effected
    }
    disable() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it) return
            it.disabled = true
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it); this.tooltipHide(it) }) }, 15) // needs timeout
        return effected
    }
    check() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it || String(item).indexOf(':') != -1) return
            it.checked = true
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it) }) }, 15) // needs timeout
        return effected
    }
    uncheck() {
        let effected = []
        Array.from(arguments).forEach(item => {
            let it = this.get(item)
            if (!it || String(item).indexOf(':') != -1) return
            // remove overlay
            if (['menu', 'menu-radio', 'menu-check', 'drop', 'color', 'text-color'].includes(it.type) && it.checked) {
                w2tooltip.hide(this.name + '-drop')
            }
            it.checked = false
            effected.push(String(item).split(':')[0])
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it) }) }, 15) // needs timeout
        return effected
    }
    click(id, event) {
        // click on menu items
        let tmp   = String(id).split(':')
        let it    = this.get(tmp[0])
        let items = (it && it.items ? w2utils.normMenu.call(this, it.items, it) : [])
        if (tmp.length > 1) {
            let subItem = this.get(id)
            if (subItem && !subItem.disabled) {
                this.menuClick({ name: this.name, item: it, subItem: subItem, originalEvent: event })
            }
            return
        }
        if (it && !it.disabled) {
            // event before
            let edata = this.trigger('click', {
                target: (id != null ? id : this.name),
                item: it, object: it, originalEvent: event
            })
            if (edata.isCancelled === true) return
            // read items again, they might have been changed in the click event handler
            items = (it && it.items ? w2utils.normMenu.call(this, it.items, it) : [])
            let btn = '#tb_'+ this.name +'_item_'+ w2utils.escapeId(it.id)
            query(this.box).find(btn).removeClass('down') // need to re-query at the moment -- as well as elsewhere in this function
            if (it.type == 'radio') {
                for (let i = 0; i < this.items.length; i++) {
                    let itt = this.items[i]
                    if (itt == null || itt.id == it.id || itt.type !== 'radio') continue
                    if (itt.group == it.group && itt.checked) {
                        itt.checked = false
                        this.refresh(itt.id)
                    }
                }
                it.checked = true
                query(this.box).find(btn).addClass('checked')
            }
            if (['menu', 'menu-radio', 'menu-check', 'drop', 'color', 'text-color'].includes(it.type)) {
                this.tooltipHide(id)
                if (it.checked) {
                    w2tooltip.hide(this.name + '-drop')
                    return
                } else {
                    // timeout is needed to make sure previous overlay hides
                    setTimeout(() => {
                        let hideDrop = (id, btn) => {
                            // need a closure to capture id variable
                            let self = this
                            return function () {
                                self.set(id, { checked: false })
                            }
                        }
                        let el = query(this.box).find('#tb_'+ this.name +'_item_'+ w2utils.escapeId(it.id))
                        if (!w2utils.isPlainObject(it.overlay)) it.overlay = {}
                        if (it.type == 'drop') {
                            w2tooltip.show(w2utils.extend({
                                html: it.html,
                                class: 'w2ui-white',
                                hideOn: ['doc-click']
                            }, it.overlay, {
                                anchor: el[0],
                                name: this.name + '-drop',
                                data: { item: it, btn }
                            }))
                            .hide(hideDrop(it.id, btn))
                        }
                        if (['menu', 'menu-radio', 'menu-check'].includes(it.type)) {
                            let menuType = 'normal'
                            if (it.type == 'menu-radio') {
                                menuType = 'radio'
                                items.forEach((item) => {
                                    if (it.selected == item.id) item.checked = true; else item.checked = false
                                })
                            }
                            if (it.type == 'menu-check') {
                                menuType = 'check'
                                items.forEach((item) => {
                                    if (Array.isArray(it.selected) && it.selected.includes(item.id)) item.checked = true; else item.checked = false
                                })
                            }
                            w2menu.show(w2utils.extend({
                                items,
                            }, it.overlay, {
                                type: menuType,
                                name : this.name + '-drop',
                                anchor: el[0],
                                data: { item: it, btn }
                            }))
                                .hide(hideDrop(it.id, btn))
                                .remove(event => {
                                    this.menuClick({ name: this.name, remove: true, item: it, subItem: event.detail.item,
                                        originalEvent: event })
                                })
                                .select(event => {
                                    this.menuClick({ name: this.name, item: it, subItem: event.detail.item,
                                        originalEvent: event })
                                })
                        }
                        if (['color', 'text-color'].includes(it.type)) {
                            w2color.show(w2utils.extend({
                                color: it.color
                            }, it.overlay, {
                                anchor: el[0],
                                name: this.name + '-drop',
                                data: { item: it, btn }
                            }))
                                .hide(hideDrop(it.id, btn))
                                .select(event => {
                                    if (event.detail.color != null) {
                                        this.colorClick({ name: this.name, item: it, color: event.detail.color })
                                    }
                                })
                        }
                    }, 0)
                }
            }
            if (['check', 'menu', 'menu-radio', 'menu-check', 'drop', 'color', 'text-color'].includes(it.type)) {
                it.checked = !it.checked
                if (it.checked) {
                    query(this.box).find(btn).addClass('checked')
                } else {
                    query(this.box).find(btn).removeClass('checked')
                }
            }
            // route processing
            if (it.route) {
                let route = String('/'+ it.route).replace(/\/{2,}/g, '/')
                let info  = w2utils.parseRoute(route)
                if (info.keys.length > 0) {
                    for (let k = 0; k < info.keys.length; k++) {
                        route = route.replace((new RegExp(':'+ info.keys[k].name, 'g')), this.routeData[info.keys[k].name])
                    }
                }
                setTimeout(() => { window.location.hash = route }, 1)
            }
            // need to refresh toolbar as it might be dynamic
            this.tooltipShow(id)
            // event after
            edata.finish()
        }
    }
    scroll(direction, line, instant) {
        return new Promise((resolve, reject) => {
            let scrollBox  = query(this.box).find(`.w2ui-tb-line:nth-child(${line}) .w2ui-scroll-wrapper`)
            let scrollLeft = scrollBox.get(0).scrollLeft
            let right      = scrollBox.find('.w2ui-tb-right').get(0)
            let width1     = scrollBox.parent().get(0).getBoundingClientRect().width
            let width2     = scrollLeft + parseInt(right.offsetLeft) + parseInt(right.clientWidth )
            switch (direction) {
                case 'left': {
                    scroll = scrollLeft - width1 + 50 // 35 is width of both button
                    if (scroll <= 0) scroll = 0
                    scrollBox.get(0).scrollTo({ top: 0, left: scroll, behavior: instant ? 'atuo' : 'smooth' })
                    break
                }
                case 'right': {
                    scroll = scrollLeft + width1 - 50 // 35 is width of both button
                    if (scroll >= width2 - width1) scroll = width2 - width1
                    scrollBox.get(0).scrollTo({ top: 0, left: scroll, behavior: instant ? 'atuo' : 'smooth' })
                    break
                }
            }
            setTimeout(() => { this.resize(); resolve() }, instant ? 0 : 500)
        })
    }
    render(box) {
        let time = Date.now()
        if (typeof box == 'string') box = query(box).get(0)
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // defaul action
        if (box != null) {
            // clean previous box
            if (query(this.box).find('.w2ui-scroll-wrapper .w2ui-tb-right').length > 0) {
                query(this.box)
                    .removeAttr('name')
                    .removeClass('w2ui-reset w2ui-toolbar')
                    .html('')
            }
            this.box = box
        }
        if (!this.box) return
        if (!Array.isArray(this.right)) {
            this.right = [this.right]
        }
        // render all buttons
        let html = ''
        let line = 0
        for (let i = 0; i < this.items.length; i++) {
            let it = this.items[i]
            if (it == null) continue
            if (it.id == null) it.id = 'item_' + i
            if (it.caption != null) {
                console.log('NOTICE: toolbar item.caption property is deprecated, please use item.text. Item -> ', it)
            }
            if (it.hint != null) {
                console.log('NOTICE: toolbar item.hint property is deprecated, please use item.tooltip. Item -> ', it)
            }
            if (i === 0 || it.type == 'new-line') {
                line++
                html += `
                    <div class="w2ui-tb-line">
                        <div class="w2ui-scroll-wrapper w2ui-eaction" data-mousedown="resize">
                            <div class="w2ui-tb-right">${this.right[line-1] ?? ''}</div>
                        </div>
                        <div class="w2ui-scroll-left w2ui-eaction" data-click='["scroll", "left", "${line}"]'></div>
                        <div class="w2ui-scroll-right w2ui-eaction" data-click='["scroll", "right", "${line}"]'></div>
                    </div>
                `
            }
            it.line = line
        }
        query(this.box)
            .attr('name', this.name)
            .addClass('w2ui-reset w2ui-toolbar')
            .html(html)
        if (query(this.box).length > 0) {
            query(this.box)[0].style.cssText += this.style
        }
        w2utils.bindEvents(query(this.box).find('.w2ui-tb-line .w2ui-eaction'), this)
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        // refresh all
        this.refresh()
        this.resize()
        // event after
        edata.finish()
        return Date.now() - time
    }
    refresh(id) {
        let time = Date.now()
        // event before
        let edata = this.trigger('refresh', { target: (id != null ? id : this.name), item: this.get(id) })
        if (edata.isCancelled === true) return
        let edata2
        // refresh all
        if (id == null) {
            for (let i = 0; i < this.items.length; i++) {
                let it1 = this.items[i]
                if (it1.id == null) it1.id = 'item_' + i
                this.refresh(it1.id)
            }
            return
        }
        // create or refresh only one item
        let it = this.get(id)
        if (it == null) return false
        if (typeof it.onRefresh == 'function') {
            edata2 = this.trigger('refresh', { target: id, item: it, object: it })
            if (edata2.isCancelled === true) return
        }
        let selector = `#tb_${this.name}_item_${w2utils.escapeId(it.id)}`
        let btn  = query(this.box).find(selector)
        let html = this.getItemHTML(it)
        // hide tooltip
        this.tooltipHide(id)
        // if there is a spacer, then right HTML is not 100%
        if (it.type == 'spacer') {
            query(this.box).find(`.w2ui-tb-line:nth-child(${it.line}`).find('.w2ui-tb-right').css('width', 'auto')
        }
        if (btn.length === 0) {
            let next = parseInt(this.get(id, true)) + 1
            let $next = query(this.box).find(`#tb_${this.name}_item_${w2utils.escapeId(this.items[next] ? this.items[next].id : '')}`)
            if ($next.length == 0) {
                $next = query(this.box).find(`.w2ui-tb-line:nth-child(${it.line}`).find('.w2ui-tb-right').before(html)
            } else {
                $next.after(html)
            }
            w2utils.bindEvents(query(this.box).find(selector), this)
        } else {
            // refresh
            query(this.box).find(selector).replace(query.html(html))
            let newBtn = query(this.box).find(selector).get(0)
            w2utils.bindEvents(newBtn, this)
            // update overlay's anchor if changed
            let overlays = w2tooltip.get(true)
            Object.keys(overlays).forEach(key => {
                if (overlays[key].anchor == btn.get(0)) {
                    overlays[key].anchor = newBtn
                }
            })
        }
        if (['menu', 'menu-radio', 'menu-check'].includes(it.type) && it.checked) {
            // check selected items
            let selected = Array.isArray(it.selected) ? it.selected : [it.selected]
            it.items.forEach((item) => {
                if (selected.includes(item.id)) item.checked = true; else item.checked = false
            })
            w2menu.update(this.name + '-drop', it.items)
        }
        // event after
        if (typeof it.onRefresh == 'function') {
            edata2.finish()
        }
        edata.finish()
        return Date.now() - time
    }
    resize() {
        let time = Date.now()
        // event before
        let edata = this.trigger('resize', { target: this.name })
        if (edata.isCancelled === true) return
        query(this.box).find('.w2ui-tb-line').each(el => {
            // show hide overflow buttons
            let box = query(el)
            box.find('.w2ui-scroll-left, .w2ui-scroll-right').hide()
            let scrollBox  = box.find('.w2ui-scroll-wrapper').get(0)
            let $right     = box.find('.w2ui-tb-right')
            let boxWidth   = box.get(0).getBoundingClientRect().width
            let itemsWidth = ($right.length > 0 ? $right[0].offsetLeft + $right[0].clientWidth : 0)
            if (boxWidth < itemsWidth) {
                // we have overflown content
                if (scrollBox.scrollLeft > 0) {
                    box.find('.w2ui-scroll-left').show()
                }
                if (boxWidth < itemsWidth - scrollBox.scrollLeft) {
                    box.find('.w2ui-scroll-right').show()
                }
            }
        })
        // event after
        edata.finish()
        return Date.now() - time
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        // clean up
        if (query(this.box).find('.w2ui-scroll-wrapper  .w2ui-tb-right').length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-reset w2ui-toolbar')
                .html('')
        }
        query(this.box).html('')
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
    }
    // ========================================
    // --- Internal Functions
    getItemHTML(item) {
        let html = ''
        if (item.caption != null && item.text == null) item.text = item.caption // for backward compatibility
        if (item.text == null) item.text = ''
        if (item.tooltip == null && item.hint != null) item.tooltip = item.hint // for backward compatibility
        if (item.tooltip == null) item.tooltip = ''
        if (typeof item.get !== 'function' && (Array.isArray(item.items) || typeof item.items == 'function')) {
            item.get = function get(id) { // need scope, cannot be arrow func
                let tmp = item.items
                if (typeof tmp == 'function') tmp = item.items(item)
                return tmp.find(it => it.id == id ? true : false)
            }
        }
        let icon = ''
        let text = (typeof item.text == 'function' ? item.text.call(this, item) : item.text)
        if (item.icon) {
            icon = item.icon
            if (typeof item.icon == 'function') {
                icon = item.icon.call(this, item)
            }
            if (String(icon).slice(0, 1) !== '<') {
                icon = `<span class="${icon}"></span>`
            }
            icon = `<div class="w2ui-tb-icon">${icon}</div>`
        }
        let classes = ['w2ui-tb-button']
        if (item.checked) classes.push('checked')
        if (item.disabled) classes.push('disabled')
        if (item.hidden) classes.push('hidden')
        if (!icon) classes.push('no-icon')
        switch (item.type) {
            case 'color':
            case 'text-color':
                if (typeof item.color == 'string') {
                    if (item.color.slice(0, 1) == '#') item.color = item.color.slice(1)
                    if ([3, 6, 8].includes(item.color.length)) item.color = '#' + item.color
                }
                if (item.type == 'color') {
                    text = `<span class="w2ui-tb-color-box" style="background-color: ${(item.color != null ? item.color : '#fff')}"></span>
                           ${(item.text ? `<div style="margin-left: 17px;">${w2utils.lang(item.text)}</div>` : '')}`
                }
                if (item.type == 'text-color') {
                    text = '<span style="color: '+ (item.color != null ? item.color : '#444') +';">'+
                                (item.text ? w2utils.lang(item.text) : '<b>Aa</b>') +
                           '</span>'
                }
            case 'menu':
            case 'menu-check':
            case 'menu-radio':
            case 'button':
            case 'check':
            case 'radio':
            case 'drop': {
                let arrow = (item.arrow === true
                    || (item.arrow !== false && ['menu', 'menu-radio', 'menu-check', 'drop', 'color', 'text-color'].includes(item.type)))
                html = `
                    <div id="tb_${this.name}_item_${item.id}" style="${(item.hidden ? 'display: none' : '')}"
                        class="${classes.join(' ')} ${(item.class ? item.class : '')}"
                        ${!item.disabled
                            ? `data-click='["click","${item.id}"]'
                               data-mouseenter='["mouseAction", "event", "this", "Enter", "${item.id}"]'
                               data-mouseleave='["mouseAction", "event", "this", "Leave", "${item.id}"]'
                               data-mousedown='["mouseAction", "event", "this", "Down", "${item.id}"]'
                               data-mouseup='["mouseAction", "event", "this", "Up", "${item.id}"]'`
                            : ''}
                    >
                        ${ icon }
                        ${ text != ''
                            ? `<div class="w2ui-tb-text" style="${(item.style ? item.style : '')}">
                                    ${ w2utils.lang(text) }
                                    ${ item.count != null
                                        ? w2utils.stripSpaces(`<span class="w2ui-tb-count">
                                                <span class="${this.last.badge[item.id] ? this.last.badge[item.id].className ?? '' : ''}"
                                                    style="${this.last.badge[item.id] ? this.last.badge[item.id].style ?? '' : ''}"
                                                >${item.count}</span>
                                           </span>`)
                                        : ''
                                    }
                                    ${ arrow
                                        ? '<span class="w2ui-tb-down"><span></span></span>'
                                        : ''
                                    }
                                </div>`
                            : ''}
                    </div>
                `
                break
            }
            case 'break':
                html = `<div id="tb_${this.name}_item_${item.id}" class="w2ui-tb-break"
                            style="${(item.hidden ? 'display: none' : '')}; ${(item.style ? item.style : '')}">
                            &#160;
                        </div>`
                break
            case 'spacer':
                html = `<div id="tb_${this.name}_item_${item.id}" class="w2ui-tb-spacer"
                            style="${(item.hidden ? 'display: none' : '')}; ${(item.style ? item.style : '')}">
                        </div>`
                break
            case 'html':
                html = `<div id="tb_${this.name}_item_${item.id}" class="w2ui-tb-html ${classes.join(' ')}"
                            style="${(item.hidden ? 'display: none' : '')}; ${(item.style ? item.style : '')}">
                            ${(typeof item.html == 'function' ? item.html.call(this, item) : item.html)}
                        </div>`
                break
        }
        return html
    }
    tooltipShow(id) {
        if (this.tooltip == null) return
        let el   = query(this.box).find('#tb_'+ this.name + '_item_'+ w2utils.escapeId(id)).get(0)
        let item = this.get(id)
        let pos  = this.tooltip
        let txt  = item.tooltip
        if (typeof txt == 'function') txt = txt.call(this, item)
        // not for opened drop downs
        if (['menu', 'menu-radio', 'menu-check', 'drop', 'color', 'text-color'].includes(item.type)
            && item.checked == true) {
            return
        }
        w2tooltip.show({
            anchor: el,
            name: this.name + '-tooltip',
            html: txt,
            position: pos
        })
        return
    }
    tooltipHide(id) {
        if (this.tooltip == null) return
        w2tooltip.hide(this.name + '-tooltip')
    }
    menuClick(event) {
        if (event.item && !event.item.disabled) {
            // event before
            let edata = this.trigger((event.remove !== true ? 'click' : 'remove'), {
                target: event.item.id + ':' + event.subItem.id, item: event.item,
                subItem: event.subItem, originalEvent: event.originalEvent
            })
            if (edata.isCancelled === true) return
            // route processing
            let it    = event.subItem
            let item  = this.get(event.item.id)
            let items = item.items
            if (typeof items == 'function') items = item.items()
            if (item.type == 'menu') {
                item.selected = it.id
            }
            if (item.type == 'menu-radio') {
                item.selected = it.id
                if (Array.isArray(items)) {
                    items.forEach((item) => {
                        if (item.checked === true) delete item.checked
                        if (Array.isArray(item.items)) {
                            item.items.forEach((item) => {
                                if (item.checked === true) delete item.checked
                            })
                        }
                    })
                }
                it.checked = true
            }
            if (item.type == 'menu-check') {
                if (!Array.isArray(item.selected)) item.selected = []
                if (it.group == null) {
                    let ind = item.selected.indexOf(it.id)
                    if (ind == -1) {
                        item.selected.push(it.id)
                        it.checked = true
                    } else {
                        item.selected.splice(ind, 1)
                        it.checked = false
                    }
                } else if (it.group === false) {
                    // if group is false, then it is not part of checkboxes
                } else {
                    let unchecked = []
                    let ind = item.selected.indexOf(it.id)
                    let checkNested = (items) => {
                        items.forEach((sub) => {
                            if (sub.group === it.group) {
                                let ind = item.selected.indexOf(sub.id)
                                if (ind != -1) {
                                    if (sub.id != it.id) unchecked.push(sub.id)
                                    item.selected.splice(ind, 1)
                                }
                            }
                            if (Array.isArray(sub.items)) checkNested(sub.items)
                        })
                    }
                    checkNested(items)
                    if (ind == -1) {
                        item.selected.push(it.id)
                        it.checked = true
                    }
                }
            }
            if (typeof it.route == 'string') {
                let route = it.route !== '' ? String('/'+ it.route).replace(/\/{2,}/g, '/') : ''
                let info  = w2utils.parseRoute(route)
                if (info.keys.length > 0) {
                    for (let k = 0; k < info.keys.length; k++) {
                        if (this.routeData[info.keys[k].name] == null) continue
                        route = route.replace((new RegExp(':'+ info.keys[k].name, 'g')), this.routeData[info.keys[k].name])
                    }
                }
                setTimeout(() => { window.location.hash = route }, 1)
            }
            this.refresh(event.item.id)
            // event after
            edata.finish()
        }
    }
    colorClick(event) {
        let obj = this
        if (event.item && !event.item.disabled) {
            // event before
            let edata = this.trigger('click', {
                target: event.item.id, item: event.item,
                color: event.color, final: event.final, originalEvent: event.originalEvent
            })
            if (edata.isCancelled === true) return
            // default behavior
            event.item.color = event.color
            obj.refresh(event.item.id)
            // event after
            edata.finish()
        }
    }
    mouseAction(event, target, action, id) {
        let btn = this.get(id)
        let edata = this.trigger('mouse' + action, { target: id, item: btn, object: btn, originalEvent: event })
        if (edata.isCancelled === true || btn.disabled || btn.hidden) return
        switch (action) {
            case 'Enter':
                query(target).addClass('over')
                this.tooltipShow(id)
                break
            case 'Leave':
                query(target).removeClass('over down')
                this.tooltipHide(id)
                break
            case 'Down':
                query(target).addClass('down')
                break
            case 'Up':
                query(target).removeClass('down')
                break
        }
        edata.finish()
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tooltip, w2menu
 *
 * == TODO ==
 *  - dbl click should be like it is in grid (with timer not HTML dbl click event)
 *  - node.style is misleading - should be there to apply color for example
 *  - node.plus - is not working
 *
 * == 2.0 changes
 *  - remove jQuery dependency
 *  - deprecarted obj.img, node.img
 *  - CSP - fixed inline events
 *  - observeResize for the box
 *  - handleTooltip and handle.tooltip - text/function
 *  - added onMouseEntter, onMouseLeave events
 */

class w2sidebar extends w2base {
    constructor(options) {
        super(options.name)
        this.name          = null
        this.box           = null
        this.sidebar       = null
        this.parent        = null
        this.nodes         = [] // Sidebar child nodes
        this.menu          = []
        this.routeData     = {} // data for dynamic routes
        this.selected      = null // current selected node (readonly)
        this.icon          = null
        this.style         = ''
        this.topHTML       = ''
        this.bottomHTML    = ''
        this.flatButton    = false
        this.keyboard      = true
        this.flat          = false
        this.hasFocus      = false
        this.levelPadding  = 12
        this.skipRefresh   = false
        this.tabIndex      = null // will only be set if > 0 and not null
        this.handle        = { size: 0, style: '', html: '', tooltip: '' },
        this.onClick       = null // Fire when user click on Node Text
        this.onDblClick    = null // Fire when user dbl clicks
        this.onMouseEnter  = null // mouse enter/leave over an item
        this.onMouseLeave  = null
        this.onContextMenu = null
        this.onMenuClick   = null // when context menu item selected
        this.onExpand      = null // Fire when node expands
        this.onCollapse    = null // Fire when node collapses
        this.onKeydown     = null
        this.onRender      = null
        this.onRefresh     = null
        this.onResize      = null
        this.onDestroy     = null
        this.onFocus       = null
        this.onBlur        = null
        this.onFlat        = null
        this.node_template = {
            id: null,
            text: '',
            order: null,
            count: null,
            icon: null,
            nodes: [],
            style: '', // additional style for subitems
            route: null,
            selected: false,
            expanded: false,
            hidden: false,
            disabled: false,
            group: false, // if true, it will build as a group
            groupShowHide: true,
            collapsible: false,
            plus: false, // if true, plus will be shown even if there is no sub nodes
            // events
            onClick: null,
            onDblClick: null,
            onContextMenu: null,
            onExpand: null,
            onCollapse: null,
            // internal
            parent: null, // node object
            sidebar: null
        }
        this.last = {
            badge: {}
        }
        let nodes = options.nodes
        delete options.nodes
        // mix in options
        Object.assign(this, options)
        // add item via method to makes sure item_template is applied
        if (Array.isArray(nodes)) this.add(nodes)
        // need to reassign back to keep it in config
        options.nodes = nodes
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
    }
    add(parent, nodes) {
        if (arguments.length == 1) {
            // need to be in reverse order
            nodes  = arguments[0]
            parent = this
        }
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent == null || parent == '') parent = this
        return this.insert(parent, null, nodes)
    }
    insert(parent, before, nodes) {
        let txt, ind, tmp, node, nd
        if (arguments.length == 2 && typeof parent == 'string') {
            // need to be in reverse order
            nodes  = arguments[1]
            before = arguments[0]
            if (before != null) {
                ind = this.get(before)
                if (ind == null) {
                    if (!Array.isArray(nodes)) nodes = [nodes]
                    if (nodes[0].caption != null && nodes[0].text == null) {
                        console.log('NOTICE: sidebar node.caption property is deprecated, please use node.text. Node -> ', nodes[0])
                        nodes[0].text = nodes[0].caption
                    }
                    txt = nodes[0].text
                    console.log('ERROR: Cannot insert node "'+ txt +'" because cannot find node "'+ before +'" to insert before.')
                    return null
                }
                parent = this.get(before).parent
            } else {
                parent = this
            }
        }
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent == null || parent == '') parent = this
        if (!Array.isArray(nodes)) nodes = [nodes]
        for (let o = 0; o < nodes.length; o++) {
            node = nodes[o]
            if (node.caption != null && node.text == null) {
                console.log('NOTICE: sidebar node.caption property is deprecated, please use node.text')
                node.text = node.caption
            }
            if (typeof node.id == null) {
                txt = node.text
                console.log('ERROR: Cannot insert node "'+ txt +'" because it has no id.')
                continue
            }
            if (this.get(this, node.id) != null) {
                console.log('ERROR: Cannot insert node with id='+ node.id +' (text: '+ node.text + ') because another node with the same id already exists.')
                continue
            }
            tmp         = Object.assign({}, this.node_template, node)
            tmp.sidebar = this
            tmp.parent  = parent
            nd          = tmp.nodes || []
            tmp.nodes   = [] // very important to re-init empty nodes array
            if (before == null) { // append to the end
                parent.nodes.push(tmp)
            } else {
                ind = this.get(parent, before, true)
                if (ind == null) {
                    console.log('ERROR: Cannot insert node "'+ node.text +'" because cannot find node "'+ before +'" to insert before.')
                    return null
                }
                parent.nodes.splice(ind, 0, tmp)
            }
            if (nd.length > 0) {
                this.insert(tmp, null, nd)
            }
        }
        if (!this.skipRefresh) this.refresh(parent.id)
        return tmp
    }
    remove() { // multiple arguments
        let effected = 0
        let node
        Array.from(arguments).forEach(arg => {
            node = this.get(arg)
            if (node == null) return
            if (this.selected != null && this.selected === node.id) {
                this.selected = null
            }
            let ind = this.get(node.parent, arg, true)
            if (ind == null) return
            if (node.parent.nodes[ind].selected) node.sidebar.unselect(node.id)
            node.parent.nodes.splice(ind, 1)
            node.parent.collapsible = node.parent.nodes.length > 0
            effected++
        })
        if (!this.skipRefresh) {
            if (effected > 0 && arguments.length == 1) this.refresh(node.parent.id); else this.refresh()
        }
        return effected
    }
    set(parent, id, node) {
        if (arguments.length == 2) {
            // need to be in reverse order
            node   = id
            id     = parent
            parent = this
        }
        // searches all nested nodes
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent.nodes == null) return null
        for (let i = 0; i < parent.nodes.length; i++) {
            if (parent.nodes[i].id === id) {
                // see if quick update is possible
                let res = this.update(id, node)
                if (Object.keys(res).length != 0) {
                    // make sure nodes inserted correctly
                    let nodes = node.nodes
                    w2utils.extend(parent.nodes[i], node, { nodes: [] })
                    if (nodes != null) {
                        this.add(parent.nodes[i], nodes)
                    }
                    if (!this.skipRefresh) this.refresh(id)
                }
                return true
            } else {
                let rv = this.set(parent.nodes[i], id, node)
                if (rv) return true
            }
        }
        return false
    }
    get(parent, id, returnIndex) { // can be just called get(id) or get(id, true)
        if (arguments.length === 0) {
            let all = []
            let tmp = this.find({})
            for (let t = 0; t < tmp.length; t++) {
                if (tmp[t].id != null) all.push(tmp[t].id)
            }
            return all
        } else {
            if (arguments.length == 1 || (arguments.length == 2 && id === true) ) {
                // need to be in reverse order
                returnIndex = id
                id          = parent
                parent      = this
            }
            // searches all nested nodes
            if (typeof parent == 'string') parent = this.get(parent)
            if (parent.nodes == null) return null
            for (let i = 0; i < parent.nodes.length; i++) {
                if (parent.nodes[i].id == id) {
                    if (returnIndex === true) return i; else return parent.nodes[i]
                } else {
                    let rv = this.get(parent.nodes[i], id, returnIndex)
                    if (rv || rv === 0) return rv
                }
            }
            return null
        }
    }
    setCount(id, count, className, style) {
        let btn = query(this.box).find(`#node_${w2utils.escapeId(id)} .w2ui-node-count`)
        if (btn.length > 0) {
            btn.removeClass()
                .addClass(`w2ui-node-count ${className || ''}`)
                .text(count)
                .get(0).style.cssText = style || ''
            this.last.badge[id] = {
                className: className || '',
                style: style || ''
            }
            let item = this.get(id)
            item.count = count
        } else {
            this.set(id, { count: count })
            this.setCount(...arguments) // to update styles
        }
    }
    find(parent, params, results) { // can be just called find({ selected: true })
        // TODO: rewrite with this.each()
        if (arguments.length == 1) {
            // need to be in reverse order
            params = parent
            parent = this
        }
        if (!results) results = []
        // searches all nested nodes
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent.nodes == null) return results
        for (let i = 0; i < parent.nodes.length; i++) {
            let match = true
            for (let prop in params) { // params is an object
                if (parent.nodes[i][prop] != params[prop]) match = false
            }
            if (match) results.push(parent.nodes[i])
            if (parent.nodes[i].nodes.length > 0) results = this.find(parent.nodes[i], params, results)
        }
        return results
    }
    sort(options, nodes) {
        // default options
        if (!options || typeof options != 'object') options = {}
        if (options.foldersFirst == null) options.foldersFirst = true
        if (options.caseSensitive == null) options.caseSensitive = false
        if (options.reverse == null) options.reverse = false
        if (nodes == null) {
            nodes = this.nodes
        }
        nodes.sort((a, b) => {
            // folders first
            let isAfolder = (a.nodes && a.nodes.length > 0)
            let isBfolder = (b.nodes && b.nodes.length > 0)
            // both folder or both not folders
            if (options.foldersFirst === false || (!isAfolder && !isBfolder) || (isAfolder && isBfolder)) {
                let aText = a.text
                let bText = b.text
                if (!options.caseSensitive) {
                    aText = aText.toLowerCase()
                    bText = bText.toLowerCase()
                }
                if (a.order != null) aText = a.order
                if (b.order != null) bText = b.order
                let cmp = w2utils.naturalCompare(aText, bText)
                return (cmp === 1 || cmp === -1) & options.reverse ? -cmp : cmp
            }
            if (isAfolder && !isBfolder) {
                return !options.reverse ? -1 : 1
            }
            if (!isAfolder && isBfolder) {
                return !options.reverse ? 1 : -1
            }
        })
        nodes.forEach(node => {
            if (node.nodes && node.nodes.length > 0) {
                this.sort(options, node.nodes)
            }
        })
    }
    each(fn, nodes) {
        if (nodes == null) nodes = this.nodes
        nodes.forEach((node) => {
            fn.call(this, node)
            if (node.nodes && node.nodes.length > 0) {
                this.each(fn, node.nodes)
            }
        })
    }
    search(str) {
        let count = 0
        let str2  = str.toLowerCase()
        this.each((node) => {
            if (node.text.toLowerCase().indexOf(str2) === -1) {
                node.hidden = true
            } else {
                count++
                showParents(node)
                node.hidden = false
            }
        })
        this.refresh()
        return count
        function showParents(node) {
            if (node.parent) {
                node.parent.hidden = false
                showParents(node.parent)
            }
        }
    }
    show() { // multiple arguments
        let effected = []
        Array.from(arguments).forEach(it => {
            let node = this.get(it)
            if (node == null || node.hidden === false) return
            node.hidden = false
            effected.push(node.id)
        })
        if (effected.length > 0) {
            if (arguments.length == 1) this.refresh(arguments[0]); else this.refresh()
        }
        return effected
    }
    hide() { // multiple arguments
        let effected = []
        Array.from(arguments).forEach(it => {
            let node = this.get(it)
            if (node == null || node.hidden === true) return
            node.hidden = true
            effected.push(node.id)
        })
        if (effected.length > 0) {
            if (arguments.length == 1) this.refresh(arguments[0]); else this.refresh()
        }
        return effected
    }
    enable() { // multiple arguments
        let effected = []
        Array.from(arguments).forEach(it => {
            let node = this.get(it)
            if (node == null || node.disabled === false) return
            node.disabled = false
            effected.push(node.id)
        })
        if (effected.length > 0) {
            if (arguments.length == 1) this.refresh(arguments[0]); else this.refresh()
        }
        return effected
    }
    disable() { // multiple arguments
        let effected = []
        Array.from(arguments).forEach(it => {
            let node = this.get(it)
            if (node == null || node.disabled === true) return
            node.disabled = true
            if (node.selected) this.unselect(node.id)
            effected.push(node.id)
        })
        if (effected.length > 0) {
            if (arguments.length == 1) this.refresh(arguments[0]); else this.refresh()
        }
        return effected
    }
    select(id) {
        let new_node = this.get(id)
        if (!new_node) return false
        if (this.selected == id && new_node.selected) return false
        this.unselect(this.selected)
        let $el = query(this.box).find('#node_'+ w2utils.escapeId(id))
        $el.addClass('w2ui-selected')
            .find('.w2ui-icon')
            .addClass('w2ui-icon-selected')
        if ($el.length > 0) {
            if (!this.inView(id)) this.scrollIntoView(id)
        }
        new_node.selected = true
        this.selected = id
        return true
    }
    unselect(id) {
        // if no arguments provided, unselect selected node
        if (arguments.length === 0) {
            id = this.selected
        }
        let current = this.get(id)
        if (!current) return false
        current.selected = false
        query(this.box).find('#node_'+ w2utils.escapeId(id))
            .removeClass('w2ui-selected')
            .find('.w2ui-icon').removeClass('w2ui-icon-selected')
        if (this.selected == id) this.selected = null
        return true
    }
    toggle(id) {
        let nd = this.get(id)
        if (nd == null) return false
        if (nd.plus) {
            this.set(id, { plus: false })
            this.expand(id)
            this.refresh(id)
            return
        }
        if (nd.nodes.length === 0) return false
        if (!nd.collapsible) return false
        if (this.get(id).expanded) return this.collapse(id); else return this.expand(id)
    }
    collapse(id) {
        let self = this
        let nd = this.get(id)
        if (nd == null) return false
        // event before
        let edata = this.trigger('collapse', { target: id, object: nd })
        if (edata.isCancelled === true) return
        // default action
        query(this.box).find('#node_'+ w2utils.escapeId(id) +'_sub').hide()
        query(this.box).find('#node_'+ w2utils.escapeId(id) +' .w2ui-expanded')
            .removeClass('w2ui-expanded')
            .addClass('w2ui-collapsed')
        nd.expanded = false
        // event after
        edata.finish()
        setTimeout(() => { self.refresh(id) }, 0)
        return true
    }
    expand(id) {
        let self = this
        let nd = this.get(id)
        // event before
        let edata = this.trigger('expand', { target: id, object: nd })
        if (edata.isCancelled === true) return
        // default action
        query(this.box).find('#node_'+ w2utils.escapeId(id) +'_sub')
            .show()
        query(this.box).find('#node_'+ w2utils.escapeId(id) +' .w2ui-collapsed')
            .removeClass('w2ui-collapsed')
            .addClass('w2ui-expanded')
        nd.expanded = true
        // event after
        edata.finish()
        self.refresh(id)
        return true
    }
    collapseAll(parent) {
        if (parent == null) parent = this
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent.nodes == null) return false
        for (let i = 0; i < parent.nodes.length; i++) {
            if (parent.nodes[i].expanded === true) parent.nodes[i].expanded = false
            if (parent.nodes[i].nodes && parent.nodes[i].nodes.length > 0) this.collapseAll(parent.nodes[i])
        }
        this.refresh(parent.id)
        return true
    }
    expandAll(parent) {
        if (parent == null) parent = this
        if (typeof parent == 'string') parent = this.get(parent)
        if (parent.nodes == null) return false
        for (let i = 0; i < parent.nodes.length; i++) {
            if (parent.nodes[i].expanded === false) parent.nodes[i].expanded = true
            if (parent.nodes[i].nodes && parent.nodes[i].nodes.length > 0) this.expandAll(parent.nodes[i])
        }
        this.refresh(parent.id)
    }
    expandParents(id) {
        let node = this.get(id)
        if (node == null) return false
        if (node.parent) {
            if (!node.parent.expanded) {
                node.parent.expanded = true
                this.refresh(node.parent.id)
            }
            this.expandParents(node.parent.id)
        }
        return true
    }
    click(id, event) {
        let obj = this
        let nd  = this.get(id)
        if (nd == null) return
        if (nd.disabled || nd.group) return // should click event if already selected
        // unselect all previously
        query(obj.box).find('.w2ui-node.w2ui-selected').each(el => {
            let oldID   = query(el).attr('id').replace('node_', '')
            let oldNode = obj.get(oldID)
            if (oldNode != null) oldNode.selected = false
            query(el).removeClass('w2ui-selected').find('.w2ui-icon').removeClass('w2ui-icon-selected')
        })
        // select new one
        let newNode = query(obj.box).find('#node_'+ w2utils.escapeId(id))
        let oldNode = query(obj.box).find('#node_'+ w2utils.escapeId(obj.selected))
        newNode.addClass('w2ui-selected').find('.w2ui-icon').addClass('w2ui-icon-selected')
        // need timeout to allow rendering
        setTimeout(() => {
            // event before
            let edata = obj.trigger('click', { target: id, originalEvent: event, node: nd, object: nd })
            if (edata.isCancelled === true) {
                // restore selection
                newNode.removeClass('w2ui-selected').find('.w2ui-icon').removeClass('w2ui-icon-selected')
                oldNode.addClass('w2ui-selected').find('.w2ui-icon').addClass('w2ui-icon-selected')
                return
            }
            // default action
            if (oldNode != null) oldNode.selected = false
            obj.get(id).selected = true
            obj.selected = id
            // route processing
            if (typeof nd.route == 'string') {
                let route = nd.route !== '' ? String('/'+ nd.route).replace(/\/{2,}/g, '/') : ''
                let info  = w2utils.parseRoute(route)
                if (info.keys.length > 0) {
                    for (let k = 0; k < info.keys.length; k++) {
                        if (obj.routeData[info.keys[k].name] == null) continue
                        route = route.replace((new RegExp(':'+ info.keys[k].name, 'g')), obj.routeData[info.keys[k].name])
                    }
                }
                setTimeout(() => { window.location.hash = route }, 1)
            }
            // event after
            edata.finish()
        }, 1)
    }
    focus(event) {
        let self = this
        // event before
        let edata = this.trigger('focus', { target: this.name, originalEvent: event })
        if (edata.isCancelled === true) return false
        // default behaviour
        this.hasFocus = true
        query(this.box).find('.w2ui-sidebar-body').addClass('w2ui-focus')
        setTimeout(() => {
            let input = query(self.box).find('#sidebar_'+ self.name + '_focus').get(0)
            if (document.activeElement != input) input.focus()
        }, 10)
        // event after
        edata.finish()
    }
    blur(event) {
        // event before
        let edata = this.trigger('blur', { target: this.name, originalEvent: event })
        if (edata.isCancelled === true) return false
        // default behaviour
        this.hasFocus = false
        query(this.box).find('.w2ui-sidebar-body').removeClass('w2ui-focus')
        // event after
        edata.finish()
    }
    keydown(event) {
        let obj = this
        let nd  = obj.get(obj.selected)
        if (obj.keyboard !== true) return
        if (!nd) nd = obj.nodes[0]
        // trigger event
        let edata = obj.trigger('keydown', { target: obj.name, originalEvent: event })
        if (edata.isCancelled === true) return
        // default behaviour
        if (event.keyCode == 13 || event.keyCode == 32) { // enter or space
            if (nd.nodes.length > 0) obj.toggle(obj.selected)
        }
        if (event.keyCode == 37) { // left
            if (nd.nodes.length > 0 && nd.expanded) {
                obj.collapse(obj.selected)
            } else {
                selectNode(nd.parent)
                if (!nd.parent.group) obj.collapse(nd.parent.id)
            }
        }
        if (event.keyCode == 39) { // right
            if ((nd.nodes.length > 0 || nd.plus) && !nd.expanded) obj.expand(obj.selected)
        }
        if (event.keyCode == 38) { // up
            if (obj.get(obj.selected) == null) {
                selectNode(this.nodes[0] || null)
            } else {
                selectNode(neighbor(nd, prev))
            }
        }
        if (event.keyCode == 40) { // down
            if (obj.get(obj.selected) == null) {
                selectNode(this.nodes[0] || null)
            } else {
                selectNode(neighbor(nd, next))
            }
        }
        // cancel event if needed
        if ([13, 32, 37, 38, 39, 40].includes(event.keyCode)) {
            if (event.preventDefault) event.preventDefault()
            if (event.stopPropagation) event.stopPropagation()
        }
        // event after
        edata.finish()
        function selectNode(node, event) {
            if (node != null && !node.hidden && !node.disabled && !node.group) {
                obj.click(node.id, event)
                if (!obj.inView(node.id)) obj.scrollIntoView(node.id)
            }
        }
        function neighbor(node, neighborFunc) {
            node = neighborFunc(node)
            while (node != null && (node.hidden || node.disabled)) {
                if (node.group) break; else node = neighborFunc(node)
            }
            return node
        }
        function next(node, noSubs) {
            if (node == null) return null
            let parent   = node.parent
            let ind      = obj.get(node.id, true)
            let nextNode = null
            // jump inside
            if (node.expanded && node.nodes.length > 0 && noSubs !== true) {
                let t = node.nodes[0]
                if (t.hidden || t.disabled || t.group) nextNode = next(t); else nextNode = t
            } else {
                if (parent && ind + 1 < parent.nodes.length) {
                    nextNode = parent.nodes[ind + 1]
                } else {
                    nextNode = next(parent, true) // jump to the parent
                }
            }
            if (nextNode != null && (nextNode.hidden || nextNode.disabled || nextNode.group)) nextNode = next(nextNode)
            return nextNode
        }
        function prev(node) {
            if (node == null) return null
            let parent   = node.parent
            let ind      = obj.get(node.id, true)
            let prevNode = (ind > 0) ? lastChild(parent.nodes[ind - 1]) : parent
            if (prevNode != null && (prevNode.hidden || prevNode.disabled || prevNode.group)) prevNode = prev(prevNode)
            return prevNode
        }
        function lastChild(node) {
            if (node.expanded && node.nodes.length > 0) {
                let t = node.nodes[node.nodes.length - 1]
                if (t.hidden || t.disabled || t.group) return prev(t); else return lastChild(t)
            }
            return node
        }
    }
    inView(id) {
        let item = query(this.box).find('#node_'+ w2utils.escapeId(id)).get(0)
        if (!item) {
            return false
        }
        let div = query(this.box).find('.w2ui-sidebar-body').get(0)
        if (item.offsetTop < div.scrollTop || (item.offsetTop + item.clientHeight > div.clientHeight + div.scrollTop)) {
            return false
        }
        return true
    }
    scrollIntoView(id, instant) {
        return new Promise((resolve, reject) => {
            if (id == null) id = this.selected
            let nd = this.get(id)
            if (nd == null) return
            let item = query(this.box).find('#node_'+ w2utils.escapeId(id)).get(0)
            item.scrollIntoView({ block: 'center', inline: 'center', behavior: instant ? 'atuo' : 'smooth' })
            setTimeout(() => { this.resize(); resolve() }, instant ? 0 : 500)
        })
    }
    dblClick(id, event) {
        let nd = this.get(id)
        // event before
        let edata = this.trigger('dblClick', { target: id, originalEvent: event, object: nd })
        if (edata.isCancelled === true) return
        // default action
        this.toggle(id)
        // event after
        edata.finish()
    }
    contextMenu(id, event) {
        let nd = this.get(id)
        if (id != this.selected) this.click(id)
        // event before
        let edata = this.trigger('contextMenu', { target: id, originalEvent: event, object: nd, allowOnDisabled: false })
        if (edata.isCancelled === true) return
        // default action
        if (nd.disabled && !edata.allowOnDisabled) return
        if (this.menu.length > 0) {
            w2menu.show({
                name: this.name + '_menu',
                anchor: document.body,
                items: this.menu,
                originalEvent: event
            })
            .select(evt => {
                this.menuClick(id, parseInt(evt.detail.index), event)
            })
        }
        // prevent default context menu
        if (event.preventDefault) event.preventDefault()
        // event after
        edata.finish()
    }
    menuClick(itemId, index, event) {
        // event before
        let edata = this.trigger('menuClick', { target: itemId, originalEvent: event, menuIndex: index, menuItem: this.menu[index] })
        if (edata.isCancelled === true) return
        // default action
        // -- empty
        // event after
        edata.finish()
    }
    goFlat() {
        // event before
        let edata = this.trigger('flat', { goFlat: !this.flat })
        if (edata.isCancelled === true) return
        // default action
        this.flat = !this.flat
        this.refresh()
        // event after
        edata.finish()
    }
    render(box) {
        let time = Date.now()
        let obj  = this
        if (typeof box == 'string') box = query(box).get(0)
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // default action
        if (box != null) {
            // clean previous box
            if (query(this.box).find('.w2ui-sidebar-body').length > 0) {
                query(this.box)
                    .removeAttr('name')
                    .removeClass('w2ui-reset w2ui-sidebar')
                    .html('')
            }
            this.box = box
        }
        if (!this.box) return
        query(this.box)
            .attr('name', this.name)
            .addClass('w2ui-reset w2ui-sidebar')
            .html(`<div>
                <div class="w2ui-sidebar-top"></div>
                <input id="sidebar_${this.name}_focus" ${(this.tabIndex ? 'tabindex="' + this.tabIndex + '"' : '')}
                    style="position: absolute; top: 0; right: 0; width: 1px; z-index: -1; opacity: 0"
                    ${(w2utils.isIOS ? 'readonly' : '')}/>
                <div class="w2ui-sidebar-body"></div>
                <div class="w2ui-sidebar-bottom"></div>
            </div>`)
        let rect = query(this.box).get(0).getBoundingClientRect()
        query(this.box).find(':scope > div').css({
            width  : rect.width + 'px',
            height : rect.height + 'px'
        })
        query(this.box).get(0).style.cssText += this.style
        // focus
        let kbd_timer
        query(this.box).find('#sidebar_'+ this.name + '_focus')
            .on('focus', function(event) {
                clearTimeout(kbd_timer)
                if (!obj.hasFocus) obj.focus(event)
            })
            .on('blur', function(event) {
                kbd_timer = setTimeout(() => {
                    if (obj.hasFocus) { obj.blur(event) }
                }, 100)
            })
            .on('keydown', function(event) {
                if (event.keyCode != 9) { // not tab
                    w2ui[obj.name].keydown.call(w2ui[obj.name], event)
                }
            })
        query(this.box).off('mousedown')
            .on('mousedown', function(event) {
                // set focus to grid
                setTimeout(() => {
                    // if input then do not focus
                    if (['INPUT', 'TEXTAREA', 'SELECT'].indexOf(event.target.tagName.toUpperCase()) == -1) {
                        let $input = query(obj.box).find('#sidebar_'+ obj.name + '_focus')
                        if (document.activeElement != $input.get(0)) {
                            $input.get(0).focus()
                        }
                    }
                }, 1)
            })
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        // event after
        edata.finish()
        // ---
        this.refresh()
        return Date.now() - time
    }
    update(id, options) {
        // quick function to refresh just this item (not sub nodes)
        //  - icon, class, style, text, count
        let nd = this.get(id)
        let level
        if (nd) {
            let $el = query(this.box).find('#node_'+ w2utils.escapeId(nd.id))
            if (nd.group) {
                if (options.text) {
                    nd.text = options.text
                    $el.find('.w2ui-group-text').replace(typeof nd.text == 'function'
                        ? nd.text.call(this, nd)
                        : '<span class="w2ui-group-text">'+ nd.text +'</span>')
                    delete options.text
                }
                if (options.class) {
                    nd.class = options.class
                    level = $el.data('level')
                    $el.get(0).className = 'w2ui-node-group w2ui-level-'+ level +(nd.class ? ' ' + nd.class : '')
                    delete options.class
                }
                if (options.style) {
                    nd.style = options.style
                    $el.get(0).nextElementSibling.style = nd.style +';'+ (!nd.hidden && nd.expanded ? '' : 'display: none;')
                    delete options.style
                }
            } else {
                if (options.icon) {
                    let $icon = $el.find('.w2ui-node-image > span')
                    if ($icon.length > 0) {
                        nd.icon = options.icon
                        $icon[0].className = (typeof nd.icon == 'function' ? nd.icon.call(this, nd) : nd.icon)
                        delete options.icon
                    }
                }
                if (options.count) {
                    nd.count = options.count
                    $el.find('.w2ui-node-count').html(nd.count)
                    if ($el.find('.w2ui-node-count').length > 0) delete options.count
                }
                if (options.class && $el.length > 0) {
                    nd.class = options.class
                    level = $el.data('level')
                    $el[0].className = 'w2ui-node w2ui-level-'+ level + (nd.selected ? ' w2ui-selected' : '') + (nd.disabled ? ' w2ui-disabled' : '') + (nd.class ? ' ' + nd.class : '')
                    delete options.class
                }
                if (options.text) {
                    nd.text = options.text
                    $el.find('.w2ui-node-text').html(typeof nd.text == 'function' ? nd.text.call(this, nd) : nd.text)
                    delete options.text
                }
                if (options.style && $el.length > 0) {
                    let $txt = $el.find('.w2ui-node-text')
                    nd.style = options.style
                    $txt[0].style = nd.style
                    delete options.style
                }
            }
        }
        // return what was not set
        return options
    }
    refresh(id, noBinding) {
        if (this.box == null) return
        let time = Date.now()
        // event before
        let edata = this.trigger('refresh', {
            target: (id != null ? id : this.name),
            nodeId: (id != null ? id : null),
            fullRefresh: (id != null ? false : true)
        })
        if (edata.isCancelled === true) return
        // adjust top and bottom
        let flatHTML = ''
        if (this.flatButton == true) {
            flatHTML = `<div class="w2ui-flat w2ui-flat-${(this.flat ? 'right' : 'left')}"></div>`
        }
        if (id == null && (this.topHTML !== '' || flatHTML !== '')) {
            query(this.box).find('.w2ui-sidebar-top').html(this.topHTML + flatHTML)
            query(this.box).find('.w2ui-sidebar-body')
                .css('top', query(this.box).find('.w2ui-sidebar-top').get(0)?.clientHeight + 'px')
            query(this.box).find('.w2ui-flat')
                .off('click')
                .on('click', event => { this.goFlat() })
        }
        if (id != null && this.bottomHTML !== '') {
            query(this.box).find('.w2ui-sidebar-bottom').html(this.bottomHTML)
            query(this.box).find('.w2ui-sidebar-body')
                .css('bottom', query(this.box).find('.w2ui-sidebar-bottom').get(0)?.clientHeight + 'px')
        }
        // default action
        query(this.box).find(':scope > div').removeClass('w2ui-sidebar-flat').addClass(this.flat ? 'w2ui-sidebar-flat' : '').css({
            width : query(this.box).get(0)?.clientWidth + 'px',
            height: query(this.box).get(0)?.clientHeight + 'px'
        })
        // if no parent - reset nodes
        if (this.nodes.length > 0 && this.nodes[0].parent == null) {
            let tmp    = this.nodes
            this.nodes = []
            this.add(this, tmp)
        }
        let obj = this
        let node
        let nodeSubId
        if (id == null) {
            node = this
            nodeSubId = '.w2ui-sidebar-body'
        } else {
            node = this.get(id)
            if (node == null) return
            nodeSubId = '#node_'+ w2utils.escapeId(node.id) + '_sub'
        }
        let nodeId = '#node_'+ w2utils.escapeId(node.id)
        let nodeHTML
        if (node !== this) {
            nodeHTML = getNodeHTML(node)
            query(this.box).find(nodeId).before('<div id="sidebar_'+ this.name + '_tmp"></div>')
            query(this.box).find(nodeId).remove()
            query(this.box).find(nodeSubId).remove()
            query(this.box).find('#sidebar_'+ this.name + '_tmp').before(nodeHTML)
            query(this.box).find('#sidebar_'+ this.name + '_tmp').remove()
        }
        // remember scroll position
        let div = query(this.box).find(':scope > div').get(0)
        let scroll = {
            top: div?.scrollTop,
            left: div?.scrollLeft
        }
        // refresh sub nodes
        query(this.box).find(nodeSubId).html('')
        for (let i = 0; i < node.nodes.length; i++) {
            let subNode = node.nodes[i]
            nodeHTML = getNodeHTML(subNode)
            query(this.box).find(nodeSubId).append(nodeHTML)
            if (subNode.nodes.length !== 0) {
                this.refresh(subNode.id, true)
            } else {
                // trigger event
                let edata2 = this.trigger('refresh', { target: subNode.id })
                if (edata2.isCancelled === true) return
                // event after
                edata2.finish()
            }
        }
        // reset scroll
        if (div) {
            div.scrollTop = scroll.top
            div.scrollLeft = scroll.left
        }
        // bind events
        if (!noBinding) {
            let els = query(this.box).find(`${nodeId}.w2ui-eaction, ${nodeSubId} .w2ui-eaction`)
            w2utils.bindEvents(els, this)
        }
        // event after
        edata.finish()
        return Date.now() - time
        function getNodeHTML(nd) {
            let html = ''
            let icon = nd.icon
            if (icon == null) icon = obj.icon
            // -- find out level
            let tmp   = nd.parent
            let level = 0
            while (tmp && tmp.parent != null) {
                // if (tmp.group) level--;
                tmp = tmp.parent
                level++
            }
            if (nd.caption != null && nd.text == null) nd.text = nd.caption
            if (nd.caption != null) {
                console.log('NOTICE: sidebar node.caption property is deprecated, please use node.text. Node -> ', nd)
                nd.text = nd.caption
            }
            if (Array.isArray(nd.nodes) && nd.nodes.length > 0) nd.collapsible = true
            if (nd.group) {
                let text = w2utils.lang(typeof nd.text == 'function' ? nd.text.call(obj, nd) : nd.text)
                if (String(text).substr(0, 5) != '<span') {
                    text = `<span class="w2ui-group-text">${text}</span>`
                }
                html = `
                    <div id="node_${nd.id}" data-level="${level}" style="${nd.hidden ? 'display: none' : ''}"
                        class="w2ui-node-group w2ui-level-${level} ${nd.class ? nd.class : ''} w2ui-eaction"
                        data-click="toggle|${nd.id}"
                        data-contextmenu="contextMenu|${nd.id}|event"
                        data-mouseenter="showPlus|this|inherit"
                        data-mouseleave="showPlus|this|transparent">
                        ${nd.groupShowHide && nd.collapsible
                            ? `<span>${!nd.hidden && nd.expanded ? w2utils.lang('Hide') : w2utils.lang('Show')}</span>`
                            : '<span></span>'
                        } ${text}
                    </div>
                    <div class="w2ui-node-sub" id="node_${nd.id}_sub" style="${nd.style}; ${!nd.hidden && nd.expanded ? '' : 'display: none;'}">
                </div>`
                if (obj.flat) {
                    html = `
                        <div class="w2ui-node-group" id="node_${nd.id}"><span>&#160;</span></div>
                        <div id="node_${nd.id}_sub" style="${nd.style}; ${!nd.hidden && nd.expanded ? '' : 'display: none;'}"></div>`
                }
            } else {
                if (nd.selected && !nd.disabled) obj.selected = nd.id
                tmp = ''
                if (icon) {
                    tmp = `
                    <div class="w2ui-node-image">
                        <span class="${typeof icon == 'function' ? icon.call(obj, nd) : icon}"></span>
                    </div>`
                }
                let expand = ''
                let counts = (nd.count != null
                    ? `<div class="w2ui-node-count ${obj.last.badge[nd.id] ? obj.last.badge[nd.id].className || '' : ''}"
                            style="${obj.last.badge[nd.id] ? obj.last.badge[nd.id].style || '' : ''}">
                                ${nd.count}
                       </div>`
                    : '')
                if (nd.collapsible === true) {
                    expand = `<div class="w2ui-${nd.expanded ? 'expanded' : 'collapsed'}"><span></span></div>`
                }
                let text = w2utils.lang(typeof nd.text == 'function' ? nd.text.call(obj, nd) : nd.text)
                // array with classes
                let classes = ['w2ui-node', `w2ui-level-${level}`, 'w2ui-eaction']
                if (nd.selected) classes.push('w2ui-selected')
                if (nd.disabled) classes.push('w2ui-disabled')
                if (nd.class) classes.push(nd.class)
                html = `
                    <div id="node_${nd.id}" class="${classes.join(' ')}" data-level="${level}"
                        style="position: relative; ${nd.hidden ? 'display: none;' : ''}"
                        data-click="click|${nd.id}|event"
                        data-dblclick="dblClick|${nd.id}|event"
                        data-contextmenu="contextMenu|${nd.id}|event"
                        data-mouseEnter="mouseAction|Enter|this|${nd.id}|event"
                        data-mouseLeave="mouseAction|Leave|this|${nd.id}|event"
                    >
                        ${obj.handle.html
                            ? `<div class="w2ui-node-handle w2ui-eaction" style="width: ${obj.handle.size}px; ${obj.handle.style}"
                                    data-mouseEnter="mouseAction|Enter|this|${nd.id}|event|handle"
                                    data-mouseLeave="mouseAction|Leave|this|${nd.id}|event|handle"
                                >
                                   ${typeof obj.handle.html == 'function' ? obj.handle.html.call(obj, nd) : obj.handle.html}
                              </div>`
                            : ''
                        }
                      <div class="w2ui-node-data" style="margin-left: ${level * obj.levelPadding + obj.handle.size}px">
                            ${expand} ${tmp} ${counts}
                            <div class="w2ui-node-text w2ui-node-caption" style="${nd.style || ''}">${text}</div>
                       </div>
                    </div>
                    <div class="w2ui-node-sub" id="node_${nd.id}_sub" style="${nd.style}; ${!nd.hidden && nd.expanded ? '' : 'display: none;'}"></div>`
                if (obj.flat) {
                    html = `
                        <div id="node_${nd.id}" class="${classes.join(' ')}" style="${nd.hidden ? 'display: none;' : ''}"
                            data-click="click|${nd.id}|event"
                            data-dblclick="dblClick|${nd.id}|event"
                            data-contextmenu="contextMenu|${nd.id}|event"
                            data-mouseEnter="mouseAction|Enter|this|${nd.id}|event|tooltip"
                            data-mouseLeave="mouseAction|Leave|this|${nd.id}|event|tooltip"
                        >
                            <div class="w2ui-node-data w2ui-node-flat">${tmp}</div>
                        </div>
                        <div class="w2ui-node-sub" id="node_${nd.id}_sub" style="${nd.style}; ${!nd.hidden && nd.expanded ? '' : 'display: none;'}"></div>`
                }
            }
            return html
        }
    }
    mouseAction(action, el, id, event, type) {
        let node = this.get(id)
        let text = w2utils.lang(typeof node.text == 'function' ? node.text.call(this, node) : node.text)
        let tooltip = text + (node.count || node.count === 0 ? ' - <span class="w2ui-node-count">'+ node.count +'</span>' : '')
        let edata = this.trigger('mouse' + action, { target: id, node, tooltip, originalEvent: event })
        if (type == 'tooltip') {
            this.tooltip(el, tooltip, id)
        }
        if (type == 'handle') {
            this.handleTooltip(el, id)
        }
        edata.finish()
    }
    tooltip(el, text, id) {
        let $el = query(el).find('.w2ui-node-data')
        if (text !== '') {
            w2tooltip.show({
                anchor: $el.get(0),
                name: this.name + '_tooltip',
                html: text,
                position: 'right|left'
            })
        } else {
            w2tooltip.hide(this.name + '_tooltip')
        }
    }
    handleTooltip(anchor, id) {
        let text = this.handle.tooltip
        if (typeof text == 'function') {
            text = text(id)
        }
        if (text !== '' && id != null) {
            w2tooltip.show({
                anchor: anchor,
                name: this.name + '_tooltip',
                html: text,
                position: 'top|bottom'
            })
        } else {
            w2tooltip.hide(this.name + '_tooltip')
        }
    }
    showPlus(el, color) {
        query(el).find('span:nth-child(1)').css('color', color)
    }
    resize() {
        let time = Date.now()
        // event before
        let edata = this.trigger('resize', { target: this.name })
        if (edata.isCancelled === true) return
        // default action
        let rect = query(this.box).get(0).getBoundingClientRect()
        query(this.box).css('overflow', 'hidden') // container should have no overflow
        query(this.box).find(':scope > div').css({
            width  : rect.width + 'px',
            height : rect.height + 'px'
        })
        // event after
        edata.finish()
        return Date.now() - time
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        // clean up
        if (query(this.box).find('.w2ui-sidebar-body').length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-reset w2ui-sidebar')
                .html('')
        }
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
    }
    lock(msg, showSpinner) {
        let args = Array.from(arguments)
        args.unshift(this.box)
        w2utils.lock(...args)
    }
    unlock(speed) {
        w2utils.unlock(this.box, speed)
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tooltip
 *
 * == 2.0 changes
 *  - CSP - fixed inline events
 *  - removed jQuery dependency
 *  - observeResize for the box
 *  - refactored w2events
 *  - scrollIntoView - removed callback
 *  - scroll, scrollIntoView return promise
 *  - animateInsert, animateClose - returns a promise
 *  - add, insert return a promise
 *  - onMouseEnter, onMouseLeave, onMouseDown, onMouseUp
 */

class w2tabs extends w2base {
    constructor(options) {
        super(options.name)
        this.box          = null // DOM Element that holds the element
        this.name         = null // unique name for w2ui
        this.active       = null
        this.reorder      = false
        this.flow         = 'down' // can be down or up
        this.tooltip      = 'top|left' // can be top, bottom, left, right
        this.tabs         = []
        this.routeData    = {} // data for dynamic routes
        this.last         = {} // placeholder for internal variables
        this.right        = ''
        this.style        = ''
        this.onClick      = null
        this.onMouseEnter = null // mouse enter and lease
        this.onMouseLeave = null
        this.onMouseDown  = null
        this.onMouseUp    = null
        this.onClose      = null
        this.onRender     = null
        this.onRefresh    = null
        this.onResize     = null
        this.onDestroy    = null
        this.tab_template = {
            id: null,
            text: null,
            route: null,
            hidden: false,
            disabled: false,
            closable: false,
            tooltip: null,
            style: '',
            onClick: null,
            onRefresh: null,
            onClose: null
        }
        let tabs = options.tabs
        delete options.tabs
        // mix in options
        Object.assign(this, options)
        // add item via method to makes sure item_template is applied
        if (Array.isArray(tabs)) this.add(tabs)
        // need to reassign back to keep it in config
        options.tabs = tabs
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
    }
    add(tab) {
        return this.insert(null, tab)
    }
    insert(id, tabs) {
        if (!Array.isArray(tabs)) tabs = [tabs]
        // assume it is array
        let proms = []
        tabs.forEach(tab => {
            // checks
            if (tab.id == null) {
                console.log(`ERROR: The parameter "id" is required but not supplied. (obj: ${this.name})`)
                return
            }
            if (!w2utils.checkUniqueId(tab.id, this.tabs, 'tabs', this.name)) return
            // add tab
            let it = Object.assign({}, this.tab_template, tab)
            if (id == null) {
                this.tabs.push(it)
                proms.push(this.animateInsert(null, it))
            } else {
                let middle = this.get(id, true)
                let before = this.tabs[middle].id
                this.tabs.splice(middle, 0, it)
                proms.push(this.animateInsert(before, it))
            }
        })
        return Promise.all(proms)
    }
    remove() {
        let effected = 0
        Array.from(arguments).forEach(it => {
            let tab = this.get(it)
            if (!tab) return
            effected++
            // remove from array
            this.tabs.splice(this.get(tab.id, true), 1)
            // remove from screen
            query(this.box).find(`#tabs_${this.name}_tab_${w2utils.escapeId(tab.id)}`).remove()
        })
        this.resize()
        return effected
    }
    select(id) {
        if (this.active == id || this.get(id) == null) return false
        this.active = id
        this.refresh()
        return true
    }
    set(id, tab) {
        let index = this.get(id, true)
        if (index == null) return false
        w2utils.extend(this.tabs[index], tab)
        this.refresh(id)
        return true
    }
    get(id, returnIndex) {
        if (arguments.length === 0) {
            let all = []
            for (let i1 = 0; i1 < this.tabs.length; i1++) {
                if (this.tabs[i1].id != null) {
                    all.push(this.tabs[i1].id)
                }
            }
            return all
        } else {
            for (let i2 = 0; i2 < this.tabs.length; i2++) {
                if (this.tabs[i2].id == id) { // need to be == since id can be numeric
                    return (returnIndex === true ? i2 : this.tabs[i2])
                }
            }
        }
        return null
    }
    show() {
        let effected = []
        Array.from(arguments).forEach(it => {
            let tab = this.get(it)
            if (!tab || tab.hidden === false) return
            tab.hidden = false
            effected.push(tab.id)
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it); this.resize() }) }, 15) // needs timeout
        return effected
    }
    hide() {
        let effected = []
        Array.from(arguments).forEach(it => {
            let tab = this.get(it)
            if (!tab || tab.hidden === true) return
            tab.hidden = true
            effected.push(tab.id)
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it); this.resize() }) }, 15) // needs timeout
        return effected
    }
    enable() {
        let effected = []
        Array.from(arguments).forEach(it => {
            let tab = this.get(it)
            if (!tab || tab.disabled === false) return
            tab.disabled = false
            effected.push(tab.id)
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it) }) }, 15) // needs timeout
        return effected
    }
    disable() {
        let effected = []
        Array.from(arguments).forEach(it => {
            let tab = this.get(it)
            if (!tab || tab.disabled === true) return
            tab.disabled = true
            effected.push(tab.id)
        })
        setTimeout(() => { effected.forEach(it => { this.refresh(it) }) }, 15) // needs timeout
        return effected
    }
    dragMove(event) {
        if (!this.last.reordering) return
        let self = this
        let info = this.last.moving
        let tab  = this.tabs[info.index]
        let next = _find(info.index, 1)
        let prev = _find(info.index, -1)
        let $el  = query(this.box).find('#tabs_'+ this.name + '_tab_'+ w2utils.escapeId(tab.id))
        if (info.divX > 0 && next) {
            let $nextEl = query(this.box).find('#tabs_'+ this.name + '_tab_'+ w2utils.escapeId(next.id))
            let width1  = parseInt($el.get(0).clientWidth)
            let width2  = parseInt($nextEl.get(0).clientWidth)
            if (width1 < width2) {
                width1 = Math.floor(width1 / 3)
                width2 = width2 - width1
            } else {
                width1 = Math.floor(width2 / 3)
                width2 = width2 - width1
            }
            if (info.divX > width2) {
                let index = this.tabs.indexOf(next)
                this.tabs.splice(info.index, 0, this.tabs.splice(index, 1)[0]) // reorder in the array
                info.$tab.before($nextEl.get(0))
                info.$tab.css('opacity', 0)
                Object.assign(this.last.moving, {
                    index: index,
                    divX: -width1,
                    x: event.pageX + width1,
                    left: info.left + info.divX + width1
                })
                return
            }
        }
        if (info.divX < 0 && prev) {
            let $prevEl = query(this.box).find('#tabs_'+ this.name + '_tab_'+ w2utils.escapeId(prev.id))
            let width1  = parseInt($el.get(0).clientWidth)
            let width2  = parseInt($prevEl.get(0).clientWidth)
            if (width1 < width2) {
                width1 = Math.floor(width1 / 3)
                width2 = width2 - width1
            } else {
                width1 = Math.floor(width2 / 3)
                width2 = width2 - width1
            }
            if (Math.abs(info.divX) > width2) {
                let index = this.tabs.indexOf(prev)
                this.tabs.splice(info.index, 0, this.tabs.splice(index, 1)[0]) // reorder in the array
                $prevEl.before(info.$tab)
                info.$tab.css('opacity', 0)
                Object.assign(info, {
                    index: index,
                    divX: width1,
                    x: event.pageX - width1,
                    left: info.left + info.divX - width1
                })
                return
            }
        }
        function _find(ind, inc) {
            ind    += inc
            let tab = self.tabs[ind]
            if (tab && tab.hidden) {
                tab = _find(ind, inc)
            }
            return tab
        }
    }
    mouseAction(action, id, event) {
        let tab = this.get(id)
        let edata = this.trigger('mouse' + action, { target: id, tab, object: tab, originalEvent: event })
        if (edata.isCancelled === true || tab.disabled || tab.hidden) return
        switch (action) {
            case 'Enter':
                this.tooltipShow(id)
                break
            case 'Leave':
                this.tooltipHide(id)
                break
            case 'Down':
                this.initReorder(id, event)
                break
            case 'Up':
                break
        }
        edata.finish()
    }
    tooltipShow(id) {
        let item = this.get(id)
        let el = query(this.box).find('#tabs_'+ this.name + '_tab_'+ w2utils.escapeId(id)).get(0)
        if (this.tooltip == null || item.disabled || this.last.reordering) {
            return
        }
        let pos = this.tooltip
        let txt = item.tooltip
        if (typeof txt == 'function') txt = txt.call(this, item)
        w2tooltip.show({
            anchor: el,
            name: this.name + '_tooltip',
            html: txt,
            position: pos
        })
    }
    tooltipHide(id) {
        if (this.tooltip == null) return
        w2tooltip.hide(this.name + '_tooltip')
    }
    getTabHTML(id) {
        let index = this.get(id, true)
        let tab   = this.tabs[index]
        if (tab == null) return false
        if (tab.text == null && tab.caption != null) tab.text = tab.caption
        if (tab.tooltip == null && tab.hint != null) tab.tooltip = tab.hint // for backward compatibility
        if (tab.caption != null) {
            console.log('NOTICE: tabs tab.caption property is deprecated, please use tab.text. Tab -> ', tab)
        }
        if (tab.hint != null) {
            console.log('NOTICE: tabs tab.hint property is deprecated, please use tab.tooltip. Tab -> ', tab)
        }
        let text = tab.text
        if (typeof text == 'function') text = text.call(this, tab)
        if (text == null) text = ''
        let closable = ''
        let addStyle = ''
        if (tab.hidden) { addStyle += 'display: none;' }
        if (tab.disabled) { addStyle += 'opacity: 0.2;' }
        if (tab.closable && !tab.disabled) {
            closable = `<div class="w2ui-tab-close w2ui-eaction ${this.active === tab.id ? 'active' : ''}"
                data-mousedown="stop" data-mouseup="clickClose|${tab.id}|event">
            </div>`
        }
        return `
            <div id="tabs_${this.name}_tab_${tab.id}" style="${addStyle} ${tab.style}"
                class="w2ui-tab w2ui-eaction ${this.active === tab.id ? 'active' : ''} ${tab.closable ? 'closable' : ''} ${tab.class ? tab.class : ''}"
                data-mouseenter="mouseAction|Enter|${tab.id}|event]"
                data-mouseleave="mouseAction|Leave|${tab.id}|event]"
                data-mousedown="mouseAction|Down|${tab.id}|event"
                data-mouseup="mouseAction|Up|${tab.id}|event"
                data-click="click|${tab.id}|event"
               >
                    ${w2utils.lang(text) + closable}
            </div>`
    }
    refresh(id) {
        let time = Date.now()
        if (this.flow == 'up') {
            query(this.box).addClass('w2ui-tabs-up')
        } else {
            query(this.box).removeClass('w2ui-tabs-up')
        }
        // event before
        let edata = this.trigger('refresh', { target: (id != null ? id : this.name), object: this.get(id) })
        if (edata.isCancelled === true) return
        if (id == null) {
            // refresh all
            for (let i = 0; i < this.tabs.length; i++) {
                this.refresh(this.tabs[i].id)
            }
        } else {
            // create or refresh only one item
            let selector = '#tabs_'+ this.name +'_tab_'+ w2utils.escapeId(id)
            let $tab = query(this.box).find(selector)
            let tabHTML = this.getTabHTML(id)
            if ($tab.length === 0) {
                query(this.box).find('#tabs_'+ this.name +'_right').before(tabHTML)
            } else {
                if (query(this.box).find('.tab-animate-insert').length == 0) {
                    $tab.replace(tabHTML)
                }
            }
            w2utils.bindEvents(query(this.box).find(`${selector}, ${selector} .w2ui-eaction`), this)
        }
        // right html
        query(this.box).find('#tabs_'+ this.name +'_right').html(this.right)
        // event after
        edata.finish()
        // this.resize();
        return Date.now() - time
    }
    render(box) {
        let time = Date.now()
        if (typeof box == 'string') box = query(box).get(0)
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // default action
        if (box != null) {
            // clean previous box
            if (query(this.box).find('#tabs_'+ this.name + '_right').length > 0) {
                query(this.box)
                    .removeAttr('name')
                    .removeClass('w2ui-reset w2ui-tabs')
                    .html('')
            }
            this.box = box
        }
        if (!this.box) return false
        // render all buttons
        let html =`
            <div class="w2ui-tabs-line"></div>
            <div class="w2ui-scroll-wrapper w2ui-eaction" data-mousedown="resize">
                <div id="tabs_${this.name}_right" class="w2ui-tabs-right">${this.right}</div>
            </div>
            <div class="w2ui-scroll-left w2ui-eaction" data-click='["scroll","left"]'></div>
            <div class="w2ui-scroll-right w2ui-eaction" data-click='["scroll","right"]'></div>`
        query(this.box)
            .attr('name', this.name)
            .addClass('w2ui-reset w2ui-tabs')
            .html(html)
        if (query(this.box).length > 0) {
            query(this.box)[0].style.cssText += this.style
        }
        w2utils.bindEvents(query(this.box).find('.w2ui-eaction'), this)
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        // event after
        edata.finish()
        this.refresh()
        this.resize()
        return Date.now() - time
    }
    initReorder(id, event) {
        if (!this.reorder) return
        let self     = this
        let $tab     = query(this.box).find('#tabs_' + this.name + '_tab_' + w2utils.escapeId(id))
        let tabIndex = this.get(id, true)
        let $ghost   = query($tab.get(0).cloneNode(true))
        let edata
        $ghost.attr('id', '#tabs_' + this.name + '_tab_ghost')
        this.last.moving = {
            index: tabIndex,
            indexFrom: tabIndex,
            $tab: $tab,
            $ghost: $ghost,
            divX: 0,
            left: $tab.get(0).getBoundingClientRect().left,
            parentX: query(this.box).get(0).getBoundingClientRect().left,
            x: event.pageX,
            opacity: $tab.css('opacity')
        }
        query(document)
            .off('.w2uiTabReorder')
            .on('mousemove.w2uiTabReorder', function (event) {
                if (!self.last.reordering) {
                    // event before
                    edata = self.trigger('reorder', { target: self.tabs[tabIndex].id, indexFrom: tabIndex, tab: self.tabs[tabIndex] })
                    if (edata.isCancelled === true) return
                    w2tooltip.hide(this.name + '_tooltip')
                    self.last.reordering = true
                    $ghost.addClass('moving')
                    $ghost.css({
                        'pointer-events': 'none',
                        'position': 'absolute',
                        'left': $tab.get(0).getBoundingClientRect().left
                    })
                    $tab.css('opacity', 0)
                    query(self.box).find('.w2ui-scroll-wrapper').append($ghost.get(0))
                    query(self.box).find('.w2ui-tab-close').hide()
                }
                self.last.moving.divX = event.pageX - self.last.moving.x
                $ghost.css('left', (self.last.moving.left - self.last.moving.parentX + self.last.moving.divX) + 'px')
                self.dragMove(event)
            })
            .on('mouseup.w2uiTabReorder', function () {
                query(document).off('.w2uiTabReorder')
                $ghost.css({
                    'transition': '0.1s',
                    'left': self.last.moving.$tab.get(0).getBoundingClientRect().left - self.last.moving.parentX
                })
                query(self.box).find('.w2ui-tab-close').show()
                setTimeout(() => {
                    $ghost.remove()
                    $tab.css({ opacity: self.last.moving.opacity })
                    // self.render()
                    if (self.last.reordering) {
                        edata.finish({ indexTo: self.last.moving.index })
                    }
                    self.last.reordering = false
                }, 100)
            })
    }
    scroll(direction, instant) {
        return new Promise((resolve, reject) => {
            let scrollBox  = query(this.box).find('.w2ui-scroll-wrapper')
            let scrollLeft = scrollBox.get(0).scrollLeft
            let right      = scrollBox.find('.w2ui-tabs-right').get(0)
            let width1     = scrollBox.parent().get(0).getBoundingClientRect().width
            let width2     = scrollLeft + parseInt(right.offsetLeft) + parseInt(right.clientWidth )
            switch (direction) {
                case 'left': {
                    let scroll = scrollLeft - width1 + 50 // 35 is width of both button
                    if (scroll <= 0) scroll = 0
                    scrollBox.get(0).scrollTo({ top: 0, left: scroll, behavior: instant ? 'atuo' : 'smooth' })
                    break
                }
                case 'right': {
                    let scroll = scrollLeft + width1 - 50 // 35 is width of both button
                    if (scroll >= width2 - width1) scroll = width2 - width1
                    scrollBox.get(0).scrollTo({ top: 0, left: scroll, behavior: instant ? 'atuo' : 'smooth' })
                    break
                }
            }
            setTimeout(() => { this.resize(); resolve() }, instant ? 0 : 350)
        })
    }
    scrollIntoView(id, instant) {
        return new Promise((resolve, reject) => {
            if (id == null) id = this.active
            let tab = this.get(id)
            if (tab == null) return
            let tabEl = query(this.box).find('#tabs_' + this.name + '_tab_' + w2utils.escapeId(id)).get(0)
            tabEl.scrollIntoView({ block: 'start', inline: 'center', behavior: instant ? 'atuo' : 'smooth' })
            setTimeout(() => { this.resize(); resolve() }, instant ? 0 : 500)
        })
    }
    resize() {
        let time = Date.now()
        if (this.box == null) return
        // event before
        let edata = this.trigger('resize', { target: this.name })
        if (edata.isCancelled === true) return
        // show hide overflow buttons
        let box = query(this.box)
        box.find('.w2ui-scroll-left, .w2ui-scroll-right').hide()
        let scrollBox  = box.find('.w2ui-scroll-wrapper').get(0)
        let $right     = box.find('.w2ui-tabs-right')
        let boxWidth   = box.get(0).getBoundingClientRect().width
        let itemsWidth = ($right.length > 0 ? $right[0].offsetLeft + $right[0].clientWidth : 0)
        if (boxWidth < itemsWidth) {
            // we have overflown content
            if (scrollBox.scrollLeft > 0) {
                box.find('.w2ui-scroll-left').show()
            }
            if (boxWidth < itemsWidth - scrollBox.scrollLeft) {
                box.find('.w2ui-scroll-right').show()
            }
        }
        // event after
        edata.finish()
        return Date.now() - time
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        // clean up
        if (query(this.box).find('#tabs_'+ this.name + '_right').length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-reset w2ui-tabs')
                .html('')
        }
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
    }
    // ===================================================
    // -- Internal Event Handlers
    click(id, event) {
        let tab = this.get(id)
        if (tab == null || tab.disabled || this.last.reordering) return false
        // event before
        let edata = this.trigger('click', { target: id, tab: tab, object: tab, originalEvent: event })
        if (edata.isCancelled === true) return
        // default action
        query(this.box).find('#tabs_'+ this.name +'_tab_'+ w2utils.escapeId(this.active)).removeClass('active')
        this.active = tab.id
        query(this.box).find('#tabs_'+ this.name +'_tab_'+ w2utils.escapeId(this.active)).addClass('active')
        // route processing
        if (typeof tab.route == 'string') {
            let route = tab.route !== '' ? String('/'+ tab.route).replace(/\/{2,}/g, '/') : ''
            let info  = w2utils.parseRoute(route)
            if (info.keys.length > 0) {
                for (let k = 0; k < info.keys.length; k++) {
                    if (this.routeData[info.keys[k].name] == null) continue
                    route = route.replace((new RegExp(':'+ info.keys[k].name, 'g')), this.routeData[info.keys[k].name])
                }
            }
            setTimeout(() => { window.location.hash = route }, 1)
        }
        // event after
        edata.finish()
    }
    clickClose(id, event) {
        let tab = this.get(id)
        if (tab == null || tab.disabled) return false
        // event before
        let edata = this.trigger('close', { target: id, object: tab, tab, originalEvent: event })
        if (edata.isCancelled === true) return
        this.animateClose(id).then(() => {
            this.remove(id)
            edata.finish()
            this.refresh()
        })
        if (event) event.stopPropagation()
    }
    animateClose(id) {
        return new Promise((resolve, reject) => {
            let $tab  = query(this.box).find('#tabs_'+ this.name +'_tab_'+ w2utils.escapeId(id))
            let width = parseInt($tab.get(0).clientWidth || 0)
            let anim = `<div class="tab-animate-close" style="display: inline-block; flex-shrink: 0; width: ${width}px; transition: width 0.25s"></div>`
            let $anim = $tab.replace(anim)
            setTimeout(() => { $anim.css({ width: '0px' }) }, 1)
            setTimeout(() => {
                $anim.remove()
                this.resize()
                resolve()
            }, 500)
        })
    }
    animateInsert(id, tab) {
        return new Promise((resolve, reject) => {
            let $before = query(this.box).find('#tabs_'+ this.name +'_tab_'+ w2utils.escapeId(id))
            let $tab    = query.html(this.getTabHTML(tab.id))
            if ($before.length == 0) {
                $before = query(this.box).find('#tabs_tabs_right')
                $before.before($tab)
                this.resize()
            } else {
                $tab.css({ opacity: 0 })
                // first insert tab on the right to get its proper dimentions
                query(this.box).find('#tabs_tabs_right').before($tab.get(0))
                let $tmp  = query(this.box).find('#' + $tab.attr('id'))
                let width = $tmp.get(0).clientWidth ?? 0
                // insert animation div
                let $anim = query.html('<div class="tab-animate-insert" style="flex-shrink: 0; width: 0; transition: width 0.25s"></div>')
                $before.before($anim)
                // hide tab and move it in the right position
                $tab.hide()
                $anim.before($tab[0])
                setTimeout(() => { $anim.css({ width: width + 'px' }) }, 1)
                setTimeout(() => {
                    $anim.remove()
                    $tab.css({ opacity: 1 }).show()
                    this.refresh(tab.id)
                    this.resize()
                    resolve()
                }, 500)
            }
        })
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tabs, w2toolbar
 *
 * == 2.0 changes
 *  - CSP - fixed inline events
 *  - remove jQuery dependency
 *  - layout.confirm - refactored
 *  - layout.message - refactored
 *  - panel.removed
 */

let w2panels = ['top', 'left', 'main', 'preview', 'right', 'bottom']
class w2layout extends w2base {
    constructor(options) {
        super(options.name)
        this.box            = null // DOM Element that holds the element
        this.name           = null // unique name for w2ui
        this.panels         = []
        this.last           = {}
        this.padding        = 1 // panel padding
        this.resizer        = 4 // resizer width or height
        this.style          = ''
        this.onShow         = null
        this.onHide         = null
        this.onResizing     = null
        this.onResizerClick = null
        this.onRender       = null
        this.onRefresh      = null
        this.onChange       = null
        this.onResize       = null
        this.onDestroy      = null
        this.panel_template = {
            type: null, // left, right, top, bottom
            title: '',
            size: 100, // width or height depending on panel name
            minSize: 20,
            maxSize: false,
            hidden: false,
            resizable: false,
            overflow: 'auto',
            style: '',
            html: '', // can be String or Object with .render(box) method
            tabs: null,
            toolbar: null,
            width: null, // read only
            height: null, // read only
            show: {
                toolbar: false,
                tabs: false
            },
            removed: null, // function to call when content is overwritten
            onRefresh: null,
            onShow: null,
            onHide: null
        }
        // mix in options
        Object.assign(this, options)
        if (!Array.isArray(this.panels)) this.panels = []
        // add defined panels
        this.panels.forEach((panel, ind) => {
            this.panels[ind] = w2utils.extend({}, this.panel_template, panel)
            if (w2utils.isPlainObject(panel.tabs) || Array.isArray(panel.tabs)) initTabs(this, panel.type)
            if (w2utils.isPlainObject(panel.toolbar) || Array.isArray(panel.toolbar)) initToolbar(this, panel.type)
        })
        // add all other panels
        w2panels.forEach(tab => {
            if (this.get(tab) != null) return
            this.panels.push(w2utils.extend({}, this.panel_template, { type: tab, hidden: (tab !== 'main'), size: 50 }))
        })
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
        function initTabs(object, panel, tabs) {
            let pan = object.get(panel)
            if (pan != null && tabs == null) tabs = pan.tabs
            if (pan == null || tabs == null) return false
            // instantiate tabs
            if (Array.isArray(tabs)) tabs = { tabs: tabs }
            let name = object.name + '_' + panel + '_tabs'
            if (w2ui[name]) w2ui[name].destroy() // destroy if existed
            pan.tabs      = new w2tabs(w2utils.extend({}, tabs, { owner: object, name: object.name + '_' + panel + '_tabs' }))
            pan.show.tabs = true
            return true
        }
        function initToolbar(object, panel, toolbar) {
            let pan = object.get(panel)
            if (pan != null && toolbar == null) toolbar = pan.toolbar
            if (pan == null || toolbar == null) return false
            // instantiate toolbar
            if (Array.isArray(toolbar)) toolbar = { items: toolbar }
            let name = object.name + '_' + panel + '_toolbar'
            if (w2ui[name]) w2ui[name].destroy() // destroy if existed
            pan.toolbar      = new w2toolbar(w2utils.extend({}, toolbar, { owner: object, name: object.name + '_' + panel + '_toolbar' }))
            pan.show.toolbar = true
            return true
        }
    }
    html(panel, data, transition) {
        let p = this.get(panel)
        let promise = {
            panel: panel,
            html: p.html,
            error: false,
            cancelled: false,
            removed(cb) {
                if (typeof cb == 'function') {
                    p.removed = cb
                }
            }
        }
        if (typeof p.removed == 'function') {
            p.removed({ panel: panel, html: p.html, html_new: data, transition: transition || 'none' })
            p.removed = null // this is one time call back only
        }
        // if it is CSS panel
        if (panel == 'css') {
            query(this.box).find('#layout_'+ this.name +'_panel_css').html('<style>'+ data +'</style>')
            promise.status = true
            return promise
        }
        if (p == null) {
            console.log('ERROR: incorrect panel name. Panel name can be main, left, right, top, bottom, preview or css')
            promise.error = true
            return promise
        }
        if (data == null) {
            return promise
        }
        // event before
        let edata = this.trigger('change', { target: panel, panel: p, html_new: data, transition: transition })
        if (edata.isCancelled === true) {
            promise.cancelled = true
            return promise
        }
        let pname = '#layout_'+ this.name + '_panel_'+ p.type
        let current = query(this.box).find(pname + '> .w2ui-panel-content')
        let panelTop = 0
        if (current.length > 0) {
            query(this.box).find(pname).get(0).scrollTop = 0
            panelTop = query(current).css('top')
        }
        if (p.html === '') {
            p.html = data
            this.refresh(panel)
        } else {
            p.html = data
            if (!p.hidden) {
                if (transition != null && transition !== '') {
                    // apply transition
                    query(this.box).addClass('animating')
                    let div1 = query(this.box).find(pname + '> .w2ui-panel-content')
                    div1.after('<div class="w2ui-panel-content new-panel" style="'+ div1[0].style.cssText +'"></div>')
                    let div2 = query(this.box).find(pname + '> .w2ui-panel-content.new-panel')
                    div1.css('top', panelTop)
                    div2.css('top', panelTop)
                    if (typeof data == 'object') {
                        data.box = div2[0] // do not do .render(box);
                        data.render()
                    } else {
                        div2.hide().html(data)
                    }
                    w2utils.transition(div1[0], div2[0], transition, () => {
                        div1.remove()
                        div2.removeClass('new-panel')
                        div2.css('overflow', p.overflow)
                        // make sure only one content left
                        query(query(this.box).find(pname + '> .w2ui-panel-content').get(1)).remove()
                        query(this.box).removeClass('animating')
                        this.refresh(panel)
                    })
                } else {
                    this.refresh(panel)
                }
            }
        }
        // event after
        edata.finish()
        return promise
    }
    message(panel, options) {
        let p = this.get(panel)
        let box = query(this.box).find('#layout_'+ this.name + '_panel_'+ p.type)
        let oldOverflow = box.css('overflow')
        box.css('overflow', 'hidden')
        let prom = w2utils.message({
            owner: this,
            box  : box.get(0),
            after: '.w2ui-panel-title',
            param: panel
        }, options)
        if (prom) {
            prom.self.on('close:after', () => {
                box.css('overflow', oldOverflow)
            })
        }
        return prom
    }
    confirm(panel, options) {
        let p = this.get(panel)
        let box = query(this.box).find('#layout_'+ this.name + '_panel_'+ p.type)
        let oldOverflow = box.css('overflow')
        box.css('overflow', 'hidden')
        let prom = w2utils.confirm({
            owner : this,
            box   : box.get(0),
            after : '.w2ui-panel-title',
            param : panel
        }, options)
        if (prom) {
            prom.self.on('close:after', () => {
                box.css('overflow', oldOverflow)
            })
        }
        return prom
    }
    load(panel, url, transition) {
        return new Promise((resolve, reject) => {
            if ((panel == 'css' || this.get(panel) != null) && url != null) {
                fetch(url)
                    .then(resp => resp.text())
                    .then(text => {
                        this.resize()
                        resolve(this.html(panel, text, transition))
                    })
            } else {
                reject()
            }
        })
    }
    sizeTo(panel, size, instant) {
        let pan = this.get(panel)
        if (pan == null) return false
        // resize
        query(this.box).find(':scope > div > .w2ui-panel')
            .css('transition', (instant !== true ? '.2s' : '0s'))
        setTimeout(() => { this.set(panel, { size: size }) }, 1)
        // clean
        setTimeout(() => {
            query(this.box).find(':scope > div > .w2ui-panel').css('transition', '0s')
            this.resize()
        }, 300)
        return true
    }
    show(panel, immediate) {
        // event before
        let edata = this.trigger('show', { target: panel, thisect: this.get(panel), immediate: immediate })
        if (edata.isCancelled === true) return
        let p = this.get(panel)
        if (p == null) return false
        p.hidden = false
        if (immediate === true) {
            query(this.box).find('#layout_'+ this.name +'_panel_'+panel)
                .css({ 'opacity': '1' })
            edata.finish()
            this.resize()
        } else {
            // resize
            query(this.box).addClass('animating')
            query(this.box).find('#layout_'+ this.name +'_panel_'+panel)
                .css({ 'opacity': '0' })
            query(this.box).find(':scope > div > .w2ui-panel')
                .css('transition', '.2s')
            setTimeout(() => { this.resize() }, 1)
            // show
            setTimeout(() => {
                query(this.box).find('#layout_'+ this.name +'_panel_'+ panel).css({ 'opacity': '1' })
            }, 250)
            // clean
            setTimeout(() => {
                query(this.box).find(':scope > div > .w2ui-panel')
                    .css('transition', '0s')
                query(this.box).removeClass('animating')
                edata.finish()
                this.resize()
            }, 300)
        }
        return true
    }
    hide(panel, immediate) {
        // event before
        let edata = this.trigger('hide', { target: panel, object: this.get(panel), immediate: immediate })
        if (edata.isCancelled === true) return
        let p = this.get(panel)
        if (p == null) return false
        p.hidden = true
        if (immediate === true) {
            query(this.box).find('#layout_'+ this.name +'_panel_'+panel)
                .css({ 'opacity': '0' })
            edata.finish()
            this.resize()
        } else {
            // hide
            query(this.box).addClass('animating')
            query(this.box).find(':scope > div > .w2ui-panel')
                .css('transition', '.2s')
            query(this.box).find('#layout_'+ this.name +'_panel_'+panel)
                .css({ 'opacity': '0' })
            setTimeout(() => { this.resize() }, 1)
            // clean
            setTimeout(() => {
                query(this.box).find(':scope > div > .w2ui-panel')
                    .css('transition', '0s')
                query(this.box).removeClass('animating')
                edata.finish()
                this.resize()
            }, 300)
        }
        return true
    }
    toggle(panel, immediate) {
        let p = this.get(panel)
        if (p == null) return false
        if (p.hidden) return this.show(panel, immediate); else return this.hide(panel, immediate)
    }
    set(panel, options) {
        let ind = this.get(panel, true)
        if (ind == null) return false
        w2utils.extend(this.panels[ind], options)
        // refresh only when content changed
        if (options.html != null || options.resizable != null) {
            this.refresh(panel)
        }
        // show/hide resizer
        this.resize() // resize is needed when panel size is changed
        return true
    }
    get(panel, returnIndex) {
        for (let p = 0; p < this.panels.length; p++) {
            if (this.panels[p].type == panel) {
                if (returnIndex === true) return p; else return this.panels[p]
            }
        }
        return null
    }
    el(panel) {
        let el = query(this.box).find('#layout_'+ this.name +'_panel_'+ panel +'> .w2ui-panel-content')
        if (el.length != 1) return null
        return el[0]
    }
    hideToolbar(panel) {
        let pan = this.get(panel)
        if (!pan) return
        pan.show.toolbar = false
        query(this.box).find('#layout_'+ this.name +'_panel_'+ panel +'> .w2ui-panel-toolbar').hide()
        this.resize()
    }
    showToolbar(panel) {
        let pan = this.get(panel)
        if (!pan) return
        pan.show.toolbar = true
        query(this.box).find('#layout_'+ this.name +'_panel_'+ panel +'> .w2ui-panel-toolbar').show()
        this.resize()
    }
    toggleToolbar(panel) {
        let pan = this.get(panel)
        if (!pan) return
        if (pan.show.toolbar) this.hideToolbar(panel); else this.showToolbar(panel)
    }
    assignToolbar(panel, toolbar) {
        if (typeof toolbar == 'string' && w2ui[toolbar] != null) toolbar = w2ui[toolbar]
        let pan = this.get(panel)
        pan.toolbar = toolbar
        let tmp = query(this.box).find(panel +'> .w2ui-panel-toolbar')
        if (pan.toolbar != null) {
            if (tmp.find('[name='+ pan.toolbar.name +']').length === 0) {
                pan.toolbar.render(tmp.get(0))
            } else if (pan.toolbar != null) {
                pan.toolbar.refresh()
            }
            toolbar.owner = this
            this.showToolbar(panel)
            this.refresh(panel)
        } else {
            tmp.html('')
            this.hideToolbar(panel)
        }
    }
    hideTabs(panel) {
        let pan = this.get(panel)
        if (!pan) return
        pan.show.tabs = false
        query(this.box).find('#layout_'+ this.name +'_panel_'+ panel +'> .w2ui-panel-tabs').hide()
        this.resize()
    }
    showTabs(panel) {
        let pan = this.get(panel)
        if (!pan) return
        pan.show.tabs = true
        query(this.box).find('#layout_'+ this.name +'_panel_'+ panel +'> .w2ui-panel-tabs').show()
        this.resize()
    }
    toggleTabs(panel) {
        let pan = this.get(panel)
        if (!pan) return
        if (pan.show.tabs) this.hideTabs(panel); else this.showTabs(panel)
    }
    render(box) {
        let time = Date.now()
        let self = this
        if (typeof box == 'string') box = query(box).get(0)
        // if (window.getSelection) window.getSelection().removeAllRanges(); // clear selection
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // default action
        if (box != null) {
            // clean previous box
            if (query(this.box).find('#layout_'+ this.name +'_panel_main').length > 0) {
                query(this.box)
                    .removeAttr('name')
                    .removeClass('w2ui-layout')
                    .html('')
            }
            this.box = box
        }
        if (!this.box) return false
        // render layout
        query(this.box)
            .attr('name', this.name)
            .addClass('w2ui-layout')
            .html('<div></div>')
        if (query(this.box).length > 0) {
            query(this.box)[0].style.cssText += this.style
        }
        // create all panels
        for (let p1 = 0; p1 < w2panels.length; p1++) {
            let html = '<div id="layout_'+ this.name + '_panel_'+ w2panels[p1] +'" class="w2ui-panel">'+
                        '    <div class="w2ui-panel-title"></div>'+
                        '    <div class="w2ui-panel-tabs"></div>'+
                        '    <div class="w2ui-panel-toolbar"></div>'+
                        '    <div class="w2ui-panel-content"></div>'+
                        '</div>'+
                        '<div id="layout_'+ this.name + '_resizer_'+ w2panels[p1] +'" class="w2ui-resizer"></div>'
            query(this.box).find(':scope > div').append(html)
        }
        query(this.box).find(':scope > div')
            .append('<div id="layout_'+ this.name + '_panel_css" style="position: absolute; top: 10000px;"></div>')
        this.refresh() // if refresh is not called here, the layout will not be available right after initialization
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        // process event
        edata.finish()
        // re-init events
        setTimeout(() => { // needed this timeout to allow browser to render first if there are tabs or toolbar
            self.last.events = { resizeStart, mouseMove, mouseUp }
            this.resize()
        }, 0)
        return Date.now() - time
        function resizeStart(type, evnt) {
            if (!self.box) return
            if (!evnt) evnt = window.event
            query(document)
                .off('mousemove', self.last.events.mouseMove)
                .on('mousemove', self.last.events.mouseMove)
            query(document)
                .off('mouseup', self.last.events.mouseUp)
                .on('mouseup', self.last.events.mouseUp)
            self.last.resize = {
                type    : type,
                x       : evnt.screenX,
                y       : evnt.screenY,
                diff_x  : 0,
                diff_y  : 0,
                value   : 0
            }
            // lock all panels
            w2panels.forEach(panel => {
                let $tmp = query(self.el(panel)).find('.w2ui-lock')
                if ($tmp.length > 0) {
                    $tmp.data('locked', 'yes')
                } else {
                    self.lock(panel, { opacity: 0 })
                }
            })
            let el = query(self.box).find('#layout_'+ self.name +'_resizer_'+ type).get(0)
            if (type == 'left' || type == 'right') {
                self.last.resize.value = parseInt(el.style.left)
            }
            if (type == 'top' || type == 'preview' || type == 'bottom') {
                self.last.resize.value = parseInt(el.style.top)
            }
        }
        function mouseUp(evnt) {
            if (!self.box) return
            if (!evnt) evnt = window.event
            query(document).off('mousemove', self.last.events.mouseMove)
            query(document).off('mouseup', self.last.events.mouseUp)
            if (self.last.resize == null) return
            // unlock all panels
            w2panels.forEach(panel => {
                let $tmp = query(self.el(panel)).find('.w2ui-lock')
                if ($tmp.data('locked') == 'yes') {
                    $tmp.removeData('locked')
                } else {
                    self.unlock(panel)
                }
            })
            // set new size
            if (self.last.diff_x !== 0 || self.last.resize.diff_y !== 0) { // only recalculate if changed
                let ptop    = self.get('top')
                let pbottom = self.get('bottom')
                let panel   = self.get(self.last.resize.type)
                let width   = w2utils.getSize(query(self.box), 'width')
                let height  = w2utils.getSize(query(self.box), 'height')
                let str     = String(panel.size)
                let ns, nd
                switch (self.last.resize.type) {
                    case 'top':
                        ns = parseInt(panel.sizeCalculated) + self.last.resize.diff_y
                        nd = 0
                        break
                    case 'bottom':
                        ns = parseInt(panel.sizeCalculated) - self.last.resize.diff_y
                        nd = 0
                        break
                    case 'preview':
                        ns = parseInt(panel.sizeCalculated) - self.last.resize.diff_y
                        nd = (ptop && !ptop.hidden ? ptop.sizeCalculated : 0) +
                            (pbottom && !pbottom.hidden ? pbottom.sizeCalculated : 0)
                        break
                    case 'left':
                        ns = parseInt(panel.sizeCalculated) + self.last.resize.diff_x
                        nd = 0
                        break
                    case 'right':
                        ns = parseInt(panel.sizeCalculated) - self.last.resize.diff_x
                        nd = 0
                        break
                }
                // set size
                if (str.substr(str.length-1) == '%') {
                    panel.size = Math.floor(ns * 100 / (panel.type == 'left' || panel.type == 'right' ? width : height - nd) * 100) / 100 + '%'
                } else {
                    if (String(panel.size).substr(0, 1) == '-') {
                        panel.size = parseInt(panel.size) - panel.sizeCalculated + ns
                    } else {
                        panel.size = ns
                    }
                }
                self.resize()
            }
            query(self.box)
                .find('#layout_'+ self.name + '_resizer_'+ self.last.resize.type)
                .removeClass('active')
            delete self.last.resize
        }
        function mouseMove(evnt) {
            if (!self.box) return
            if (!evnt) evnt = window.event
            if (self.last.resize == null) return
            let panel = self.get(self.last.resize.type)
            // event before
            let tmp   = self.last.resize
            let edata = self.trigger('resizing', { target: self.name, object: panel, originalEvent: evnt,
                panel: tmp ? tmp.type : 'all', diff_x: tmp ? tmp.diff_x : 0, diff_y: tmp ? tmp.diff_y : 0 })
            if (edata.isCancelled === true) return
            let p         = query(self.box).find('#layout_'+ self.name + '_resizer_'+ tmp.type)
            let resize_x  = (evnt.screenX - tmp.x)
            let resize_y  = (evnt.screenY - tmp.y)
            let mainPanel = self.get('main')
            if (!p.hasClass('active')) p.addClass('active')
            switch (tmp.type) {
                case 'left':
                    if (panel.minSize - resize_x > panel.width) {
                        resize_x = panel.minSize - panel.width
                    }
                    if (panel.maxSize && (panel.width + resize_x > panel.maxSize)) {
                        resize_x = panel.maxSize - panel.width
                    }
                    if (mainPanel.minSize + resize_x > mainPanel.width) {
                        resize_x = mainPanel.width - mainPanel.minSize
                    }
                    break
                case 'right':
                    if (panel.minSize + resize_x > panel.width) {
                        resize_x = panel.width - panel.minSize
                    }
                    if (panel.maxSize && (panel.width - resize_x > panel.maxSize)) {
                        resize_x = panel.width - panel.maxSize
                    }
                    if (mainPanel.minSize - resize_x > mainPanel.width) {
                        resize_x = mainPanel.minSize - mainPanel.width
                    }
                    break
                case 'top':
                    if (panel.minSize - resize_y > panel.height) {
                        resize_y = panel.minSize - panel.height
                    }
                    if (panel.maxSize && (panel.height + resize_y > panel.maxSize)) {
                        resize_y = panel.maxSize - panel.height
                    }
                    if (mainPanel.minSize + resize_y > mainPanel.height) {
                        resize_y = mainPanel.height - mainPanel.minSize
                    }
                    break
                case 'preview':
                case 'bottom':
                    if (panel.minSize + resize_y > panel.height) {
                        resize_y = panel.height - panel.minSize
                    }
                    if (panel.maxSize && (panel.height - resize_y > panel.maxSize)) {
                        resize_y = panel.height - panel.maxSize
                    }
                    if (mainPanel.minSize - resize_y > mainPanel.height) {
                        resize_y = mainPanel.minSize - mainPanel.height
                    }
                    break
            }
            tmp.diff_x = resize_x
            tmp.diff_y = resize_y
            switch (tmp.type) {
                case 'top':
                case 'preview':
                case 'bottom':
                    tmp.diff_x = 0
                    if (p.length > 0) p[0].style.top = (tmp.value + tmp.diff_y) + 'px'
                    break
                case 'left':
                case 'right':
                    tmp.diff_y = 0
                    if (p.length > 0) p[0].style.left = (tmp.value + tmp.diff_x) + 'px'
                    break
            }
            // event after
            edata.finish()
        }
    }
    refresh(panel) {
        let self = this
        // if (window.getSelection) window.getSelection().removeAllRanges(); // clear selection
        if (panel == null) panel = null
        let time = Date.now()
        // event before
        let edata = self.trigger('refresh', { target: (panel != null ? panel : self.name), object: self.get(panel) })
        if (edata.isCancelled === true) return
        // self.unlock(panel);
        if (typeof panel == 'string') {
            let p = self.get(panel)
            if (p == null) return
            let pname = '#layout_'+ self.name + '_panel_'+ p.type
            let rname = '#layout_'+ self.name +'_resizer_'+ p.type
            // apply properties to the panel
            query(self.box).find(pname).css({ display: p.hidden ? 'none' : 'block' })
            if (p.resizable) {
                query(self.box).find(rname).show()
            } else {
                query(self.box).find(rname).hide()
            }
            // insert content
            if (typeof p.html == 'object' && typeof p.html.render === 'function') {
                p.html.box = query(self.box).find(pname +'> .w2ui-panel-content')[0]
                setTimeout(() => {
                    // need to remove unnecessary classes
                    if (query(self.box).find(pname +'> .w2ui-panel-content').length > 0) {
                        query(self.box).find(pname +'> .w2ui-panel-content')
                            .removeClass()
                            .removeAttr('name')
                            .addClass('w2ui-panel-content')
                            .css('overflow', p.overflow)[0].style.cssText += ';' + p.style
                    }
                    if (p.html && typeof p.html.render == 'function') {
                        p.html.render() // do not do .render(box);
                    }
                }, 1)
            } else {
                // need to remove unnecessary classes
                if (query(self.box).find(pname +'> .w2ui-panel-content').length > 0) {
                    query(self.box).find(pname +'> .w2ui-panel-content')
                        .removeClass()
                        .removeAttr('name')
                        .addClass('w2ui-panel-content')
                        .html(p.html)
                        .css('overflow', p.overflow)[0].style.cssText += ';' + p.style
                }
            }
            // if there are tabs and/or toolbar - render it
            let tmp = query(self.box).find(pname +'> .w2ui-panel-tabs')
            if (p.show.tabs) {
                if (tmp.find('[name='+ p.tabs.name +']').length === 0 && p.tabs != null) {
                    p.tabs.render(tmp.get(0))
                } else {
                    p.tabs.refresh()
                }
            } else {
                tmp.html('').removeClass('w2ui-tabs').hide()
            }
            tmp = query(self.box).find(pname +'> .w2ui-panel-toolbar')
            if (p.show.toolbar) {
                if (tmp.find('[name='+ p.toolbar.name +']').length === 0 && p.toolbar != null) {
                    p.toolbar.render(tmp.get(0))
                } else {
                    p.toolbar.refresh()
                }
            } else {
                tmp.html('').removeClass('w2ui-toolbar').hide()
            }
            // show title
            tmp = query(self.box).find(pname +'> .w2ui-panel-title')
            if (p.title) {
                tmp.html(p.title).show()
            } else {
                tmp.html('').hide()
            }
        } else {
            if (query(self.box).find('#layout_'+ self.name +'_panel_main').length === 0) {
                self.render()
                return
            }
            self.resize()
            // refresh all of them
            for (let p1 = 0; p1 < this.panels.length; p1++) { self.refresh(this.panels[p1].type) }
        }
        edata.finish()
        return Date.now() - time
    }
    resize() {
        // if (window.getSelection) window.getSelection().removeAllRanges();    // clear selection
        if (!this.box) return false
        let time = Date.now()
        // event before
        let tmp   = this.last.resize
        let edata = this.trigger('resize', { target: this.name,
            panel: tmp ? tmp.type : 'all', diff_x: tmp ? tmp.diff_x : 0, diff_y: tmp ? tmp.diff_y : 0 })
        if (edata.isCancelled === true) return
        if (this.padding < 0) this.padding = 0
        // layout itself
        // width includes border and padding, we need to exclude that so panels
        // are sized correctly
        let width  = w2utils.getSize(query(this.box), 'width')
        let height = w2utils.getSize(query(this.box), 'height')
        let self = this
        // panels
        let pmain   = this.get('main')
        let pprev   = this.get('preview')
        let pleft   = this.get('left')
        let pright  = this.get('right')
        let ptop    = this.get('top')
        let pbottom = this.get('bottom')
        let sprev   = (pprev != null && pprev.hidden !== true ? true : false)
        let sleft   = (pleft != null && pleft.hidden !== true ? true : false)
        let sright  = (pright != null && pright.hidden !== true ? true : false)
        let stop    = (ptop != null && ptop.hidden !== true ? true : false)
        let sbottom = (pbottom != null && pbottom.hidden !== true ? true : false)
        let l, t, w, h
        // calculate %
        for (let p = 0; p < w2panels.length; p++) {
            if (w2panels[p] === 'main') continue
            tmp = this.get(w2panels[p])
            if (!tmp) continue
            let str = String(tmp.size || 0)
            if (str.substr(str.length-1) == '%') {
                let tmph = height
                if (tmp.type == 'preview') {
                    tmph = tmph -
                        (ptop && !ptop.hidden ? ptop.sizeCalculated : 0) -
                        (pbottom && !pbottom.hidden ? pbottom.sizeCalculated : 0)
                }
                tmp.sizeCalculated = parseInt((tmp.type == 'left' || tmp.type == 'right' ? width : tmph) * parseFloat(tmp.size) / 100)
            } else {
                tmp.sizeCalculated = parseInt(tmp.size)
            }
            tmp.sizeCalculated = Math.max(tmp.sizeCalculated, parseInt(tmp.minSize))
        }
        // negative size
        if (String(pright.size).substr(0, 1) == '-') {
            if (sleft && String(pleft.size).substr(0, 1) == '-') {
                console.log('ERROR: you cannot have both left panel.size and right panel.size be negative.')
            } else {
                pright.sizeCalculated = width - (sleft ? pleft.sizeCalculated : 0) + parseInt(pright.size)
            }
        }
        if (String(pleft.size).substr(0, 1) == '-') {
            if (sright && String(pright.size).substr(0, 1) == '-') {
                console.log('ERROR: you cannot have both left panel.size and right panel.size be negative.')
            } else {
                pleft.sizeCalculated = width - (sright ? pright.sizeCalculated : 0) + parseInt(pleft.size)
            }
        }
        // top if any
        if (ptop != null && ptop.hidden !== true) {
            l = 0
            t = 0
            w = width
            h = ptop.sizeCalculated
            query(this.box).find('#layout_'+ this.name +'_panel_top')
                .css({
                    'display': 'block',
                    'left': l + 'px',
                    'top': t + 'px',
                    'width': w + 'px',
                    'height': h + 'px'
                })
            ptop.width  = w
            ptop.height = h
            // resizer
            if (ptop.resizable) {
                t = ptop.sizeCalculated - (this.padding === 0 ? this.resizer : 0)
                h = (this.resizer > this.padding ? this.resizer : this.padding)
                query(this.box).find('#layout_'+ this.name +'_resizer_top')
                    .css({
                        'display': 'block',
                        'left': l + 'px',
                        'top': t + 'px',
                        'width': w + 'px',
                        'height': h + 'px',
                        'cursor': 'ns-resize'
                    })
                    .off('mousedown')
                    .on('mousedown', function(event) {
                        event.preventDefault()
                        // event before
                        let edata = self.trigger('resizerClick', { target: 'top', originalEvent: event })
                        if (edata.isCancelled === true) return
                        // default action
                        w2ui[self.name].last.events.resizeStart('top', event)
                        // event after
                        edata.finish()
                        return false
                    })
            }
        } else {
            query(this.box).find('#layout_'+ this.name +'_panel_top').hide()
            query(this.box).find('#layout_'+ this.name +'_resizer_top').hide()
        }
        // left if any
        if (pleft != null && pleft.hidden !== true) {
            l = 0
            t = 0 + (stop ? ptop.sizeCalculated + this.padding : 0)
            w = pleft.sizeCalculated
            h = height - (stop ? ptop.sizeCalculated + this.padding : 0) -
                    (sbottom ? pbottom.sizeCalculated + this.padding : 0)
            query(this.box).find('#layout_'+ this.name +'_panel_left')
                .css({
                    'display': 'block',
                    'left': l + 'px',
                    'top': t + 'px',
                    'width': w + 'px',
                    'height': h + 'px'
                })
            pleft.width  = w
            pleft.height = h
            // resizer
            if (pleft.resizable) {
                l = pleft.sizeCalculated - (this.padding === 0 ? this.resizer : 0)
                w = (this.resizer > this.padding ? this.resizer : this.padding)
                query(this.box).find('#layout_'+ this.name +'_resizer_left')
                    .css({
                        'display': 'block',
                        'left': l + 'px',
                        'top': t + 'px',
                        'width': w + 'px',
                        'height': h + 'px',
                        'cursor': 'ew-resize'
                    })
                    .off('mousedown')
                    .on('mousedown', function(event) {
                        event.preventDefault()
                        // event before
                        let edata = self.trigger('resizerClick', { target: 'left', originalEvent: event })
                        if (edata.isCancelled === true) return
                        // default action
                        w2ui[self.name].last.events.resizeStart('left', event)
                        // event after
                        edata.finish()
                        return false
                    })
            }
        } else {
            query(this.box).find('#layout_'+ this.name +'_panel_left').hide()
            query(this.box).find('#layout_'+ this.name +'_resizer_left').hide()
        }
        // right if any
        if (pright != null && pright.hidden !== true) {
            l = width - pright.sizeCalculated
            t = 0 + (stop ? ptop.sizeCalculated + this.padding : 0)
            w = pright.sizeCalculated
            h = height - (stop ? ptop.sizeCalculated + this.padding : 0) -
                (sbottom ? pbottom.sizeCalculated + this.padding : 0)
            query(this.box).find('#layout_'+ this.name +'_panel_right')
                .css({
                    'display': 'block',
                    'left': l + 'px',
                    'top': t + 'px',
                    'width': w + 'px',
                    'height': h + 'px'
                })
            pright.width  = w
            pright.height = h
            // resizer
            if (pright.resizable) {
                l = l - this.padding
                w = (this.resizer > this.padding ? this.resizer : this.padding)
                query(this.box).find('#layout_'+ this.name +'_resizer_right')
                    .css({
                        'display': 'block',
                        'left': l + 'px',
                        'top': t + 'px',
                        'width': w + 'px',
                        'height': h + 'px',
                        'cursor': 'ew-resize'
                    })
                    .off('mousedown')
                    .on('mousedown', function(event) {
                        event.preventDefault()
                        // event before
                        let edata = self.trigger('resizerClick', { target: 'right', originalEvent: event })
                        if (edata.isCancelled === true) return
                        // default action
                        w2ui[self.name].last.events.resizeStart('right', event)
                        // event after
                        edata.finish()
                        return false
                    })
            }
        } else {
            query(this.box).find('#layout_'+ this.name +'_panel_right').hide()
            query(this.box).find('#layout_'+ this.name +'_resizer_right').hide()
        }
        // bottom if any
        if (pbottom != null && pbottom.hidden !== true) {
            l = 0
            t = height - pbottom.sizeCalculated
            w = width
            h = pbottom.sizeCalculated
            query(this.box).find('#layout_'+ this.name +'_panel_bottom')
                .css({
                    'display': 'block',
                    'left': l + 'px',
                    'top': t + 'px',
                    'width': w + 'px',
                    'height': h + 'px'
                })
            pbottom.width  = w
            pbottom.height = h
            // resizer
            if (pbottom.resizable) {
                t = t - (this.padding === 0 ? 0 : this.padding)
                h = (this.resizer > this.padding ? this.resizer : this.padding)
                query(this.box).find('#layout_'+ this.name +'_resizer_bottom')
                    .css({
                        'display': 'block',
                        'left': l + 'px',
                        'top': t + 'px',
                        'width': w + 'px',
                        'height': h + 'px',
                        'cursor': 'ns-resize'
                    })
                    .off('mousedown')
                    .on('mousedown', function(event) {
                        event.preventDefault()
                        // event before
                        let edata = self.trigger('resizerClick', { target: 'bottom', originalEvent: event })
                        if (edata.isCancelled === true) return
                        // default action
                        w2ui[self.name].last.events.resizeStart('bottom', event)
                        // event after
                        edata.finish()
                        return false
                    })
            }
        } else {
            query(this.box).find('#layout_'+ this.name +'_panel_bottom').hide()
            query(this.box).find('#layout_'+ this.name +'_resizer_bottom').hide()
        }
        // main - always there
        l = 0 + (sleft ? pleft.sizeCalculated + this.padding : 0)
        t = 0 + (stop ? ptop.sizeCalculated + this.padding : 0)
        w = width - (sleft ? pleft.sizeCalculated + this.padding : 0) -
            (sright ? pright.sizeCalculated + this.padding: 0)
        h = height - (stop ? ptop.sizeCalculated + this.padding : 0) -
            (sbottom ? pbottom.sizeCalculated + this.padding : 0) -
            (sprev ? pprev.sizeCalculated + this.padding : 0)
        query(this.box)
            .find('#layout_'+ this.name +'_panel_main')
            .css({
                'display': 'block',
                'left': l + 'px',
                'top': t + 'px',
                'width': w + 'px',
                'height': h + 'px'
            })
        pmain.width  = w
        pmain.height = h
        // preview if any
        if (pprev != null && pprev.hidden !== true) {
            l = 0 + (sleft ? pleft.sizeCalculated + this.padding : 0)
            t = height - (sbottom ? pbottom.sizeCalculated + this.padding : 0) - pprev.sizeCalculated
            w = width - (sleft ? pleft.sizeCalculated + this.padding : 0) -
                (sright ? pright.sizeCalculated + this.padding : 0)
            h = pprev.sizeCalculated
            query(this.box).find('#layout_'+ this.name +'_panel_preview')
                .css({
                    'display': 'block',
                    'left': l + 'px',
                    'top': t + 'px',
                    'width': w + 'px',
                    'height': h + 'px'
                })
            pprev.width  = w
            pprev.height = h
            // resizer
            if (pprev.resizable) {
                t = t - (this.padding === 0 ? 0 : this.padding)
                h = (this.resizer > this.padding ? this.resizer : this.padding)
                query(this.box).find('#layout_'+ this.name +'_resizer_preview')
                    .css({
                        'display': 'block',
                        'left': l + 'px',
                        'top': t + 'px',
                        'width': w + 'px',
                        'height': h + 'px',
                        'cursor': 'ns-resize'
                    })
                    .off('mousedown')
                    .on('mousedown', function(event) {
                        event.preventDefault()
                        // event before
                        let edata = self.trigger('resizerClick', { target: 'preview', originalEvent: event })
                        if (edata.isCancelled === true) return
                        // default action
                        w2ui[self.name].last.events.resizeStart('preview', event)
                        // event after
                        edata.finish()
                        return false
                    })
            }
        } else {
            query(this.box).find('#layout_'+ this.name +'_panel_preview').hide()
            query(this.box).find('#layout_'+ this.name +'_resizer_preview').hide()
        }
        // display tabs and toolbar if needed
        for (let p1 = 0; p1 < w2panels.length; p1++) {
            let pan = this.get(w2panels[p1])
            let tmp2 = '#layout_'+ this.name +'_panel_'+ w2panels[p1] +' > .w2ui-panel-'
            let tabHeight = 0
            if (pan) {
                if (pan.title) {
                    let el = query(this.box).find(tmp2 + 'title').css({ top: tabHeight + 'px', display: 'block' })
                    tabHeight += w2utils.getSize(el, 'height')
                }
                if (pan.show.tabs) {
                    let el = query(this.box).find(tmp2 + 'tabs').css({ top: tabHeight + 'px', display: 'block' })
                    tabHeight += w2utils.getSize(el, 'height')
                }
                if (pan.show.toolbar) {
                    let el = query(this.box).find(tmp2 + 'toolbar').css({ top: tabHeight + 'px', display: 'block' })
                    tabHeight += w2utils.getSize(el, 'height')
                }
            }
            query(this.box).find(tmp2 + 'content').css({ display: 'block' }).css({ top: tabHeight + 'px' })
        }
        edata.finish()
        return Date.now() - time
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        if (w2ui[this.name] == null) return false
        // clean up
        if (query(this.box).find('#layout_'+ this.name +'_panel_main').length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-layout')
                .html('')
        }
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
        if (this.last.events && this.last.events.resize) {
            query(window).off('resize', this.last.events.resize)
        }
        return true
    }
    lock(panel, msg, showSpinner) {
        if (w2panels.indexOf(panel) == -1) {
            console.log('ERROR: First parameter needs to be the a valid panel name.')
            return
        }
        let args = Array.from(arguments)
        args[0]  = '#layout_'+ this.name + '_panel_' + panel
        w2utils.lock(...args)
    }
    unlock(panel, speed) {
        if (w2panels.indexOf(panel) == -1) {
            console.log('ERROR: First parameter needs to be the a valid panel name.')
            return
        }
        let nm = '#layout_'+ this.name + '_panel_' + panel
        w2utils.unlock(nm, speed)
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: jQuery, w2utils, w2base, w2toolbar, w2field
 *
 * == TODO ==
 *  - problem with .set() and arrays, array get extended too, but should be replaced
 *  - allow functions in routeData (also add routeData to list/enum)
 *  - send parsed URL to the event if there is routeData
 *  - add selectType: 'none' so that no selection can be make but with mouse
 *  - focus/blur for selectType = cell not display grayed out selection
 *  - allow enum in inline edit (see https://github.com/vitmalina/w2ui/issues/911#issuecomment-107341193)
 *  - remote source, but localSort/localSearch
 *  - promise for request, load, save, etc.
 *  - onloadmore event (so it will be easy to implement remote data source with local sort)
 *  - status() - clears on next select, etc. Should not if it is off
 *
 * == DEMOS To create ==
 *  - batch for disabled buttons
 *  - natural sort
 *  - resize on max content
 *
 * == 2.0 changes
 *  - toolbarInput - deprecated, toolbarSearch stays
 *  - searchSuggest
 *  - searchSave, searchSelected, savedSearches, defaultSearches, useLocalStorage, searchFieldTooltip
 *  - cache, cacheSave
 *  - onSearchSave, onSearchRemove, onSearchSelect
 *  - show.searchLogic
 *  - show.searchSave
 *  - refreshSearch
 *  - initAllFields -> searchInitInput
 *  - textSearch - deprecated in favor of defaultOperator
 *  - grid.confirm - refactored
 *  - grid.message - refactored
 *  - search.type == 'text' can have 'in' and 'not in' operators, then it will switch to enum
 *  - grid.find(..., displayedOnly)
 *  - column.render(..., this) - added
 *  - observeResize for the box
 *  - remove edit.type == 'select'
 *  - editDone(...)
 *  - liveSearch
 *  - deprecated onUnselect event
 *  - requestComplete(data, action, callBack, resolve, reject) - new argument list
 *  - msgAJAXError -> msgHTTPError
 *  - aded msgServerError
 *  - deleted grid.method
 *  - added mouseEnter/mouseLeave
 *  - grid.show.columnReorder -> grid.reorderRows
 *  - updagte docs search.label (not search.text)
 */

class w2grid extends w2base {
    constructor(options) {
        super(options.name)
        this.name         = null
        this.box          = null // HTML element that hold this element
        this.columns      = [] // { field, text, size, attr, render, hidden, gridMinWidth, editable }
        this.columnGroups = [] // { span: int, text: 'string', main: true/false, style: 'string' }
        this.records      = [] // { recid: int(required), field1: 'value1', ... fieldN: 'valueN', style: 'string',  changes: object }
        this.summary      = [] // array of summary records, same structure as records array
        this.searches     = [] // { type, label, field, attr, text, hidden }
        this.toolbar      = {} // if not empty object; then it is toolbar object
        this.ranges       = []
        this.contextMenu  = []
        this.searchMap    = {} // re-map search fields
        this.searchData   = []
        this.sortMap      = {} // re-map sort fields
        this.sortData     = []
        this.savedSearches   = []
        this.defaultSearches = []
        this.total        = 0 // server total
        this.recid        = null // field from records to be used as recid
        // internal
        this.last = {
            field     : '',         // last search field, e.g. 'all'
            label     : '',         // last search field label, e.g. 'All Fields'
            logic     : 'AND',      // last search logic, e.g. 'AND' or 'OR'
            search    : '',         // last search text
            searchIds : [],         // last search IDs
            selection : {           // last selection details
                indexes : [],
                columns : {}
            },
            saved_sel     : null,     // last result of selectionSave()
            multi         : false,    // last multi flag, true when searching for multiple fields
            scrollTop     : 0,        // last scrollTop position
            scrollLeft    : 0,        // last scrollLeft position
            colStart      : 0,        // for column virtual scrolling
            colEnd        : 0,        // for column virtual scrolling
            fetch: {
                action    : '',       // last fetch command, e.g. 'load'
                offset    : null,     // last fetch offset, integer
                start     : 0,        // timestamp of start of last fetch request
                response  : 0,        // time it took to complete the last fetch request in seconds
                options   : null,
                controller: null,
                loaded    : false,    // data is loaded from the server
                hasMore   : false     // flag to indicate if there are more items to pull from the server
            },
            pull_more     : false,
            pull_refresh  : true,
            range_start   : null,     // last range start cell
            range_end     : null,     // last range end cell
            sel_ind       : null,     // last selected cell index
            sel_col       : null,     // last selected column
            sel_type      : null,     // last selection type, e.g. 'click' or 'key'
            sel_recid     : null,     // last selected record id
            idCache       : {},       // object, id cache for get()
            move          : null,     // object, move details
            cancelClick   : null,     // boolean flag to indicate if the click event should be ignored, set during mouseMove()
            inEditMode    : false,    // flag to indicate if we're currently in edit mode during inline editing
            _edit         : null,     // object with details on the last edited cell, { value, index, column, recid }
            kbd_timer     : null,     // last id of blur() timer
            marker_timer  : null,     // last id of markSearch() timer
            click_time    : null,     // timestamp of last click
            click_recid   : null,     // last clicked record id
            bubbleEl      : null,     // last bubble element
            colResizing   : false,    // flag to indicate that a column is currently being resized
            tmp           : null,     // object with last column resizing details
            copy_event    : null,     // last copy event
            userSelect    : '',       // last user select type, e.g. 'text'
            columnDrag    : false,    // false or an object with a remove() method
            state         : null,     // last grid state
            show_extra    : 0,        // last show extra for virtual scrolling
            toolbar_height: 0,        // height of grid's toolbar
        }
        this.header            = ''
        this.url               = ''
        this.limit             = 100
        this.offset            = 0 // how many records to skip (for infinite scroll) when pulling from server
        this.postData          = {}
        this.routeData         = {}
        this.httpHeaders       = {}
        this.show              = {
            header          : false,
            toolbar         : false,
            footer          : false,
            columnMenu      : true,
            columnHeaders   : true,
            lineNumbers     : false,
            expandColumn    : false,
            selectColumn    : false,
            emptyRecords    : true,
            toolbarReload   : true,
            toolbarColumns  : false,
            toolbarSearch   : true,
            toolbarAdd      : false,
            toolbarEdit     : false,
            toolbarDelete   : false,
            toolbarSave     : false,
            searchAll       : true,
            searchLogic     : true,
            searchHiddenMsg : false,
            searchSave      : true,
            statusRange     : true,
            statusBuffered  : false,
            statusRecordID  : true,
            statusSelection : true,
            statusResponse  : true,
            statusSort      : false,
            statusSearch    : false,
            recordTitles    : false,
            selectionBorder : true,
            skipRecords     : true,
            saveRestoreState: true
        }
        this.stateId           = null // Custom state name for stateSave, stateRestore and stateReset
        this.hasFocus          = false
        this.autoLoad          = true // for infinite scroll
        this.fixedBody         = true // if false; then grid grows with data
        this.recordHeight      = 32
        this.lineNumberWidth   = 34
        this.keyboard          = true
        this.selectType        = 'row' // can be row|cell
        this.liveSearch        = false // if true, it will auto search if typed in search_all
        this.multiSearch       = true
        this.multiSelect       = true
        this.multiSort         = true
        this.reorderColumns    = false
        this.reorderRows       = false
        this.showExtraOnSearch = 0 // show extra records before and after on search
        this.markSearch        = true
        this.columnTooltip     = 'top|bottom' // can be top, bottom, left, right
        this.disableCVS        = false // disable Column Virtual Scroll
        this.nestedFields      = true // use field name containing dots as separator to look into object
        this.vs_start          = 150
        this.vs_extra          = 5
        this.style             = ''
        this.tabIndex          = null
        this.dataType          = null // if defined, then overwrites w2utils.settings.dataType
        this.parser            = null
        this.advanceOnEdit     = true // automatically begin editing the next cell after submitting an inline edit?
        this.useLocalStorage   = true
        // default values for the column
        this.colTemplate = {
            text           : '',    // column text (can be a function)
            field          : '',    // field name to map the column to a record
            size           : null,  // size of column in px or %
            min            : 20,    // minimum width of column in px
            max            : null,  // maximum width of column in px
            gridMinWidth   : null,  // minimum width of the grid when column is visible
            sizeCorrected  : null,  // read only, corrected size (see explanation below)
            sizeCalculated : null,  // read only, size in px (see explanation below)
            sizeOriginal   : null,  // size as defined
            sizeType       : null,  // px or %
            hidden         : false, // indicates if column is hidden
            sortable       : false, // indicates if column is sortable
            sortMode       : null,  // sort mode ('default'|'natural'|'i18n') or custom compare function
            searchable     : false, // bool/string: int,float,date,... or an object to create search field
            resizable      : true,  // indicates if column is resizable
            hideable       : true,  // indicates if column can be hidden
            autoResize     : null,  // indicates if column can be auto-resized by double clicking on the resizer
            attr           : '',    // string that will be inside the <td ... attr> tag
            style          : '',    // additional style for the td tag
            render         : null,  // string or render function
            title          : null,  // string or function for the title property for the column cells
            tooltip        : null,  // string for the title property for the column header
            editable       : {},    // editable object (see explanation below)
            frozen         : false, // indicates if the column is fixed to the left
            info           : null,  // info bubble, can be bool/object
            clipboardCopy  : false, // if true (or string or function), it will display clipboard copy icon
        }
        // these column properties will be saved in stateSave()
        this.stateColProps = {
            text            : false,
            field           : true,
            size            : true,
            min             : false,
            max             : false,
            gridMinWidth    : false,
            sizeCorrected   : false,
            sizeCalculated  : true,
            sizeOriginal    : true,
            sizeType        : true,
            hidden          : true,
            sortable        : false,
            sortMode        : true,
            searchable      : false,
            resizable       : false,
            hideable        : false,
            autoResize      : false,
            attr            : false,
            style           : false,
            render          : false,
            title           : false,
            tooltip         : false,
            editable        : false,
            frozen          : true,
            info            : false,
            clipboardCopy   : false
        }
        this.msgDelete     = 'Are you sure you want to delete ${count} ${records}?'
        this.msgNotJSON    = 'Returned data is not in valid JSON format.'
        this.msgHTTPError  = 'HTTP error. See console for more details.'
        this.msgServerError= 'Server error'
        this.msgRefresh    = 'Refreshing...'
        this.msgNeedReload = 'Your remote data source record count has changed, reloading from the first record.'
        this.msgEmpty      = '' // if not blank, then it is message when server returns no records
        this.buttons = {
            'reload'   : { type: 'button', id: 'w2ui-reload', icon: 'w2ui-icon-reload', tooltip: 'Reload data in the list' },
            'columns'  : { type: 'menu-check', id: 'w2ui-column-on-off', icon: 'w2ui-icon-columns', tooltip: 'Show/hide columns',
                overlay: { align: 'none' }
            },
            'search'   : { type: 'html', id: 'w2ui-search',
                html: '<div class="w2ui-icon w2ui-icon-search w2ui-search-down w2ui-action" data-click="searchShowFields"></div>'
            },
            'add'      : { type: 'button', id: 'w2ui-add', text: 'Add New', tooltip: 'Add new record', icon: 'w2ui-icon-plus' },
            'edit'     : { type: 'button', id: 'w2ui-edit', text: 'Edit', tooltip: 'Edit selected record', icon: 'w2ui-icon-pencil', batch: 1, disabled: true },
            'delete'   : { type: 'button', id: 'w2ui-delete', text: 'Delete', tooltip: 'Delete selected records', icon: 'w2ui-icon-cross', batch: true, disabled: true },
            'save'     : { type: 'button', id: 'w2ui-save', text: 'Save', tooltip: 'Save changed records', icon: 'w2ui-icon-check' }
        }
        this.operators = { // for search fields
            'text'    : ['is', 'begins', 'contains', 'ends'], // could have "in" and "not in"
            'number'  : ['=', 'between', '>', '<', '>=', '<='],
            'date'    : ['is', { oper: 'less', text: 'before'}, { oper: 'more', text: 'since' }, 'between'],
            'list'    : ['is'],
            'hex'     : ['is', 'between'],
            'color'   : ['is', 'begins', 'contains', 'ends'],
            'enum'    : ['in', 'not in']
            // -- all possible
            // "text"    : ['is', 'begins', 'contains', 'ends'],
            // "number"  : ['is', 'between', 'less:less than', 'more:more than', 'null:is null', 'not null:is not null'],
            // "list"    : ['is', 'null:is null', 'not null:is not null'],
            // "enum"    : ['in', 'not in', 'null:is null', 'not null:is not null']
        }
        this.defaultOperator = {
            'text'    : 'begins',
            'number'  : '=',
            'date'    : 'is',
            'list'    : 'is',
            'enum'    : 'in',
            'hex'     : 'begins',
            'color'   : 'begins'
        }
        // map search field type to operator
        this.operatorsMap = {
            'text'         : 'text',
            'int'          : 'number',
            'float'        : 'number',
            'money'        : 'number',
            'currency'     : 'number',
            'percent'      : 'number',
            'hex'          : 'hex',
            'alphanumeric' : 'text',
            'color'        : 'color',
            'date'         : 'date',
            'time'         : 'date',
            'datetime'     : 'date',
            'list'         : 'list',
            'combo'        : 'text',
            'enum'         : 'enum',
            'file'         : 'enum',
            'select'       : 'list',
            'radio'        : 'list',
            'checkbox'     : 'list',
            'toggle'       : 'list'
        }
        // events
        this.onAdd               = null
        this.onEdit              = null
        this.onRequest           = null // called on any server event
        this.onLoad              = null
        this.onDelete            = null
        this.onSave              = null
        this.onSelect            = null
        this.onClick             = null
        this.onDblClick          = null
        this.onContextMenu       = null
        this.onContextMenuClick  = null // when context menu item selected
        this.onColumnClick       = null
        this.onColumnDblClick    = null
        this.onColumnContextMenu = null
        this.onColumnResize      = null
        this.onColumnAutoResize  = null
        this.onSort              = null
        this.onSearch            = null
        this.onSearchOpen        = null
        this.onChange            = null // called when editable record is changed
        this.onRestore           = null // called when editable record is restored
        this.onExpand            = null
        this.onCollapse          = null
        this.onError             = null
        this.onKeydown           = null
        this.onToolbar           = null // all events from toolbar
        this.onColumnOnOff       = null
        this.onCopy              = null
        this.onPaste             = null
        this.onSelectionExtend   = null
        this.onEditField         = null
        this.onRender            = null
        this.onRefresh           = null
        this.onReload            = null
        this.onResize            = null
        this.onDestroy           = null
        this.onStateSave         = null
        this.onStateRestore      = null
        this.onFocus             = null
        this.onBlur              = null
        this.onReorderRow        = null
        this.onSearchSave        = null
        this.onSearchRemove      = null
        this.onSearchSelect      = null
        this.onColumnSelect      = null
        this.onColumnDragStart   = null
        this.onColumnDragEnd     = null
        this.onResizerDblClick   = null
        this.onMouseEnter        = null // mouse enter over record event
        this.onMouseLeave        = null
        // need deep merge, should be extend, not objectAssign
        w2utils.extend(this, options)
        // check if there are records without recid
        if (Array.isArray(this.records)) {
            let remove = [] // remove from records as they are summary
            this.records.forEach((rec, ind) => {
                if (rec[this.recid] != null) {
                    rec.recid = rec[this.recid]
                }
                if (rec.recid == null) {
                    console.log('ERROR: Cannot add records without recid. (obj: '+ this.name +')')
                }
                if (rec.w2ui?.summary === true) {
                    this.summary.push(rec)
                    remove.push(ind) // cannot remove here as it will mess up array walk thru
                }
            })
            remove.sort()
            for (let t = remove.length-1; t >= 0; t--) {
                this.records.splice(remove[t], 1)
            }
        }
        // add searches
        if (Array.isArray(this.columns)) {
            this.columns.forEach((col, ind) => {
                col = w2utils.extend({}, this.colTemplate, col)
                this.columns[ind] = col
                let search = col.searchable
                if (search == null || search === false || this.getSearch(col.field) != null) return
                if (w2utils.isPlainObject(search)) {
                    this.addSearch(w2utils.extend({ field: col.field, label: col.text, type: 'text' }, search))
                } else {
                    let stype = col.searchable
                    let attr  = ''
                    if (col.searchable === true) {
                        stype = 'text'
                        attr  = 'size="20"'
                    }
                    this.addSearch({ field: col.field, label: col.text, type: stype, attr: attr })
                }
            })
        }
        // add icon to default searches if not defined
        if (Array.isArray(this.defaultSearches)) {
            this.defaultSearches.forEach((search, ind) => {
                search.id = 'default-'+ ind
                search.icon ??= 'w2ui-icon-search'
            })
        }
        // check if there are saved searches in localStorage
        let data = this.cache('searches')
        if (Array.isArray(data)) {
            data.forEach(search => {
                this.savedSearches.push({
                    id: search.id ?? 'none',
                    text: search.text ?? 'none',
                    icon: 'w2ui-icon-search',
                    remove: true,
                    logic: search.logic ?? 'AND',
                    data: search.data ?? []
                })
            })
        }
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
    }
    add(record, first) {
        if (!Array.isArray(record)) record = [record]
        let added = 0
        for (let i = 0; i < record.length; i++) {
            let rec = record[i]
            if (rec[this.recid] != null) {
                rec.recid = rec[this.recid]
            }
            if (rec.recid == null) {
                console.log('ERROR: Cannot add record without recid. (obj: '+ this.name +')')
                continue
            }
            if (rec.w2ui?.summary === true) {
                if (first) this.summary.unshift(rec); else this.summary.push(rec)
            } else {
                if (first) this.records.unshift(rec); else this.records.push(rec)
            }
            added++
        }
        let url = this.url?.get ?? this.url
        if (!url) {
            this.total = this.records.length
            this.localSort(false, true)
            this.localSearch()
            // do not call this.refresh(), this is unnecessary, heavy, and messes with the toolbar.
            // this.refreshBody()
            // this.resizeRecords()
            this.refresh()
        } else {
            this.refresh() // ??  should it be reload?
        }
        return added
    }
    find(obj, returnIndex, displayedOnly) {
        if (obj == null) obj = {}
        let recs    = []
        let hasDots = false
        // check if property is nested - needed for speed
        for (let o in obj) if (String(o).indexOf('.') != -1) hasDots = true
        // look for an item
        let start = displayedOnly ? this.last.range_start : 0
        let end   = displayedOnly ? this.last.range_end + 1: this.records.length
        if (end > this.records.length) end = this.records.length
        for (let i = start; i < end; i++) {
            let match = true
            for (let o in obj) {
                let val = this.records[i][o]
                if (hasDots && String(o).indexOf('.') != -1) val = this.parseField(this.records[i], o)
                if (obj[o] == 'not-null') {
                    if (val == null || val === '') match = false
                } else {
                    if (obj[o] != val) match = false
                }
            }
            if (match && returnIndex !== true) recs.push(this.records[i].recid)
            if (match && returnIndex === true) recs.push(i)
        }
        return recs
    }
    set(recid, record, noRefresh) { // does not delete existing, but overrides on top of it
        if ((typeof recid == 'object') && (recid !== null)) {
            noRefresh = record
            record    = recid
            recid     = null
        }
        // update all records
        if (recid == null) {
            for (let i = 0; i < this.records.length; i++) {
                w2utils.extend(this.records[i], record) // recid is the whole record
            }
            if (noRefresh !== true) this.refresh()
        } else { // find record to update
            let ind = this.get(recid, true)
            if (ind == null) return false
            let isSummary = (this.records[ind] && this.records[ind].recid == recid ? false : true)
            if (isSummary) {
                w2utils.extend(this.summary[ind], record)
            } else {
                w2utils.extend(this.records[ind], record)
            }
            if (noRefresh !== true) this.refreshRow(recid, ind) // refresh only that record
        }
        return true
    }
    get(recid, returnIndex) {
        // search records
        if (Array.isArray(recid)) {
            let recs = []
            for (let i = 0; i < recid.length; i++) {
                let v = this.get(recid[i], returnIndex)
                if (v !== null)
                    recs.push(v)
            }
            return recs
        } else {
            // get() must be fast, implements a cache to bypass loop over all records
            // most of the time.
            let idCache = this.last.idCache
            if (!idCache) {
                this.last.idCache = idCache = {}
            }
            let i = idCache[recid]
            if (typeof(i) === 'number') {
                if (i >= 0 && i < this.records.length && this.records[i].recid == recid) {
                    if (returnIndex === true) return i; else return this.records[i]
                }
                // summary indexes are stored as negative numbers, try them now.
                i = ~i
                if (i >= 0 && i < this.summary.length && this.summary[i].recid == recid) {
                    if (returnIndex === true) return i; else return this.summary[i]
                }
                // wrong index returned, clear cache
                this.last.idCache = idCache = {}
            }
            for (let i = 0; i < this.records.length; i++) {
                if (this.records[i].recid == recid) {
                    idCache[recid] = i
                    if (returnIndex === true) return i; else return this.records[i]
                }
            }
            // search summary
            for (let i = 0; i < this.summary.length; i++) {
                if (this.summary[i].recid == recid) {
                    idCache[recid] = ~i
                    if (returnIndex === true) return i; else return this.summary[i]
                }
            }
            return null
        }
    }
    getFirst(offset) {
        if (this.records.length == 0) return null
        let rec = this.records[0]
        let tmp = this.last.searchIds
        if (this.searchData.length > 0) {
            if (Array.isArray(tmp) && tmp.length > 0) {
                rec = this.records[tmp[offset || 0]]
            } else {
                rec = null
            }
        }
        return rec
    }
    remove() {
        let removed = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.records.length-1; r >= 0; r--) {
                if (this.records[r].recid == arguments[a]) { this.records.splice(r, 1); removed++ }
            }
            for (let r = this.summary.length-1; r >= 0; r--) {
                if (this.summary[r].recid == arguments[a]) { this.summary.splice(r, 1); removed++ }
            }
        }
        let url = this.url?.get ?? this.url
        if (!url) {
            this.localSort(false, true)
            this.localSearch()
        }
        this.refresh()
        return removed
    }
    addColumn(before, columns) {
        let added = 0
        if (arguments.length == 1) {
            columns = before
            before  = this.columns.length
        } else {
            if (typeof before == 'string') before = this.getColumn(before, true)
            if (before == null) before = this.columns.length
        }
        if (!Array.isArray(columns)) columns = [columns]
        for (let i = 0; i < columns.length; i++) {
            let col = w2utils.extend({}, this.colTemplate, columns[i])
            this.columns.splice(before, 0, col)
            // if column is searchable, add search field
            if (columns[i].searchable) {
                let stype = columns[i].searchable
                let attr  = ''
                if (columns[i].searchable === true) { stype = 'text'; attr = 'size="20"' }
                this.addSearch({ field: columns[i].field, label: columns[i].text, type: stype, attr: attr })
            }
            before++
            added++
        }
        this.refresh()
        return added
    }
    removeColumn() {
        let removed = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.columns.length-1; r >= 0; r--) {
                if (this.columns[r].field == arguments[a]) {
                    if (this.columns[r].searchable) this.removeSearch(arguments[a])
                    this.columns.splice(r, 1)
                    removed++
                }
            }
        }
        this.refresh()
        return removed
    }
    getColumn(field, returnIndex) {
        // no arguments - return fields of all columns
        if (arguments.length === 0) {
            let ret = []
            for (let i = 0; i < this.columns.length; i++) ret.push(this.columns[i].field)
            return ret
        }
        // find column
        for (let i = 0; i < this.columns.length; i++) {
            if (this.columns[i].field == field) {
                if (returnIndex === true) return i; else return this.columns[i]
            }
        }
        return null
    }
    updateColumn(fields, updates) {
        let effected = 0
        fields = (Array.isArray(fields) ? fields : [fields])
        fields.forEach((colName) => {
            this.columns.forEach((col) => {
                if (col.field == colName) {
                    let _updates = w2utils.clone(updates)
                    Object.keys(_updates).forEach((key) => {
                        // if it is a function
                        if (typeof _updates[key] == 'function') {
                            _updates[key] = _updates[key](col)
                        }
                        if (col[key] != _updates[key]) effected++
                    })
                    w2utils.extend(col, _updates)
                }
            })
        })
        if (effected > 0) {
            this.refresh() // need full refresh due to colgroups not reassigning properly
        }
        return effected
    }
    toggleColumn() {
        return this.updateColumn(Array.from(arguments), { hidden(col) { return !col.hidden } })
    }
    showColumn() {
        return this.updateColumn(Array.from(arguments), { hidden: false })
    }
    hideColumn() {
        return this.updateColumn(Array.from(arguments), { hidden: true })
    }
    addSearch(before, search) {
        let added = 0
        if (arguments.length == 1) {
            search = before
            before = this.searches.length
        } else {
            if (typeof before == 'string') before = this.getSearch(before, true)
            if (before == null) before = this.searches.length
        }
        if (!Array.isArray(search)) search = [search]
        for (let i = 0; i < search.length; i++) {
            this.searches.splice(before, 0, search[i])
            before++
            added++
        }
        this.searchClose()
        return added
    }
    removeSearch() {
        let removed = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.searches.length-1; r >= 0; r--) {
                if (this.searches[r].field == arguments[a]) { this.searches.splice(r, 1); removed++ }
            }
        }
        this.searchClose()
        return removed
    }
    getSearch(field, returnIndex) {
        // no arguments - return fields of all searches
        if (arguments.length === 0) {
            let ret = []
            for (let i = 0; i < this.searches.length; i++) ret.push(this.searches[i].field)
            return ret
        }
        // find search
        for (let i = 0; i < this.searches.length; i++) {
            if (this.searches[i].field == field) {
                if (returnIndex === true) return i; else return this.searches[i]
            }
        }
        return null
    }
    toggleSearch() {
        let effected = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.searches.length-1; r >= 0; r--) {
                if (this.searches[r].field == arguments[a]) {
                    this.searches[r].hidden = !this.searches[r].hidden
                    effected++
                }
            }
        }
        this.searchClose()
        return effected
    }
    showSearch() {
        let shown = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.searches.length-1; r >= 0; r--) {
                if (this.searches[r].field == arguments[a] && this.searches[r].hidden !== false) {
                    this.searches[r].hidden = false
                    shown++
                }
            }
        }
        this.searchClose()
        return shown
    }
    hideSearch() {
        let hidden = 0
        for (let a = 0; a < arguments.length; a++) {
            for (let r = this.searches.length-1; r >= 0; r--) {
                if (this.searches[r].field == arguments[a] && this.searches[r].hidden !== true) {
                    this.searches[r].hidden = true
                    hidden++
                }
            }
        }
        this.searchClose()
        return hidden
    }
    getSearchData(field) {
        for (let i = 0; i < this.searchData.length; i++) {
            if (this.searchData[i].field == field) return this.searchData[i]
        }
        return null
    }
    localSort(silent, noResetRefresh) {
        let obj = this
        let url = this.url?.get ?? this.url
        if (url) {
            console.log('ERROR: grid.localSort can only be used on local data source, grid.url should be empty.')
            return
        }
        if (Object.keys(this.sortData).length === 0) return
        let time = Date.now()
        // process date fields
        this.selectionSave()
        this.prepareData()
        if (!noResetRefresh) {
            this.reset()
        }
        // process sortData
        for (let i = 0; i < this.sortData.length; i++) {
            let column = this.getColumn(this.sortData[i].field)
            if (!column) return // TODO: ability to sort columns when they are not part of colums array
            if (typeof column.render == 'string') {
                if (['date', 'age'].indexOf(column.render.split(':')[0]) != -1) {
                    this.sortData[i].field_ = column.field + '_'
                }
                if (['time'].indexOf(column.render.split(':')[0]) != -1) {
                    this.sortData[i].field_ = column.field + '_'
                }
            }
        }
        // prepare paths and process sort
        preparePaths()
        this.records.sort((a, b) => {
            return compareRecordPaths(a, b)
        })
        cleanupPaths()
        this.selectionRestore(noResetRefresh)
        time = Date.now() - time
        if (silent !== true && this.show.statusSort) {
            setTimeout(() => {
                this.status(w2utils.lang('Sorting took ${count} seconds', { count: time/1000 }))
            }, 10)
        }
        return time
        // grab paths before sorting for efficiency and because calling obj.get()
        // while sorting 'obj.records' is unsafe, at least on webkit
        function preparePaths() {
            for (let i = 0; i < obj.records.length; i++) {
                let rec = obj.records[i]
                if (rec.w2ui?.parent_recid != null) {
                    rec.w2ui._path = getRecordPath(rec)
                }
            }
        }
        // cleanup and release memory allocated by preparePaths()
        function cleanupPaths() {
            for (let i = 0; i < obj.records.length; i++) {
                let rec = obj.records[i]
                if (rec.w2ui?.parent_recid != null) {
                    rec.w2ui._path = null
                }
            }
        }
        // compare two paths, from root of tree to given records
        function compareRecordPaths(a, b) {
            if ((!a.w2ui || a.w2ui.parent_recid == null) && (!b.w2ui || b.w2ui.parent_recid == null)) {
                return compareRecords(a, b) // no tree, fast path
            }
            let pa = getRecordPath(a)
            let pb = getRecordPath(b)
            for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
                let diff = compareRecords(pa[i], pb[i])
                if (diff !== 0) return diff // different subpath
            }
            if (pa.length > pb.length) return 1
            if (pa.length < pb.length) return -1
            console.log('ERROR: two paths should not be equal.')
            return 0
        }
        // return an array of all records from root to and including 'rec'
        function getRecordPath(rec) {
            if (!rec.w2ui || rec.w2ui.parent_recid == null) return [rec]
            if (rec.w2ui._path)
                return rec.w2ui._path
            // during actual sort, we should never reach this point
            let subrec = obj.get(rec.w2ui.parent_recid)
            if (!subrec) {
                console.log('ERROR: no parent record: ' + rec.w2ui.parent_recid)
                return [rec]
            }
            return (getRecordPath(subrec).concat(rec))
        }
        // compare two records according to sortData and finally recid
        function compareRecords(a, b) {
            if (a === b) return 0 // optimize, same object
            for (let i = 0; i < obj.sortData.length; i++) {
                let fld     = obj.sortData[i].field
                let sortFld = (obj.sortData[i].field_) ? obj.sortData[i].field_ : fld
                let aa      = a[sortFld]
                let bb      = b[sortFld]
                if (String(fld).indexOf('.') != -1) {
                    aa = obj.parseField(a, sortFld)
                    bb = obj.parseField(b, sortFld)
                }
                let col = obj.getColumn(fld)
                if (col && Object.keys(col.editable).length > 0) { // for drop editable fields and drop downs
                    if (w2utils.isPlainObject(aa) && aa.text) aa = aa.text
                    if (w2utils.isPlainObject(bb) && bb.text) bb = bb.text
                }
                let ret = compareCells(aa, bb, i, obj.sortData[i].direction, col.sortMode || 'default')
                if (ret !== 0) return ret
            }
            // break tie for similar records,
            // required to have consistent ordering for tree paths
            let ret = compareCells(a.recid, b.recid, -1, 'asc')
            return ret
        }
        // compare two values, aa and bb, producing consistent ordering
        function compareCells(aa, bb, i, direction, sortMode) {
            // if both objects are strictly equal, we're done
            if (aa === bb)
                return 0
            // all nulls, empty and undefined on bottom
            if ((aa == null || aa === '') && (bb != null && bb !== ''))
                return 1
            if ((aa != null && aa !== '') && (bb == null || bb === ''))
                return -1
            let dir = (direction.toLowerCase() === 'asc') ? 1 : -1
            // for different kind of objects, sort by object type
            if (typeof aa != typeof bb)
                return (typeof aa > typeof bb) ? dir : -dir
            // for different kind of classes, sort by classes
            if (aa.constructor.name != bb.constructor.name)
                return (aa.constructor.name > bb.constructor.name) ? dir : -dir
            // if we're dealing with non-null objects, call valueOf().
            // this mean that Date() or custom objects will compare properly.
            if (aa && typeof aa == 'object')
                aa = aa.valueOf()
            if (bb && typeof bb == 'object')
                bb = bb.valueOf()
            // if we're still dealing with non-null objects that have
            // a useful Object => String conversion, convert to string.
            let defaultToString = {}.toString
            if (aa && typeof aa == 'object' && aa.toString != defaultToString)
                aa = String(aa)
            if (bb && typeof bb == 'object' && bb.toString != defaultToString)
                bb = String(bb)
            // do case-insensitive string comparison
            if (typeof aa == 'string')
                aa = aa.toLowerCase().trim()
            if (typeof bb == 'string')
                bb = bb.toLowerCase().trim()
            switch (sortMode) {
                case 'natural':
                    sortMode = w2utils.naturalCompare
                    break
                case 'i18n':
                    sortMode = w2utils.i18nCompare
                    break
            }
            if (typeof sortMode == 'function') {
                return sortMode(aa,bb) * dir
            }
            // compare both objects
            if (aa > bb)
                return dir
            if (aa < bb)
                return -dir
            return 0
        }
    }
    localSearch(silent) {
        let obj = this
        let url = this.url?.get ?? this.url
        if (url) {
            console.log('ERROR: grid.localSearch can only be used on local data source, grid.url should be empty.')
            return
        }
        let time            = Date.now()
        let defaultToString = {}.toString
        let duplicateMap    = {}
        this.total          = this.records.length
        // mark all records as shown
        this.last.searchIds = []
        // prepare date/time fields
        this.prepareData()
        // hide records that did not match
        if (this.searchData.length > 0 && !url) {
            this.total = 0
            for (let i = 0; i < this.records.length; i++) {
                let rec   = this.records[i]
                let match = searchRecord(rec)
                if (match) {
                    if (rec?.w2ui) addParent(rec.w2ui.parent_recid)
                    if (this.showExtraOnSearch > 0) {
                        let before = this.showExtraOnSearch
                        let after  = this.showExtraOnSearch
                        if (i < before) before = i
                        if (i + after > this.records.length) after = this.records.length - i
                        if (before > 0) {
                            for (let j = i - before; j < i; j++) {
                                if (this.last.searchIds.indexOf(j) < 0)
                                    this.last.searchIds.push(j)
                            }
                        }
                        if (this.last.searchIds.indexOf(i) < 0) this.last.searchIds.push(i)
                        if (after > 0) {
                            for (let j = (i + 1) ; j <= (i + after) ; j++) {
                                if (this.last.searchIds.indexOf(j) < 0) this.last.searchIds.push(j)
                            }
                        }
                    } else {
                        this.last.searchIds.push(i)
                    }
                }
            }
            this.total = this.last.searchIds.length
        }
        time = Date.now() - time
        if (silent !== true && this.show.statusSearch) {
            setTimeout(() => {
                this.status(w2utils.lang('Search took ${count} seconds', { count: time/1000 }))
            }, 10)
        }
        return time
        // check if a record (or one of its closed children) matches the search data
        function searchRecord(rec) {
            let fl      = 0, val1, val2, val3, tmp
            let orEqual = false
            for (let j = 0; j < obj.searchData.length; j++) {
                let sdata  = obj.searchData[j]
                let search = obj.getSearch(sdata.field)
                if (sdata == null) continue
                if (search == null) search = { field: sdata.field, type: sdata.type }
                let val1b = obj.parseField(rec, search.field)
                val1      = (val1b !== null && val1b !== undefined &&
                    (typeof val1b != 'object' || val1b.toString != defaultToString)) ?
                    String(val1b).toLowerCase() : '' // do not match a bogus string
                if (sdata.value != null) {
                    if (!Array.isArray(sdata.value)) {
                        val2 = String(sdata.value).toLowerCase()
                    } else {
                        val2 = sdata.value[0]
                        val3 = sdata.value[1]
                    }
                }
                switch (sdata.operator) {
                    case '=':
                    case 'is':
                        if (obj.parseField(rec, search.field) == sdata.value) fl++ // do not hide record
                        else if (search.type == 'date') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatDate(tmp, 'yyyy-mm-dd')
                            val2 = w2utils.formatDate(w2utils.isDate(val2, w2utils.settings.dateFormat, true), 'yyyy-mm-dd')
                            if (val1 == val2) fl++
                        }
                        else if (search.type == 'time') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatTime(tmp, 'hh24:mi')
                            val2 = w2utils.formatTime(val2, 'hh24:mi')
                            if (val1 == val2) fl++
                        }
                        else if (search.type == 'datetime') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatDateTime(tmp, 'yyyy-mm-dd|hh24:mm:ss')
                            val2 = w2utils.formatDateTime(w2utils.isDateTime(val2, w2utils.settings.datetimeFormat, true), 'yyyy-mm-dd|hh24:mm:ss')
                            if (val1 == val2) fl++
                        }
                        break
                    case 'between':
                        if (['int', 'float', 'money', 'currency', 'percent'].indexOf(search.type) != -1) {
                            if (parseFloat(obj.parseField(rec, search.field)) >= parseFloat(val2) && parseFloat(obj.parseField(rec, search.field)) <= parseFloat(val3)) fl++
                        }
                        else if (search.type == 'date') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.isDate(tmp, w2utils.settings.dateFormat, true)
                            val2 = w2utils.isDate(val2, w2utils.settings.dateFormat, true)
                            val3 = w2utils.isDate(val3, w2utils.settings.dateFormat, true)
                            if (val3 != null) val3 = new Date(val3.getTime() + 86400000) // 1 day
                            if (val1 >= val2 && val1 < val3) fl++
                        }
                        else if (search.type == 'time') {
                            val1 = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val2 = w2utils.isTime(val2, true)
                            val3 = w2utils.isTime(val3, true)
                            val2 = (new Date()).setHours(val2.hours, val2.minutes, val2.seconds ? val2.seconds : 0, 0)
                            val3 = (new Date()).setHours(val3.hours, val3.minutes, val3.seconds ? val3.seconds : 0, 0)
                            if (val1 >= val2 && val1 < val3) fl++
                        }
                        else if (search.type == 'datetime') {
                            val1 = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val2 = w2utils.isDateTime(val2, w2utils.settings.datetimeFormat, true)
                            val3 = w2utils.isDateTime(val3, w2utils.settings.datetimeFormat, true)
                            if (val3) val3 = new Date(val3.getTime() + 86400000) // 1 day
                            if (val1 >= val2 && val1 < val3) fl++
                        }
                        break
                    case '<=':
                        orEqual = true
                    case '<':
                    case 'less':
                        if (['int', 'float', 'money', 'currency', 'percent'].indexOf(search.type) != -1) {
                            val1 = parseFloat(obj.parseField(rec, search.field))
                            val2 = parseFloat(sdata.value)
                            if (val1 < val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'date') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.isDate(tmp, w2utils.settings.dateFormat, true)
                            val2 = w2utils.isDate(val2, w2utils.settings.dateFormat, true)
                            if (val1 < val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'time') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatTime(tmp, 'hh24:mi')
                            val2 = w2utils.formatTime(val2, 'hh24:mi')
                            if (val1 < val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'datetime') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatDateTime(tmp, 'yyyy-mm-dd|hh24:mm:ss')
                            val2 = w2utils.formatDateTime(w2utils.isDateTime(val2, w2utils.settings.datetimeFormat, true), 'yyyy-mm-dd|hh24:mm:ss')
                            if (val1.length == val2.length && (val1 < val2 || (orEqual && val1 === val2))) fl++
                        }
                        break
                    case '>=':
                        orEqual = true
                    case '>':
                    case 'more':
                        if (['int', 'float', 'money', 'currency', 'percent'].indexOf(search.type) != -1) {
                            val1 = parseFloat(obj.parseField(rec, search.field))
                            val2 = parseFloat(sdata.value)
                            if (val1 > val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'date') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.isDate(tmp, w2utils.settings.dateFormat, true)
                            val2 = w2utils.isDate(val2, w2utils.settings.dateFormat, true)
                            if (val1 > val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'time') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatTime(tmp, 'hh24:mi')
                            val2 = w2utils.formatTime(val2, 'hh24:mi')
                            if (val1 > val2 || (orEqual && val1 === val2)) fl++
                        }
                        else if (search.type == 'datetime') {
                            tmp  = (obj.parseField(rec, search.field + '_') instanceof Date ? obj.parseField(rec, search.field + '_') : obj.parseField(rec, search.field))
                            val1 = w2utils.formatDateTime(tmp, 'yyyy-mm-dd|hh24:mm:ss')
                            val2 = w2utils.formatDateTime(w2utils.isDateTime(val2, w2utils.settings.datetimeFormat, true), 'yyyy-mm-dd|hh24:mm:ss')
                            if (val1.length == val2.length && (val1 > val2 || (orEqual && val1 === val2))) fl++
                        }
                        break
                    case 'in':
                        tmp = sdata.value
                        if (sdata.svalue) tmp = sdata.svalue
                        if ((tmp.indexOf(w2utils.isFloat(val1b) ? parseFloat(val1b) : val1b) !== -1) || tmp.indexOf(val1) !== -1) fl++
                        break
                    case 'not in':
                        tmp = sdata.value
                        if (sdata.svalue) tmp = sdata.svalue
                        if (!((tmp.indexOf(w2utils.isFloat(val1b) ? parseFloat(val1b) : val1b) !== -1) || tmp.indexOf(val1) !== -1)) fl++
                        break
                    case 'begins':
                    case 'begins with': // need for back compatibility
                        if (val1.indexOf(val2) === 0) fl++ // do not hide record
                        break
                    case 'contains':
                        if (val1.indexOf(val2) >= 0) fl++ // do not hide record
                        break
                    case 'null':
                        if (obj.parseField(rec, search.field) == null) fl++ // do not hide record
                        break
                    case 'not null':
                        if (obj.parseField(rec, search.field) != null) fl++ // do not hide record
                        break
                    case 'ends':
                    case 'ends with': // need for back compatibility
                        let lastIndex = val1.lastIndexOf(val2)
                        if (lastIndex !== -1 && lastIndex == val1.length - val2.length) fl++ // do not hide record
                        break
                }
            }
            if ((obj.last.logic == 'OR' && fl !== 0) ||
                (obj.last.logic == 'AND' && fl == obj.searchData.length))
                return true
            if (rec.w2ui?.children && rec.w2ui?.expanded !== true) {
                // there are closed children, search them too.
                for (let r = 0; r < rec.w2ui.children.length; r++) {
                    let subRec = rec.w2ui.children[r]
                    if (searchRecord(subRec))
                        return true
                }
            }
            return false
        }
        // add parents nodes recursively
        function addParent(recid) {
            let i = obj.get(recid, true)
            if (i == null || recid == null || duplicateMap[recid] || obj.last.searchIds.includes(i)) {
                return
            }
            duplicateMap[recid] = true
            let rec = obj.records[i]
            if (rec?.w2ui) {
                addParent(rec.w2ui.parent_recid)
            }
            obj.last.searchIds.push(i)
        }
    }
    getRangeData(range, extra) {
        let rec1 = this.get(range[0].recid, true)
        let rec2 = this.get(range[1].recid, true)
        let col1 = range[0].column
        let col2 = range[1].column
        let res = []
        if (col1 == col2) { // one row
            for (let r = rec1; r <= rec2; r++) {
                let record = this.records[r]
                let dt     = record[this.columns[col1].field] || null
                if (extra !== true) {
                    res.push(dt)
                } else {
                    res.push({ data: dt, column: col1, index: r, record: record })
                }
            }
        } else if (rec1 == rec2) { // one line
            let record = this.records[rec1]
            for (let i = col1; i <= col2; i++) {
                let dt = record[this.columns[i].field] || null
                if (extra !== true) {
                    res.push(dt)
                } else {
                    res.push({ data: dt, column: i, index: rec1, record: record })
                }
            }
        } else {
            for (let r = rec1; r <= rec2; r++) {
                let record = this.records[r]
                res.push([])
                for (let i = col1; i <= col2; i++) {
                    let dt = record[this.columns[i].field]
                    if (extra !== true) {
                        res[res.length-1].push(dt)
                    } else {
                        res[res.length-1].push({ data: dt, column: i, index: r, record: record })
                    }
                }
            }
        }
        return res
    }
    addRange(ranges) {
        let added = 0, first, last
        if (this.selectType == 'row') return added
        if (!Array.isArray(ranges)) ranges = [ranges]
        // if it is selection
        for (let i = 0; i < ranges.length; i++) {
            if (typeof ranges[i] != 'object') ranges[i] = { name: 'selection' }
            if (ranges[i].name == 'selection') {
                if (this.show.selectionBorder === false) continue
                let sel = this.getSelection()
                if (sel.length === 0) {
                    this.removeRange('selection')
                    continue
                } else {
                    first = sel[0]
                    last  = sel[sel.length-1]
                }
            } else { // other range
                first = ranges[i].range[0]
                last  = ranges[i].range[1]
            }
            if (first) {
                let rg = {
                    name: ranges[i].name,
                    range: [{ recid: first.recid, column: first.column }, { recid: last.recid, column: last.column }],
                    style: ranges[i].style || ''
                }
                // add range
                let ind = false
                for (let j = 0; j < this.ranges.length; j++) if (this.ranges[j].name == ranges[i].name) { ind = j; break }
                if (ind !== false) {
                    this.ranges[ind] = rg
                } else {
                    this.ranges.push(rg)
                }
                added++
            }
        }
        this.refreshRanges()
        return added
    }
    removeRange() {
        let removed = 0
        for (let a = 0; a < arguments.length; a++) {
            let name = arguments[a]
            query(this.box).find('#grid_'+ this.name +'_'+ name).remove()
            query(this.box).find('#grid_'+ this.name +'_f'+ name).remove()
            for (let r = this.ranges.length-1; r >= 0; r--) {
                if (this.ranges[r].name == name) {
                    this.ranges.splice(r, 1)
                    removed++
                }
            }
        }
        return removed
    }
    refreshRanges() {
        if (this.ranges.length === 0) return
        let self = this
        let range
        let time = Date.now()
        let rec1 = query(this.box).find(`#grid_${this.name}_frecords`)
        let rec2 = query(this.box).find(`#grid_${this.name}_records`)
        for (let i = 0; i < this.ranges.length; i++) {
            let rg    = this.ranges[i]
            let first = rg.range[0]
            let last  = rg.range[1]
            if (first.index == null) first.index = this.get(first.recid, true)
            if (last.index == null) last.index = this.get(last.recid, true)
            let td1  = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(first.recid) + ' td[col="'+ first.column +'"]')
            let td2  = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(last.recid) + ' td[col="'+ last.column +'"]')
            let td1f = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(first.recid) + ' td[col="'+ first.column +'"]')
            let td2f = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(last.recid) + ' td[col="'+ last.column +'"]')
            let _lastColumn = last.column
            // adjustment due to column virtual scroll
            if (first.column < this.last.colStart && last.column > this.last.colStart) {
                td1 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(first.recid) + ' td[col="start"]')
            }
            if (first.column < this.last.colEnd && last.column > this.last.colEnd) {
                td2 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(last.recid) + ' td[col="end"]')
                _lastColumn = '"end"'
            }
            // if virtual scrolling kicked in
            let index_top     = parseInt(query(this.box).find('#grid_'+ this.name +'_rec_top').next().attr('index'))
            let index_bottom  = parseInt(query(this.box).find('#grid_'+ this.name +'_rec_bottom').prev().attr('index'))
            let index_ftop    = parseInt(query(this.box).find('#grid_'+ this.name +'_frec_top').next().attr('index'))
            let index_fbottom = parseInt(query(this.box).find('#grid_'+ this.name +'_frec_bottom').prev().attr('index'))
            if (td1.length === 0 && first.index < index_top && last.index > index_top) {
                td1 = query(this.box).find('#grid_'+ this.name +'_rec_top').next().find('td[col="'+ first.column +'"]')
            }
            if (td2.length === 0 && last.index > index_bottom && first.index < index_bottom) {
                td2 = query(this.box).find('#grid_'+ this.name +'_rec_bottom').prev().find('td[col="'+ _lastColumn +'"]')
            }
            if (td1f.length === 0 && first.index < index_ftop && last.index > index_ftop) { // frozen
                td1f = query(this.box).find('#grid_'+ this.name +'_frec_top').next().find('td[col="'+ first.column +'"]')
            }
            if (td2f.length === 0 && last.index > index_fbottom && first.index < index_fbottom) { // frozen
                td2f = query(this.box).find('#grid_'+ this.name +'_frec_bottom').prev().find('td[col="'+ last.column +'"]')
            }
            // do not show selection cell if it is editable
            let edit = query(this.box).find('#grid_'+ this.name + '_editable')
            let tmp  = edit.find('.w2ui-input')
            let tmp1 = tmp.attr('recid')
            let tmp2 = tmp.attr('column')
            if (rg.name == 'selection' && rg.range[0].recid == tmp1 && rg.range[0].column == tmp2) continue
            // frozen regular columns range
            range = query(this.box).find('#grid_'+ this.name +'_f'+ rg.name)
            if (td1f.length > 0 || td2f.length > 0) {
                if (range.length === 0) {
                    rec1.append('<div id="grid_'+ this.name +'_f' + rg.name +'" class="w2ui-selection" style="'+ rg.style +'">'+
                                    (rg.name == 'selection' ? '<div id="grid_'+ this.name +'_resizer" class="w2ui-selection-resizer"></div>' : '')+
                                '</div>')
                    range = query(this.box).find('#grid_'+ this.name +'_f'+ rg.name)
                } else {
                    range.attr('style', rg.style)
                    range.find('.w2ui-selection-resizer').show()
                }
                if (td2f.length === 0) {
                    td2f = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(last.recid) +' td:last-child')
                    if (td2f.length === 0) td2f = query(this.box).find('#grid_'+ this.name +'_frec_bottom td:first-child')
                    range.css('border-right', '0px')
                    range.find('.w2ui-selection-resizer').hide()
                }
                if (first.recid != null && last.recid != null && td1f.length > 0 && td2f.length > 0) {
                    let style = getComputedStyle(td2f[0])
                    let top1  = (td1f.prop('offsetTop') - td1f.prop('scrollTop'))
                    let left1 = (td1f.prop('offsetLeft') + td1f.prop('scrollLeft'))
                    let top2  = (td2f.prop('offsetTop') - td2f.prop('scrollTop'))
                    let left2 = (td2f.prop('offsetLeft') + td2f.prop('scrollLeft'))
                    range.show().css({
                        top     : (top1 > 0 ? top1 : 0) + 'px',
                        left    : (left1 > 0 ? left1 : 0) + 'px',
                        width   : (left2 - left1 + parseFloat(style.width) + 2) + 'px',
                        height  : (top2 - top1 + parseFloat(style.height) + 1) + 'px'
                    })
                } else {
                    range.hide()
                }
            } else {
                range.hide()
            }
            // regular columns range
            range = query(this.box).find('#grid_'+ this.name +'_'+ rg.name)
            if (td1.length > 0 || td2.length > 0) {
                if (range.length === 0) {
                    rec2.append('<div id="grid_'+ this.name +'_' + rg.name +'" class="w2ui-selection" style="'+ rg.style +'">'+
                                    (rg.name == 'selection' ? '<div id="grid_'+ this.name +'_resizer" class="w2ui-selection-resizer"></div>' : '')+
                                '</div>')
                    range = query(this.box).find('#grid_'+ this.name +'_'+ rg.name)
                } else {
                    range.attr('style', rg.style)
                }
                if (td1.length === 0) {
                    td1 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(first.recid) +' td:first-child')
                    if (td1.length === 0) td1 = query(this.box).find('#grid_'+ this.name +'_rec_top td:first-child')
                }
                if (td2f.length !== 0) {
                    range.css('border-left', '0px')
                }
                if (first.recid != null && last.recid != null && td1.length > 0 && td2.length > 0) {
                    let style = getComputedStyle(td2[0])
                    let top1  = (td1.prop('offsetTop') - td1.prop('scrollTop'))
                    let left1 = (td1.prop('offsetLeft') + td1.prop('scrollLeft'))
                    let top2  = (td2.prop('offsetTop') - td2.prop('scrollTop'))
                    let left2 = (td2.prop('offsetLeft') + td2.prop('scrollLeft'))
                    range.show().css({
                        top     : (top1 > 0 ? top1 : 0) + 'px',
                        left    : (left1 > 0 ? left1 : 0) + 'px',
                        width   : (left2 - left1 + parseFloat(style.width) + 2) + 'px',
                        height  : (top2 - top1 + parseFloat(style.height) + 1) + 'px'
                    })
                } else {
                    range.hide()
                }
            } else {
                range.hide()
            }
        }
        // add resizer events
        query(this.box).find('.w2ui-selection-resizer')
            .off('.resizer')
            .on('mousedown.resizer', mouseStart)
            .on('dblclick.resizer', (event) => {
                let edata = this.trigger('resizerDblClick', { target: this.name, originalEvent: event })
                if (edata.isCancelled === true) return
                edata.finish()
            })
        let edata = { target: this.name, originalRange: null, newRange: null }
        return Date.now() - time
        function mouseStart(event) {
            let sel = self.getSelection()
            self.last.move = {
                type   : 'expand',
                x      : event.screenX,
                y      : event.screenY,
                divX   : 0,
                divY   : 0,
                recid  : sel[0].recid,
                column : sel[0].column,
                originalRange : [w2utils.clone(sel[0]), w2utils.clone(sel[sel.length-1]) ],
                newRange      : [w2utils.clone(sel[0]), w2utils.clone(sel[sel.length-1]) ]
            }
            query('body')
                .off('.w2ui-' + self.name)
                .on('mousemove.w2ui-' + self.name, mouseMove)
                .on('mouseup.w2ui-' + self.name, mouseStop)
            // do not blur grid
            event.preventDefault()
        }
        function mouseMove(event) {
            let mv = self.last.move
            if (!mv || mv.type != 'expand') return
            mv.divX = (event.screenX - mv.x)
            mv.divY = (event.screenY - mv.y)
            // find new cell
            let recid, column
            let tmp = event.target
            if (tmp.tagName.toUpperCase() != 'TD') tmp = query(tmp).closest('td')[0]
            if (query(tmp).attr('col') != null) column = parseInt(query(tmp).attr('col'))
            if (column == null) {
                return
            }
            tmp   = query(tmp).closest('tr')[0]
            recid = self.records[query(tmp).attr('index')].recid
            // new range
            if (mv.newRange[1].recid == recid && mv.newRange[1].column == column) return
            let prevNewRange = w2utils.clone(mv.newRange)
            mv.newRange      = [{ recid: mv.recid, column: mv.column }, { recid: recid, column: column }]
            // event before
            if (edata.detail) {
                edata.detail.newRange = w2utils.clone(mv.newRange)
                edata.detail.originalRange = w2utils.clone(mv.originalRange)
            }
            edata = self.trigger('selectionExtend', edata)
            if (edata.isCancelled === true) {
                mv.newRange = prevNewRange
                edata.detail.newRange = prevNewRange
                return
            } else {
                // default behavior
                self.removeRange('grid-selection-expand')
                self.addRange({
                    name  : 'grid-selection-expand',
                    range : mv.newRange,
                    style : 'background-color: rgba(100,100,100,0.1); border: 2px dotted rgba(100,100,100,0.5);'
                })
            }
        }
        function mouseStop(event) {
            // default behavior
            self.removeRange('grid-selection-expand')
            delete self.last.move
            query('body').off('.w2ui-' + self.name)
            // event after
            if (edata.finish) edata.finish()
        }
    }
    select() {
        if (arguments.length === 0) return 0
        let selected = 0
        let sel = this.last.selection
        if (!this.multiSelect) this.selectNone(true)
        // if too many arguments > 150k, then it errors off
        let args = Array.from(arguments)
        if (Array.isArray(args[0])) args = args[0]
        // event before
        let tmp = { target: this.name }
        if (args.length == 1) {
            tmp.multiple = false
            if (w2utils.isPlainObject(args[0])) {
                tmp.clicked = {
                    recid: args[0].recid,
                    column: args[0].column
                }
            } else {
                tmp.recid = args[0]
            }
        } else {
            tmp.multiple = true
            tmp.clicked = { recids:  args }
        }
        let edata = this.trigger('select', tmp)
        if (edata.isCancelled === true) return 0
        // default action
        if (this.selectType == 'row') {
            for (let a = 0; a < args.length; a++) {
                let recid = typeof args[a] == 'object' ? args[a].recid : args[a]
                let index = this.get(recid, true)
                if (index == null) continue
                let recEl1 = null
                let recEl2 = null
                if (this.searchData.length !== 0 || (index + 1 >= this.last.range_start && index + 1 <= this.last.range_end)) {
                    recEl1 = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid))
                    recEl2 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(recid))
                }
                if (this.selectType == 'row') {
                    if (sel.indexes.indexOf(index) != -1) continue
                    sel.indexes.push(index)
                    if (recEl1 && recEl2) {
                        recEl1.addClass('w2ui-selected').find('.w2ui-col-number').addClass('w2ui-row-selected')
                        recEl2.addClass('w2ui-selected').find('.w2ui-col-number').addClass('w2ui-row-selected')
                        recEl1.find('.w2ui-grid-select-check').prop('checked', true)
                    }
                    selected++
                }
            }
        } else {
            // normalize for performance
            let new_sel = {}
            for (let a = 0; a < args.length; a++) {
                let recid      = typeof args[a] == 'object' ? args[a].recid : args[a]
                let column     = typeof args[a] == 'object' ? args[a].column : null
                new_sel[recid] = new_sel[recid] || []
                if (Array.isArray(column)) {
                    new_sel[recid] = column
                } else if (w2utils.isInt(column)) {
                    new_sel[recid].push(column)
                } else {
                    for (let i = 0; i < this.columns.length; i++) { if (this.columns[i].hidden) continue; new_sel[recid].push(parseInt(i)) }
                }
            }
            // add all
            let col_sel = []
            for (let recid in new_sel) {
                let index = this.get(recid, true)
                if (index == null) continue
                let recEl1 = null
                let recEl2 = null
                if (index + 1 >= this.last.range_start && index + 1 <= this.last.range_end) {
                    recEl1 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(recid))
                    recEl2 = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid))
                }
                let s = sel.columns[index] || []
                // default action
                if (sel.indexes.indexOf(index) == -1) {
                    sel.indexes.push(index)
                }
                // only only those that are new
                for (let t = 0; t < new_sel[recid].length; t++) {
                    if (s.indexOf(new_sel[recid][t]) == -1) s.push(new_sel[recid][t])
                }
                s.sort((a, b) => { return a-b }) // sort function must be for numerical sort
                for (let t = 0; t < new_sel[recid].length; t++) {
                    let col = new_sel[recid][t]
                    if (col_sel.indexOf(col) == -1) col_sel.push(col)
                    if (recEl1) {
                        recEl1.find('#grid_'+ this.name +'_data_'+ index +'_'+ col).addClass('w2ui-selected')
                        recEl1.find('.w2ui-col-number').addClass('w2ui-row-selected')
                        recEl1.find('.w2ui-grid-select-check').prop('checked', true)
                    }
                    if (recEl2) {
                        recEl2.find('#grid_'+ this.name +'_data_'+ index +'_'+ col).addClass('w2ui-selected')
                        recEl2.find('.w2ui-col-number').addClass('w2ui-row-selected')
                        recEl2.find('.w2ui-grid-select-check').prop('checked', true)
                    }
                    selected++
                }
                // save back to selection object
                sel.columns[index] = s
            }
            // select columns (need here for speed)
            for (let c = 0; c < col_sel.length; c++) {
                query(this.box).find('#grid_'+ this.name +'_column_'+ col_sel[c] +' .w2ui-col-header').addClass('w2ui-col-selected')
            }
        }
        // need to sort new selection for speed
        sel.indexes.sort((a, b) => { return a-b })
        // all selected?
        let areAllSelected = (this.records.length > 0 && sel.indexes.length == this.records.length),
            areAllSearchedSelected = (sel.indexes.length > 0 && this.searchData.length !== 0 && sel.indexes.length == this.last.searchIds.length)
        if (areAllSelected || areAllSearchedSelected) {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', true)
        } else {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', false)
        }
        this.status()
        this.addRange('selection')
        this.updateToolbar(sel, areAllSelected)
        // event after
        edata.finish()
        return selected
    }
    unselect() {
        let unselected = 0
        let sel = this.last.selection
        // if too many arguments > 150k, then it errors off
        let args = Array.from(arguments)
        if (Array.isArray(args[0])) args = args[0]
        // event before
        let tmp = { target: this.name }
        if (args.length == 1) {
            tmp.multiple = false
            if (w2utils.isPlainObject(args[0])) {
                tmp.clicked = {
                    recid: args[0].recid,
                    column: args[0].column
                }
            } else {
                tmp.clicked = { recid: args[0] }
            }
        } else {
            tmp.multiple = true
            tmp.recids   = args
        }
        let edata = this.trigger('select', tmp)
        if (edata.isCancelled === true) return 0
        for (let a = 0; a < args.length; a++) {
            let recid  = typeof args[a] == 'object' ? args[a].recid : args[a]
            let record = this.get(recid)
            if (record == null) continue
            let index  = this.get(record.recid, true)
            let recEl1 = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid))
            let recEl2 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(recid))
            if (this.selectType == 'row') {
                if (sel.indexes.indexOf(index) == -1) continue
                // default action
                sel.indexes.splice(sel.indexes.indexOf(index), 1)
                recEl1.removeClass('w2ui-selected w2ui-inactive').find('.w2ui-col-number').removeClass('w2ui-row-selected')
                recEl2.removeClass('w2ui-selected w2ui-inactive').find('.w2ui-col-number').removeClass('w2ui-row-selected')
                if (recEl1.length != 0) {
                    recEl1[0].style.cssText = 'height: '+ this.recordHeight +'px; ' + recEl1.attr('custom_style')
                    recEl2[0].style.cssText = 'height: '+ this.recordHeight +'px; ' + recEl2.attr('custom_style')
                }
                recEl1.find('.w2ui-grid-select-check').prop('checked', false)
                unselected++
            } else {
                let col = args[a].column
                if (!w2utils.isInt(col)) { // unselect all columns
                    let cols = []
                    for (let i = 0; i < this.columns.length; i++) { if (this.columns[i].hidden) continue; cols.push({ recid: recid, column: i }) }
                    return this.unselect(cols)
                }
                let s = sel.columns[index]
                if (!Array.isArray(s) || s.indexOf(col) == -1) continue
                // default action
                s.splice(s.indexOf(col), 1)
                query(this.box).find(`#grid_${this.name}_rec_${w2utils.escapeId(recid)} > td[col="${col}"]`).removeClass('w2ui-selected w2ui-inactive')
                query(this.box).find(`#grid_${this.name}_frec_${w2utils.escapeId(recid)} > td[col="${col}"]`).removeClass('w2ui-selected w2ui-inactive')
                // check if any row/column still selected
                let isColSelected = false
                let isRowSelected = false
                let tmp           = this.getSelection()
                for (let i = 0; i < tmp.length; i++) {
                    if (tmp[i].column == col) isColSelected = true
                    if (tmp[i].recid == recid) isRowSelected = true
                }
                if (!isColSelected) {
                    query(this.box).find(`.w2ui-grid-columns td[col="${col}"] .w2ui-col-header, .w2ui-grid-fcolumns td[col="${col}"] .w2ui-col-header`).removeClass('w2ui-col-selected')
                }
                if (!isRowSelected) {
                    query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid)).find('.w2ui-col-number').removeClass('w2ui-row-selected')
                }
                unselected++
                if (s.length === 0) {
                    delete sel.columns[index]
                    sel.indexes.splice(sel.indexes.indexOf(index), 1)
                    recEl1.find('.w2ui-grid-select-check').prop('checked', false)
                }
            }
        }
        // all selected?
        let areAllSelected = (this.records.length > 0 && sel.indexes.length == this.records.length),
            areAllSearchedSelected = (sel.indexes.length > 0 && this.searchData.length !== 0 && sel.indexes.length == this.last.searchIds.length)
        if (areAllSelected || areAllSearchedSelected) {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', true)
        } else {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', false)
        }
        // show number of selected
        this.status()
        this.addRange('selection')
        this.updateToolbar(sel, areAllSelected)
        // event after
        edata.finish()
        return unselected
    }
    selectAll() {
        let time = Date.now()
        if (this.multiSelect === false) return
        // default action
        let url = this.url?.get ?? this.url
        let sel  = w2utils.clone(this.last.selection)
        let cols = []
        for (let i = 0; i < this.columns.length; i++) cols.push(i)
        // if local data source and searched
        sel.indexes = []
        if (!url && this.searchData.length !== 0) {
            // local search applied
            for (let i = 0; i < this.last.searchIds.length; i++) {
                sel.indexes.push(this.last.searchIds[i])
                if (this.selectType != 'row') sel.columns[this.last.searchIds[i]] = cols.slice() // .slice makes copy of the array
            }
        } else {
            let buffered = this.records.length
            if (this.searchData.length != 0 && !url) buffered = this.last.searchIds.length
            for (let i = 0; i < buffered; i++) {
                sel.indexes.push(i)
                if (this.selectType != 'row') sel.columns[i] = cols.slice() // .slice makes copy of the array
            }
        }
        // event before
        let edata = this.trigger('select', { target: this.name, multiple: true, all: true, clicked: sel })
        if (edata.isCancelled === true) return
        this.last.selection = sel
        // add selected class
        if (this.selectType == 'row') {
            query(this.box).find('.w2ui-grid-records tr:not(.w2ui-empty-record)')
                .addClass('w2ui-selected').find('.w2ui-col-number').addClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-frecords tr:not(.w2ui-empty-record)')
                .addClass('w2ui-selected').find('.w2ui-col-number').addClass('w2ui-row-selected')
            query(this.box).find('input.w2ui-grid-select-check').prop('checked', true)
        } else {
            query(this.box).find('.w2ui-grid-columns td .w2ui-col-header, .w2ui-grid-fcolumns td .w2ui-col-header').addClass('w2ui-col-selected')
            query(this.box).find('.w2ui-grid-records tr .w2ui-col-number').addClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-records tr:not(.w2ui-empty-record)')
                .find('.w2ui-grid-data:not(.w2ui-col-select)').addClass('w2ui-selected')
            query(this.box).find('.w2ui-grid-frecords tr .w2ui-col-number').addClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-frecords tr:not(.w2ui-empty-record)')
                .find('.w2ui-grid-data:not(.w2ui-col-select)').addClass('w2ui-selected')
            query(this.box).find('input.w2ui-grid-select-check').prop('checked', true)
        }
        // enable/disable toolbar buttons
        sel = this.getSelection(true)
        this.addRange('selection')
        query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', true)
        this.status()
        this.updateToolbar({ indexes: sel }, true)
        // event after
        edata.finish()
        return Date.now() - time
    }
    selectNone(skipEvent) {
        let time = Date.now()
        // event before
        let edata
        if (!skipEvent) {
            edata = this.trigger('select', { target: this.name, clicked: [] })
            if (edata.isCancelled === true) return
        }
        // default action
        let sel = this.last.selection
        // remove selected class
        if (this.selectType == 'row') {
            query(this.box).find('.w2ui-grid-records tr.w2ui-selected').removeClass('w2ui-selected w2ui-inactive')
                .find('.w2ui-col-number').removeClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-frecords tr.w2ui-selected').removeClass('w2ui-selected w2ui-inactive')
                .find('.w2ui-col-number').removeClass('w2ui-row-selected')
            query(this.box).find('input.w2ui-grid-select-check').prop('checked', false)
        } else {
            query(this.box).find('.w2ui-grid-columns td .w2ui-col-header, .w2ui-grid-fcolumns td .w2ui-col-header').removeClass('w2ui-col-selected')
            query(this.box).find('.w2ui-grid-records tr .w2ui-col-number').removeClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-frecords tr .w2ui-col-number').removeClass('w2ui-row-selected')
            query(this.box).find('.w2ui-grid-data.w2ui-selected').removeClass('w2ui-selected w2ui-inactive')
            query(this.box).find('input.w2ui-grid-select-check').prop('checked', false)
        }
        sel.indexes = []
        sel.columns = {}
        this.removeRange('selection')
        query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', false)
        this.status()
        this.updateToolbar(sel, false)
        // event after
        if (!skipEvent) {
            edata.finish()
        }
        return Date.now() - time
    }
    updateToolbar(sel) {
        let obj = this
        let cnt = sel && sel.indexes ? sel.indexes.length : 0
        this.toolbar.items.forEach((item) => {
            _checkItem(item, '')
            if (Array.isArray(item.items)) {
                item.items.forEach((it) => {
                    _checkItem(it, item.id + ':')
                })
            }
        })
        // enable/disable toolbar search button
        if (this.show.toolbarSave) {
            if (this.getChanges().length > 0) {
                this.toolbar.enable('w2ui-save')
            } else {
                this.toolbar.disable('w2ui-save')
            }
        }
        function _checkItem(item, prefix) {
            if (item.batch != null) {
                let enabled = false
                if (item.batch === true) {
                    if (cnt > 0) enabled = true
                } else if (typeof item.batch == 'number') {
                    if (cnt === item.batch) enabled = true
                } else if (typeof item.batch == 'function') {
                    enabled = item.batch({ cnt, sel })
                }
                if (enabled) {
                    obj.toolbar.enable(prefix + item.id)
                } else {
                    obj.toolbar.disable(prefix + item.id)
                }
            }
        }
    }
    getSelection(returnIndex) {
        let ret = []
        let sel = this.last.selection
        if (this.selectType == 'row') {
            for (let i = 0; i < sel.indexes.length; i++) {
                if (!this.records[sel.indexes[i]]) continue
                if (returnIndex === true) ret.push(sel.indexes[i]); else ret.push(this.records[sel.indexes[i]].recid)
            }
            return ret
        } else {
            for (let i = 0; i < sel.indexes.length; i++) {
                let cols = sel.columns[sel.indexes[i]]
                if (!this.records[sel.indexes[i]]) continue
                for (let j = 0; j < cols.length; j++) {
                    ret.push({ recid: this.records[sel.indexes[i]].recid, index: parseInt(sel.indexes[i]), column: cols[j] })
                }
            }
            return ret
        }
    }
    search(field, value) {
        let url = this.url?.get ?? this.url
        let searchData = []
        let last_multi = this.last.multi
        let last_logic = this.last.logic
        let last_field = this.last.field
        let last_search = this.last.search
        let hasHiddenSearches = false
        let overlay = query(`#w2overlay-${this.name}-search-overlay`)
        // add hidden searches
        for (let i = 0; i < this.searches.length; i++) {
            if (!this.searches[i].hidden || this.searches[i].value == null) continue
            searchData.push({
                field    : this.searches[i].field,
                operator : this.searches[i].operator || 'is',
                type     : this.searches[i].type,
                value    : this.searches[i].value || ''
            })
            hasHiddenSearches = true
        }
        if (arguments.length === 0 && overlay.length === 0) {
            if (this.multiSearch) {
                field = this.searchData
                value = this.last.logic
            } else {
                field = this.last.field
                value = this.last.search
            }
        }
        // 1: search() - advanced search (reads from popup)
        if (arguments.length === 0 && overlay.length !== 0) {
            this.focus() // otherwise search drop down covers searches
            last_logic = overlay.find(`#grid_${this.name}_logic`).val()
            last_search = ''
            // advanced search
            for (let i = 0; i < this.searches.length; i++) {
                let search   = this.searches[i]
                let operator = overlay.find('#grid_'+ this.name + '_operator_'+ i).val()
                let field1   = overlay.find('#grid_'+ this.name + '_field_'+ i)
                let field2   = overlay.find('#grid_'+ this.name + '_field2_'+ i)
                let value1   = field1.val()
                let value2   = field2.val()
                let svalue   = null
                let text     = null
                if (['int', 'float', 'money', 'currency', 'percent'].indexOf(search.type) != -1) {
                    let fld1 = field1[0]._w2field
                    let fld2 = field2[0]._w2field
                    if (fld1) value1 = fld1.clean(value1)
                    if (fld2) value2 = fld2.clean(value2)
                }
                if (['list', 'enum'].indexOf(search.type) != -1 || ['in', 'not in'].indexOf(operator) != -1) {
                    value1 = field1[0]._w2field.selected || {}
                    if (Array.isArray(value1)) {
                        svalue = []
                        for (let j = 0; j < value1.length; j++) {
                            svalue.push(w2utils.isFloat(value1[j].id) ? parseFloat(value1[j].id) : String(value1[j].id).toLowerCase())
                            delete value1[j].hidden
                        }
                        if (Object.keys(value1).length === 0) value1 = ''
                    } else {
                        text   = value1.text || ''
                        value1 = value1.id || ''
                    }
                }
                if ((value1 !== '' && value1 != null) || (value2 != null && value2 !== '')) {
                    let tmp = {
                        field    : search.field,
                        type     : search.type,
                        operator : operator
                    }
                    if (operator == 'between') {
                        w2utils.extend(tmp, { value: [value1, value2] })
                    } else if (operator == 'in' && typeof value1 == 'string') {
                        w2utils.extend(tmp, { value: value1.split(',') })
                    } else if (operator == 'not in' && typeof value1 == 'string') {
                        w2utils.extend(tmp, { value: value1.split(',') })
                    } else {
                        w2utils.extend(tmp, { value: value1 })
                    }
                    if (svalue) w2utils.extend(tmp, { svalue: svalue })
                    if (text) w2utils.extend(tmp, { text: text })
                    // convert date to unix time
                    try {
                        if (search.type == 'date' && operator == 'between') {
                            tmp.value[0] = value1 // w2utils.isDate(value1, w2utils.settings.dateFormat, true).getTime();
                            tmp.value[1] = value2 // w2utils.isDate(value2, w2utils.settings.dateFormat, true).getTime();
                        }
                        if (search.type == 'date' && operator == 'is') {
                            tmp.value = value1 // w2utils.isDate(value1, w2utils.settings.dateFormat, true).getTime();
                        }
                    } catch (e) {
                    }
                    searchData.push(tmp)
                    last_multi = true // if only hidden searches, then do not set
                }
            }
        }
        // 2: search(field, value) - regular search
        if (typeof field == 'string') {
            // if only one argument - search all
            if (arguments.length == 1) {
                value = field
                field = 'all'
            }
            last_field  = field
            last_search = value
            last_multi  = false
            last_logic  = (hasHiddenSearches ? 'AND' : 'OR')
            // loop through all searches and see if it applies
            if (value != null) {
                if (field.toLowerCase() == 'all') {
                    // if there are search fields loop thru them
                    if (this.searches.length > 0) {
                        for (let i = 0; i < this.searches.length; i++) {
                            let search = this.searches[i]
                            if (search.type == 'text' || (search.type == 'alphanumeric' && w2utils.isAlphaNumeric(value))
                                    || (search.type == 'int' && w2utils.isInt(value)) || (search.type == 'float' && w2utils.isFloat(value))
                                    || (search.type == 'percent' && w2utils.isFloat(value)) || ((search.type == 'hex' || search.type == 'color') && w2utils.isHex(value))
                                    || (search.type == 'currency' && w2utils.isMoney(value)) || (search.type == 'money' && w2utils.isMoney(value))
                                    || (search.type == 'date' && w2utils.isDate(value)) || (search.type == 'time' && w2utils.isTime(value))
                                    || (search.type == 'datetime' && w2utils.isDateTime(value)) || (search.type == 'datetime' && w2utils.isDate(value))
                                    || (search.type == 'enum' && w2utils.isAlphaNumeric(value)) || (search.type == 'list' && w2utils.isAlphaNumeric(value))
                            ) {
                                let def = this.defaultOperator[this.operatorsMap[search.type]]
                                let tmp = {
                                    field    : search.field,
                                    type     : search.type,
                                    operator : (search.operator != null ? search.operator : def),
                                    value    : value
                                }
                                if (String(value).trim() != '') searchData.push(tmp)
                            }
                            // range in global search box
                            if (['int', 'float', 'money', 'currency', 'percent'].indexOf(search.type) != -1 && String(value).trim().split('-').length == 2) {
                                let t   = String(value).trim().split('-')
                                let tmp = {
                                    field    : search.field,
                                    type     : search.type,
                                    operator : (search.operator != null ? search.operator : 'between'),
                                    value    : [t[0], t[1]]
                                }
                                searchData.push(tmp)
                            }
                            // lists fields
                            if (['list', 'enum'].indexOf(search.type) != -1) {
                                let new_values = []
                                if (search.options == null) search.options = {}
                                if (!Array.isArray(search.options.items)) search.options.items = []
                                for (let j = 0; j < search.options.items; j++) {
                                    let tmp = search.options.items[j]
                                    try {
                                        let re = new RegExp(value, 'i')
                                        if (re.test(tmp)) new_values.push(j)
                                        if (tmp.text && re.test(tmp.text)) new_values.push(tmp.id)
                                    } catch (e) {}
                                }
                                if (new_values.length > 0) {
                                    let tmp = {
                                        field    : search.field,
                                        type     : search.type,
                                        operator : (search.operator != null ? search.operator : 'in'),
                                        value    : new_values
                                    }
                                    searchData.push(tmp)
                                }
                            }
                        }
                    } else {
                        // no search fields, loop thru columns
                        for (let i = 0; i < this.columns.length; i++) {
                            let tmp = {
                                field    : this.columns[i].field,
                                type     : 'text',
                                operator : this.defaultOperator.text,
                                value    : value
                            }
                            searchData.push(tmp)
                        }
                    }
                } else {
                    let el = overlay.find('#grid_'+ this.name +'_search_all')
                    let search = this.getSearch(field)
                    if (search == null) search = { field: field, type: 'text' }
                    if (search.field == field) this.last.label = search.label
                    if (value !== '') {
                        let op  = this.defaultOperator[this.operatorsMap[search.type]]
                        let val = value
                        if (['date', 'time', 'datetime'].indexOf(search.type) != -1) op = 'is'
                        if (['list', 'enum'].indexOf(search.type) != -1) {
                            op = 'is'
                            let tmp = el._w2field.get()
                            if (tmp && Object.keys(tmp).length > 0) val = tmp.id; else val = ''
                        }
                        if (search.type == 'int' && value !== '') {
                            op = 'is'
                            if (String(value).indexOf('-') != -1) {
                                let tmp = value.split('-')
                                if (tmp.length == 2) {
                                    op  = 'between'
                                    val = [parseInt(tmp[0]), parseInt(tmp[1])]
                                }
                            }
                            if (String(value).indexOf(',') != -1) {
                                let tmp = value.split(',')
                                op      = 'in'
                                val     = []
                                for (let i = 0; i < tmp.length; i++) val.push(tmp[i])
                            }
                        }
                        if (search.operator != null) op = search.operator
                        let tmp = {
                            field    : search.field,
                            type     : search.type,
                            operator : op,
                            value    : val
                        }
                        searchData.push(tmp)
                    }
                }
            }
        }
        // 3: search([{ field, value, [operator,] [type] }, { field, value, [operator,] [type] } ], logic) - submit whole structure
        if (Array.isArray(field)) {
            let logic = 'AND'
            if (typeof value == 'string') {
                logic = value.toUpperCase()
                if (logic != 'OR' && logic != 'AND') logic = 'AND'
            }
            last_search = ''
            last_multi  = true
            last_logic  = logic
            for (let i = 0; i < field.length; i++) {
                let data = field[i]
                if (typeof data.value == 'number' && data.operator == null) data.operator = this.defaultOperator.number
                if (typeof data.value == 'string' && data.operator == null) data.operator = this.defaultOperator.text
                if (Array.isArray(data.value) && data.operator == null) data.operator = this.defaultOperator.enum
                if (w2utils.isDate(data.value) && data.operator == null) data.operator = this.defaultOperator.date
                // merge current field and search if any
                searchData.push(data)
            }
        }
        // event before
        let edata = this.trigger('search', {
            target: this.name,
            multi: (arguments.length === 0 ? true : false),
            searchField: (field ? field : 'multi'),
            searchValue: (field ? value : 'multi'),
            searchData: searchData,
            searchLogic: last_logic
        })
        if (edata.isCancelled === true) return
        // default action
        this.searchData             = edata.detail.searchData
        this.last.field             = last_field
        this.last.search            = last_search
        this.last.multi             = last_multi
        this.last.logic             = edata.detail.searchLogic
        this.last.scrollTop         = 0
        this.last.scrollLeft        = 0
        this.last.selection.indexes = []
        this.last.selection.columns = {}
        // -- clear all search field
        this.searchClose()
        // apply search
        if (url) {
            this.last.fetch.offset = 0
            this.reload()
        } else {
            // local search
            this.localSearch()
            this.refresh()
        }
        // event after
        edata.finish()
    }
    // open advanced search popover
    searchOpen() {
        if (!this.box) return
        if (this.searches.length === 0) return
        // event before
        let edata = this.trigger('searchOpen', { target: this.name })
        if (edata.isCancelled === true) {
            return
        }
        let $btn = query(this.toolbar.box).find('.w2ui-grid-search-input .w2ui-search-drop')
        $btn.addClass('checked')
        // show search
        w2tooltip.show({
            name: this.name + '-search-overlay',
            anchor: query(this.box).find('#grid_'+ this.name +'_search_all').get(0),
            position: 'bottom|top',
            html: this.getSearchesHTML(),
            align: 'left',
            arrowSize: 12,
            class: 'w2ui-grid-search-advanced',
            hideOn: ['doc-click']
        })
            .then(event => {
                this.initSearches()
                this.last.search_opened = true
                let overlay = query(`#w2overlay-${this.name}-search-overlay`)
                overlay
                    .data('gridName', this.name)
                    .off('.grid-search')
                    .on('click.grid-search', () => {
                    // hide any tooltip opened by searches
                        overlay.find('input, select').each(el => {
                            let names = query(el).data('tooltipName')
                            if (names) names.forEach(name => {
                                w2tooltip.hide(name)
                            })
                        })
                    })
                w2utils.bindEvents(overlay.find('select, input, button'), this)
                // init first field
                let sfields = query(`#w2overlay-${this.name}-search-overlay *[rel=search]`)
                if (sfields.length > 0) sfields[0].focus()
                // event after
                edata.finish()
            })
            .hide(event => {
                $btn.removeClass('checked')
                this.last.search_opened = false
            })
    }
    searchClose() {
        w2tooltip.hide(this.name + '-search-overlay')
    }
    // if clicked on a field in the search strip
    searchFieldTooltip(ind, sd_ind, el) {
        let sf = this.searches[ind]
        let sd = this.searchData[sd_ind]
        let oper = sd.operator
        if (oper == 'more' && sd.type == 'date') oper = 'since'
        if (oper == 'less' && sd.type == 'date') oper = 'before'
        let options = ''
        let val = sd.value
        if (Array.isArray(sd.value)) { // && Array.isArray(sf.options.items)) {
            sd.value.forEach(opt => {
                options += `<span class="value">${opt.text || opt}</span>`
            })
            if (sd.type == 'date') {
                options = ''
                sd.value.forEach(opt => {
                    options += `<span class="value">${w2utils.formatDate(opt)}</span>`
                })
            }
        } else {
            if (sd.type == 'date') {
                val = w2utils.formatDateTime(val)
            }
        }
        w2tooltip.hide(this.name + '-search-props')
        w2tooltip.show({
            name: this.name + '-search-props',
            anchor: el,
            class: 'w2ui-white',
            hideOn: 'doc-click',
            html: `
                <div class="w2ui-grid-search-single">
                    <span class="field">${sf.label}</span>
                    <span class="operator">${w2utils.lang(oper)}</span>
                    ${Array.isArray(sd.value)
                        ? `${options}`
                        : `<span class="value">${val}</span>`
                    }
                    <div class="buttons">
                        <button id="remove" class="w2ui-btn">${w2utils.lang('Remove This Field')}</button>
                    </div>
                </div>`
        }).then(event => {
            query(event.detail.overlay.box).find('#remove').on('click', () => {
                this.searchData.splice(`${sd_ind}`, 1)
                this.reload()
                this.localSearch()
                w2tooltip.hide(this.name + '-search-props')
            })
        })
    }
    // drop down with save searches
    searchSuggest(imediate, forceHide, input) {
        clearTimeout(this.last.kbd_timer)
        clearTimeout(this.last.overlay_timer)
        this.searchShowFields(true)
        this.searchClose()
        if (forceHide === true) {
            w2tooltip.hide(this.name + '-search-suggest')
            return
        }
        if (query(`#w2overlay-${this.name}-search-suggest`).length > 0) {
            // already shown
            return
        }
        if (!imediate) {
            this.last.overlay_timer = setTimeout(() => { this.searchSuggest(true) }, 100)
            return
        }
        let el = query(this.box).find(`#grid_${this.name}_search_all`).get(0)
        let searches = [
            ...this.defaultSearches ?? [],
            ...this.defaultSearches?.length > 0 && this.savedSearches?.length > 0 ? ['--'] : [],
            ...this.savedSearches ?? []
        ]
        if (Array.isArray(searches) && searches.length > 0) {
            w2menu.show({
                name: this.name + '-search-suggest',
                anchor: el,
                align: 'both',
                items: searches,
                hideOn: ['doc-click', 'sleect', 'remove'],
                render(item) {
                    let ret = item.text
                    if (item.isDefault) ret = `<b>${ret}</b>`
                    return ret
                }
            })
            .select(event => {
                let edata = this.trigger('searchSelect', {
                    target: this.name,
                    index: event.detail.index,
                    item: event.detail.item
                })
                if (edata.isCancelled === true) {
                    event.preventDefault()
                    return
                }
                event.detail.overlay.hide()
                this.last.logic  = event.detail.item.logic || 'AND'
                this.last.search = ''
                this.last.label  = '[Multiple Fields]'
                this.searchData  = w2utils.clone(event.detail.item.data)
                this.searchSelected = w2utils.clone(event.detail.item, { exclude: ['icon', 'remove'] })
                this.reload()
                edata.finish()
            })
            .remove(event => {
                let item = event.detail.item
                let edata = this.trigger('searchRemove', { target: this.name, index: event.detail.index, item })
                if (edata.isCancelled === true) {
                    event.preventDefault()
                    return
                }
                event.detail.overlay.hide()
                this.confirm(w2utils.lang('Do you want to delete search "${item}"?', { item: item.text }))
                    .yes(evt => {
                    // remove from searches
                        let search = this.savedSearches.findIndex((s) => s.id == item.id ? true : false)
                        if (search !== -1) {
                            this.savedSearches.splice(search, 1)
                        }
                        this.cacheSave('searches', this.savedSearches.map(s => w2utils.clone(s, { exclude: ['remove', 'icon'] })))
                        evt.detail.self.close()
                        // evt after
                        edata.finish()
                    })
                    .no(evt => {
                        evt.detail.self.close()
                    })
            })
        }
    }
    searchSave() {
        let value = ''
        if (this.searchSelected) {
            value = this.searchSelected.text
        }
        let ind = this.savedSearches.findIndex(s => { return s.id == this.searchSelected?.id ? true : false })
        // event before
        let edata = this.trigger('searchSave', { target: this.name, saveLocalStorage: true })
        if (edata.isCancelled === true) return
        this.message({
            width: 350,
            height: 150,
            body: `<div class="w2ui-grid-save-search">
                        <span>${w2utils.lang(ind != -1 ? 'Update Search' : 'Save New Search')}</span>
                        <input class="search-name w2ui-input" placeholder="${w2utils.lang('Search name')}">
                   </div>`,
            buttons: `
                <button id="grid-search-cancel" class="w2ui-btn">${w2utils.lang('Cancel')}</button>
                <button id="grid-search-save" class="w2ui-btn w2ui-btn-blue" ${String(value).trim() == '' ? 'disabled': ''}>${w2utils.lang('Save')}</button>
            `
        }).open(async (event) => {
            query(event.detail.box).find('input, button').eq(0).val(value)
            await event.complete
            query(event.detail.box).find('#grid-search-cancel').on('click', () => {
                this.message()
            })
            query(event.detail.box).find('#grid-search-save').on('click', () => {
                let name = query(event.detail.box).find('.w2ui-message .search-name').val()
                // save in savedSearches
                if (this.searchSelected && ind != -1) {
                    Object.assign(this.savedSearches[ind], {
                        id: name,
                        text: name,
                        logic: this.last.logic,
                        data: w2utils.clone(this.searchData)
                    })
                } else {
                    this.savedSearches.push({
                        id: name,
                        text: name,
                        icon: 'w2ui-icon-search',
                        remove: true,
                        logic: this.last.logic,
                        data: this.searchData
                    })
                }
                // save local storage
                this.cacheSave('searches', this.savedSearches.map(s => w2utils.clone(s, { exclude: ['remove', 'icon'] })))
                this.message()
                // update on screen
                if (this.searchSelected) {
                    this.searchSelected.text = name
                    query(this.box).find(`#grid_${this.name}_search_name .name-text`).html(name)
                } else {
                    this.searchSelected = {
                        text: name,
                        logic: this.last.logic,
                        data: w2utils.clone(this.searchData)
                    }
                    query(event.detail.box).find(`#grid_${this.name}_search_all`).val(' ').prop('readOnly', true)
                    query(event.detail.box).find(`#grid_${this.name}_search_name`).show().find('.name-text').html(name)
                }
                edata.finish({ name })
            })
            query(event.detail.box).find('input, button')
                .off('.message')
                .on('keydown.message', evt => {
                    let val = String(query(event.detail.box).find('.w2ui-message-body input').val()).trim()
                    if (evt.keyCode == 13 && val != '') {
                        query(event.detail.box).find('#grid-search-save').trigger('click') // enter
                    }
                    if (evt.keyCode == 27) { // escape
                        this.message()
                    }
                })
                .eq(0)
                .on('input.message', evt => {
                    let $save = query(event.detail.box).closest('.w2ui-message').find('#grid-search-save')
                    if (String(query(event.detail.box).val()).trim() === '') {
                        $save.prop('disabled', true)
                    } else {
                        $save.prop('disabled', false)
                    }
                })
                .get(0)
                .focus()
        })
    }
    cache(type) {
        if (w2utils.hasLocalStorage && this.useLocalStorage) {
            try {
                let data = JSON.parse(localStorage.w2ui || '{}')
                data[(this.stateId || this.name)] ??= {}
                return data[(this.stateId || this.name)][type]
            } catch (e) {
            }
        }
        return null
    }
    cacheSave(type, value) {
        if (w2utils.hasLocalStorage && this.useLocalStorage) {
            try {
                let data = JSON.parse(localStorage.w2ui || '{}')
                data[(this.stateId || this.name)] ??= {}
                data[(this.stateId || this.name)][type] = value
                localStorage.w2ui = JSON.stringify(data)
                return true
            } catch (e) {
                delete localStorage.w2ui
            }
        }
        return false
    }
    searchReset(noReload) {
        let searchData = []
        let hasHiddenSearches = false
        // add hidden searches
        for (let i = 0; i < this.searches.length; i++) {
            if (!this.searches[i].hidden || this.searches[i].value == null) continue
            searchData.push({
                field    : this.searches[i].field,
                operator : this.searches[i].operator || 'is',
                type     : this.searches[i].type,
                value    : this.searches[i].value || ''
            })
            hasHiddenSearches = true
        }
        // event before
        let edata = this.trigger('search', { reset: true, target: this.name, searchData: searchData })
        if (edata.isCancelled === true) return
        // default action
        let input = query(this.box).find('#grid_'+ this.name +'_search_all')
        this.searchData = edata.detail.searchData
        this.searchSelected = null
        this.last.search = ''
        this.last.logic = (hasHiddenSearches ? 'AND' : 'OR')
        // --- do not reset to All Fields (I think)
        input.next().hide() // advanced search button
        if (this.searches.length > 0) {
            if (!this.multiSearch || !this.show.searchAll) {
                let tmp = 0
                while (tmp < this.searches.length && (this.searches[tmp].hidden || this.searches[tmp].simple === false)) tmp++
                if (tmp >= this.searches.length) {
                    // all searches are hidden
                    this.last.field = ''
                    this.last.label = ''
                } else {
                    this.last.field = this.searches[tmp].field
                    this.last.label = this.searches[tmp].label
                }
            } else {
                this.last.field = 'all'
                this.last.label = 'All Fields'
                input.next().show() // advanced search button
            }
        }
        this.last.multi      = false
        this.last.fetch.offset = 0
        // reset scrolling position
        this.last.scrollTop         = 0
        this.last.scrollLeft        = 0
        this.last.selection.indexes = []
        this.last.selection.columns = {}
        // -- clear all search field
        this.searchClose()
        let all = input.val('').get(0)
        if (all?._w2field) { all._w2field.reset() }
        // apply search
        if (!noReload) this.reload()
        // event after
        edata.finish()
    }
    searchShowFields(forceHide) {
        if (forceHide === true) {
            w2tooltip.hide(this.name + '-search-fields')
            return
        }
        let items = []
        for (let s = -1; s < this.searches.length; s++) {
            let search   = this.searches[s]
            let sField   = (search ? search.field : null)
            let column   = this.getColumn(sField)
            let disabled = false
            let tooltip  = null
            if (this.show.searchHiddenMsg == true && s != -1
                    && (column == null || (column.hidden === true && column.hideable !== false))) {
                disabled = true
                tooltip = w2utils.lang(`This column ${column == null ? 'does not exist' : 'is hidden'}`)
            }
            if (s == -1) { // -1 is All Fields search
                if (!this.multiSearch || !this.show.searchAll) continue
                search = { field: 'all', label: 'All Fields' }
            } else {
                if (column != null && column.hideable === false) continue
                if (search.hidden === true) {
                    tooltip = w2utils.lang('This column is hidden')
                    // don't show hidden (not simple) searches
                    if (search.simple === false) continue
                }
            }
            if (search.label == null && search.caption != null) {
                console.log('NOTICE: grid search.caption property is deprecated, please use search.label. Search ->', search)
                search.label = search.caption
            }
            items.push({
                id: search.field,
                text: w2utils.lang(search.label),
                search,
                tooltip,
                disabled,
                checked: (search.field == this.last.field)
            })
        }
        w2menu.show({
            type: 'radio',
            name: this.name + '-search-fields',
            anchor: query(this.box).find('#grid_'+ this.name +'_search_name').parent().find('.w2ui-search-down').get(0),
            items,
            align: 'none',
            hideOn: ['doc-click', 'select']
        })
            .select(event => {
                this.searchInitInput(event.detail.item.search.field)
            })
    }
    searchInitInput(field, value) {
        let search
        let el = query(this.box).find('#grid_'+ this.name +'_search_all')
        if (field == 'all') {
            search = { field: 'all', label: w2utils.lang('All Fields') }
        } else {
            search = this.getSearch(field)
            if (search == null) return
        }
        // update field
        if (this.last.search != '') {
            this.last.label = search.label
            this.search(search.field, this.last.search)
        } else {
            this.last.field = search.field
            this.last.label = search.label
        }
        el.attr('placeholder', w2utils.lang('Search') + ' ' + w2utils.lang(search.label || search.caption || search.field, true))
    }
    // clears records and related params
    clear(noRefresh) {
        this.total   = 0
        this.records = []
        this.summary = []
        this.last.fetch.offset = 0 // need this for reload button to work on remote data set
        this.last.idCache   = {} // optimization to free memory
        this.last.selection = { indexes: [], columns: {} }
        this.reset(true)
        // refresh
        if (!noRefresh) this.refresh()
    }
    // clears scroll position, selection, ranges
    reset(noRefresh) {
        // position
        this.last.scrollTop   = 0
        this.last.scrollLeft  = 0
        this.last.range_start = null
        this.last.range_end   = null
        // additional
        query(this.box).find(`#grid_${this.name}_records`).prop('scrollTop', 0)
        // refresh
        if (!noRefresh) this.refresh()
    }
    skip(offset, callBack) {
        let url = this.url?.get ?? this.url
        if (url) {
            this.offset = parseInt(offset)
            if (this.offset > this.total) this.offset = this.total - this.limit
            if (this.offset < 0 || !w2utils.isInt(this.offset)) this.offset = 0
            this.clear(true)
            this.reload(callBack)
        } else {
            console.log('ERROR: grid.skip() can only be called when you have remote data source.')
        }
    }
    load(url, callBack) {
        if (url == null) {
            console.log('ERROR: You need to provide url argument when calling .load() method of "'+ this.name +'" object.')
            return new Promise((resolve, reject) => { reject() })
        }
        // default action
        this.clear(true)
        return this.request('load', {}, url, callBack)
    }
    reload(callBack) {
        let grid = this
        let url = this.url?.get ?? this.url
        grid.selectionSave()
        if (url) {
            // need to remember selection (not just last.selection object)
            return this.load(url, () => {
                grid.selectionRestore()
                if (typeof callBack == 'function') callBack()
            })
        } else {
            this.reset(true)
            this.localSearch()
            this.selectionRestore()
            if (typeof callBack == 'function') callBack({ status: 'success' })
            return new Promise(resolve => { resolve() })
        }
    }
    request(action, postData, url, callBack) {
        let self = this
        let resolve, reject
        let requestProm = new Promise((res, rej) => { resolve = res; reject = rej })
        if (postData == null) postData = {}
        if (!url) url = this.url
        if (!url) return new Promise((resolve, reject) => { reject() })
        // build parameters list
        if (!w2utils.isInt(this.offset)) this.offset = 0
        if (!w2utils.isInt(this.last.fetch.offset)) this.last.fetch.offset = 0
        // add list params
        let edata
        let params = {
            limit: this.limit,
            offset: parseInt(this.offset) + parseInt(this.last.fetch.offset),
            searchLogic: this.last.logic,
            search: this.searchData.map((search) => {
                let _search = w2utils.clone(search)
                if (this.searchMap && this.searchMap[_search.field]) _search.field = this.searchMap[_search.field]
                return _search
            }),
            sort: this.sortData.map((sort) => {
                let _sort = w2utils.clone(sort)
                if (this.sortMap && this.sortMap[_sort.field]) _sort.field = this.sortMap[_sort.field]
                return _sort
            })
        }
        if (this.searchData.length === 0) {
            delete params.search
            delete params.searchLogic
        }
        if (this.sortData.length === 0) {
            delete params.sort
        }
        // append other params
        w2utils.extend(params, this.postData)
        w2utils.extend(params, postData)
        // other actions
        if (action == 'delete' || action == 'save') {
            delete params.limit
            delete params.offset
            params.action = action
            if (action == 'delete') {
                params[this.recid || 'recid'] = this.getSelection()
            }
        }
        // event before
        if (action == 'load') {
            edata = this.trigger('request', { target: this.name, url, postData: params, httpMethod: 'GET',
                httpHeaders: this.httpHeaders })
            if (edata.isCancelled === true) return new Promise((resolve, reject) => { reject() })
        } else {
            edata = { detail: {
                url,
                postData: params,
                httpMethod: action == 'save' ? 'PUT' : 'DELETE',
                httpHeaders: this.httpHeaders
            }}
        }
        // call server to get data
        if (this.last.fetch.offset === 0) {
            this.lock(w2utils.lang(this.msgRefresh), true)
        }
        if (this.last.fetch.controller) try { this.last.fetch.controller.abort() } catch (e) {}
        // URL
        url = edata.detail.url
        switch (action) {
            case 'save':
                if (url?.save) url = url.save
                break
            case 'delete':
                if (url?.remove) url = url.remove
                break
            default:
                url = url?.get ?? url
        }
        // process url with routeData
        if (Object.keys(this.routeData).length > 0) {
            let info = w2utils.parseRoute(url)
            if (info.keys.length > 0) {
                for (let k = 0; k < info.keys.length; k++) {
                    if (this.routeData[info.keys[k].name] == null) continue
                    url = url.replace((new RegExp(':'+ info.keys[k].name, 'g')), this.routeData[info.keys[k].name])
                }
            }
        }
        url = new URL(url, location)
        // ajax options
        let fetchOptions = w2utils.prepareParams(url, {
            method: edata.detail.httpMethod,
            headers: edata.detail.httpHeaders,
            body: edata.detail.postData
        }, this.dataType)
        Object.assign(this.last.fetch, {
            action: action,
            options: fetchOptions,
            controller: new AbortController(),
            start: Date.now(),
            loaded: false
        })
        fetchOptions.signal = this.last.fetch.controller.signal
        fetch(url, fetchOptions)
            .catch(processError)
            .then(resp => {
                if (resp == null) return // request aborted
                if (resp?.status != 200) {
                    processError(resp ?? {})
                    return
                }
                self.unlock()
                resp.json()
                    .catch(processError)
                    .then(data => {
                        this.requestComplete(data, action, callBack, resolve, reject)
                    })
            })
        if (action == 'load') {
            // event after
            edata.finish()
        }
        return requestProm
        function processError(response) {
            if (response?.name === 'AbortError') {
                // request was aborted by the grid
                return
            }
            self.unlock()
            // trigger event
            let edata2 = self.trigger('error', { response, lastFetch: self.last.fetch })
            if (edata2.isCancelled === true) return
            // default behavior
            if (response.status && response.status != 200) {
                self.error(response.status + ': ' + response.statusText)
            } else {
                console.log('ERROR: Server communication failed.',
                    '\n   EXPECTED:', { total: 5, records: [{ recid: 1, field: 'value' }] },
                    '\n         OR:', { error: true, message: 'error message' })
                self.requestComplete({ error: true, message: w2utils.lang(this.msgHTTPError), response }, action, callBack, resolve, reject)
            }
            // event after
            edata2.finish()
        }
    }
    requestComplete(data, action, callBack, resolve, reject) {
        let error = data.error ?? false
        if (data.error == null && data.status === 'error') error = true
        this.last.fetch.response = (Date.now() - this.last.fetch.start) / 1000
        setTimeout(() => {
            if (this.show.statusResponse) {
                this.status(w2utils.lang('Server Response ${count} seconds', { count: this.last.fetch.response }))
            }
        }, 10)
        this.last.pull_more = false
        this.last.pull_refresh = true
        // event before
        let event_name = 'load'
        if (this.last.fetch.action == 'save') event_name = 'save'
        if (this.last.fetch.action == 'delete') event_name = 'delete'
        let edata = this.trigger(event_name, { target: this.name, error, data, lastFetch: this.last.fetch })
        if (edata.isCancelled === true) {
            reject()
            return
        }
        // parse server response
        if (!error) {
            // default action
            if (typeof this.parser == 'function') {
                data = this.parser(data)
                if (typeof data != 'object') {
                    console.log('ERROR: Your parser did not return proper object')
                }
            } else {
                if (data == null) {
                    data = {
                        error: true,
                        message: w2utils.lang(this.msgNotJSON),
                    }
                } else if (Array.isArray(data)) {
                    // if it is plain array, assume these are records
                    data = {
                        error,
                        records: data,
                        total: data.length
                    }
                }
            }
            if (action == 'load') {
                if (data.total == null) data.total = -1
                if (data.records == null) {
                    data.records = []
                }
                if (data.records.length == this.limit) {
                    let loaded = this.records.length + data.records.length
                    this.last.fetch.hasMore = (loaded == this.total ? false : true)
                } else {
                    this.last.fetch.hasMore = false
                    this.total = this.offset + this.last.fetch.offset + data.records.length
                }
                if (!this.last.fetch.hasMore) {
                    // if no more records, then hide spinner
                    query(this.box).find('#grid_'+ this.name +'_rec_more, #grid_'+ this.name +'_frec_more').hide()
                }
                if (this.last.fetch.offset === 0) {
                    this.records = []
                    this.summary = []
                } else {
                    if (data.total != -1 && parseInt(data.total) != parseInt(this.total)) {
                        let grid = this
                        this.message(w2utils.lang(this.msgNeedReload))
                            .ok(() => {
                                delete grid.last.fetch.offset
                                grid.reload()
                            })
                        return new Promise(resolve => { resolve() })
                    }
                }
                if (w2utils.isInt(data.total)) this.total = parseInt(data.total)
                // records
                if (data.records) {
                    data.records.forEach(rec => {
                        if (this.recid) {
                            rec.recid = this.parseField(rec, this.recid)
                        }
                        if (rec.recid == null) {
                            rec.recid = 'recid-' + this.records.length
                        }
                        if (rec.w2ui?.summary === true) {
                            this.summary.push(rec)
                        } else {
                            this.records.push(rec)
                        }
                    })
                }
                // summary records (if any)
                if (data.summary) {
                    this.summary = [] // reset summary with each call
                    data.summary.forEach(rec => {
                        if (this.recid) {
                            rec.recid = this.parseField(rec, this.recid)
                        }
                        if (rec.recid == null) {
                            rec.recid = 'recid-' + this.summary.length
                        }
                        this.summary.push(rec)
                    })
                }
            } else if (action == 'delete') {
                this.reset() // unselect old selections
                return this.reload()
            }
        } else {
            this.error(w2utils.lang(data.message ?? this.msgServerError))
            reject(data)
        }
        // event after
        let url = this.url?.get ?? this.url
        if (!url) {
            this.localSort()
            this.localSearch()
        }
        this.total = parseInt(this.total)
        // do not refresh if loading on infinite scroll
        if (this.last.fetch.offset === 0) {
            this.refresh()
        } else {
            this.scroll()
            this.resize()
        }
        // call back
        if (typeof callBack == 'function') callBack(data) // need to be before event:after
        resolve(data)
        // after event
        edata.finish()
        this.last.fetch.loaded = true
    }
    error(msg) {
        // let the management of the error outside of the grid
        let edata = this.trigger('error', { target: this.name, message: msg })
        if (edata.isCancelled === true) {
            return
        }
        this.message(msg)
        // event after
        edata.finish()
    }
    getChanges(recordsBase) {
        let changes = []
        if (typeof recordsBase == 'undefined') {
            recordsBase = this.records
        }
        for (let r = 0; r < recordsBase.length; r++) {
            let rec = recordsBase[r]
            if (rec?.w2ui) {
                if (rec.w2ui.changes != null) {
                    let obj                    = {}
                    obj[this.recid || 'recid'] = rec.recid
                    changes.push(w2utils.extend(obj, rec.w2ui.changes))
                }
                // recursively look for changes in non-expanded children
                if (rec.w2ui.expanded !== true && rec.w2ui.children && rec.w2ui.children.length) {
                    changes.push(...this.getChanges(rec.w2ui.children))
                }
            }
        }
        return changes
    }
    mergeChanges() {
        let changes = this.getChanges()
        for (let c = 0; c < changes.length; c++) {
            let record = this.get(changes[c][this.recid || 'recid'])
            for (let s in changes[c]) {
                if (s == 'recid' || (this.recid && s == this.recid)) continue // do not allow to change recid
                if (typeof changes[c][s] === 'object') changes[c][s] = changes[c][s].text
                try {
                    _setValue(record, s, changes[c][s])
                } catch (e) {
                    console.log('ERROR: Cannot merge. ', e.message || '', e)
                }
                if (record.w2ui) delete record.w2ui.changes
            }
        }
        this.refresh()
        function _setValue(obj, field, value) {
            let fld = field.split('.')
            if (fld.length == 1) {
                obj[field] = value
            } else {
                obj = obj[fld[0]]
                fld.shift()
                _setValue(obj, fld.join('.'), value)
            }
        }
    }
    save(callBack) {
        let changes = this.getChanges()
        let url = this.url?.save ?? this.url
        // event before
        let edata = this.trigger('save', { target: this.name, changes: changes })
        if (edata.isCancelled === true) return
        if (url) {
            this.request('save', { 'changes' : edata.detail.changes }, null,
                (data) => {
                    if (!data.error) {
                        // only merge changes, if save was successful
                        this.mergeChanges()
                    }
                    // event after
                    edata.finish()
                    // call back
                    if (typeof callBack == 'function') callBack(data)
                }
            )
        } else {
            this.mergeChanges()
            // event after
            edata.finish()
        }
    }
    editField(recid, column, value, event) {
        let self = this
        if (this.last.inEditMode === true) {
            // This is triggerign when user types fast
            if (event && event.keyCode == 13) {
                let { index, column, value } = this.last._edit
                this.editChange({ type: 'custom', value }, index, column, event)
                this.editDone(index, column, event)
            } else {
                // when 2 chars entered fast (spreadsheet)
                let input = query(this.box).find('div.w2ui-edit-box .w2ui-input')
                if (input.length > 0) {
                    if (input.get(0).tagName == 'DIV') {
                        input.text(input.text() + value)
                        w2utils.setCursorPosition(input.get(0), input.text().length)
                    } else {
                        input.val(input.val() + value)
                        w2utils.setCursorPosition(input.get(0), input.val().length)
                    }
                }
            }
            return
        }
        let index = this.get(recid, true)
        let edit = this.getCellEditable(index, column)
        if (!edit || ['checkbox', 'check'].includes(edit.type)) return
        let rec = this.records[index]
        let col = this.columns[column]
        let prefix = (col.frozen === true ? '_f' : '_')
        if (['enum', 'file'].indexOf(edit.type) != -1) {
            console.log('ERROR: input types "enum" and "file" are not supported in inline editing.')
            return
        }
        // event before
        let edata = this.trigger('editField', { target: this.name, recid, column, value, index, originalEvent: event })
        if (edata.isCancelled === true) return
        value = edata.detail.value
        // default behaviour
        this.last.inEditMode = true
        this.last.editColumn = column
        this.last._edit = { value: value, index: index, column: column, recid: recid }
        this.selectNone(true) // no need to trigger select event
        this.select({ recid: recid, column: column })
        // create input element
        let tr = query(this.box).find('#grid_'+ this.name + prefix +'rec_' + w2utils.escapeId(recid))
        let div = tr.find('[col="'+ column +'"] > div') // TD -> DIV
        this.last._edit.tr = tr
        this.last._edit.div = div
        // clear previous if any (spreadsheet)
        query(this.box).find('div.w2ui-edit-box').remove()
        // for spreadsheet - insert into selection
        if (this.selectType != 'row') {
            query(this.box).find('#grid_'+ this.name + prefix + 'selection')
                .attr('id', 'grid_'+ this.name + '_editable')
                .removeClass('w2ui-selection')
                .addClass('w2ui-edit-box')
                .prepend('<div style="position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px;"></div>')
                .find('.w2ui-selection-resizer')
                .remove()
            div = query(this.box).find('#grid_'+ this.name + '_editable > div:first-child')
        }
        edit.attr  = edit.attr ?? ''
        edit.text  = edit.text ?? ''
        edit.style = edit.style ?? ''
        edit.items = edit.items ?? []
        let val = (rec.w2ui?.changes?.[col.field] != null
            ? w2utils.stripTags(rec.w2ui.changes[col.field])
            : w2utils.stripTags(self.parseField(rec, col.field)))
        if (val == null) val = ''
        let prevValue = (typeof val != 'object' ? val : '')
        if (edata.detail.prevValue != null) prevValue = edata.detail.prevValue
        if (value != null) val = value
        let addStyle = (col.style != null ? col.style + ';' : '')
        if (typeof col.render == 'string'
                && ['number', 'int', 'float', 'money', 'percent', 'size'].includes(col.render.split(':')[0])) {
            addStyle += 'text-align: right;'
        }
        // normalize items, if not yet normlized
        if (edit.items.length > 0 && !w2utils.isPlainObject(edit.items[0])) {
            edit.items = w2utils.normMenu(edit.items)
        }
        let input
        let dropTypes = ['date', 'time', 'datetime', 'color', 'list', 'combo']
        let styles = getComputedStyle(tr.find('[col="'+ column +'"] > div').get(0))
        let font = `font-family: ${styles['font-family']}; font-size: ${styles['font-size']};`
        switch (edit.type) {
            case 'div': {
                div.addClass('w2ui-editable')
                    .html(w2utils.stripSpaces(`<div id="grid_${this.name}_edit_${recid}_${column}" class="w2ui-input w2ui-focus"
                        contenteditable autocorrect="off" autocomplete="off" spellcheck="false"
                        style="${font + addStyle + edit.style}"
                        field="${col.field}" recid="${recid}" column="${column}" ${edit.attr}>
                    </div>${edit.text}`))
                input = div.find('div.w2ui-input').get(0)
                input.innerText = (typeof val != 'object' ? val : '')
                if (value != null) {
                    w2utils.setCursorPosition(input, input.innerText.length)
                } else {
                    w2utils.setCursorPosition(input, 0, input.innerText.length)
                }
                break
            }
            default: {
                div.addClass('w2ui-editable')
                    .html(w2utils.stripSpaces(`<input id="grid_${this.name}_edit_${recid}_${column}" class="w2ui-input"
                        autocorrect="off" autocomplete="off" spellcheck="false" type="text"
                        style="${font + addStyle + edit.style}"
                        field="${col.field}" recid="${recid}" column="${column}" ${edit.attr}>${edit.text}`))
                input = div.find('input').get(0)
                // issue #499
                if (edit.type == 'number') {
                    val = w2utils.formatNumber(val)
                }
                if (edit.type == 'date') {
                    val = w2utils.formatDate(w2utils.isDate(val, edit.format, true) || new Date(), edit.format)
                }
                input.value = (typeof val != 'object' ? val : '')
                // init w2field, attached to input._w2field
                let doHide = (event) => {
                    let escKey = this.last._edit?.escKey
                    // check if any element is selected in drop down
                    let selected = false
                    let name = query(input).data('tooltipName')
                    if (name && w2tooltip.get(name[0])?.selected != null) {
                        selected = true
                    }
                    // trigger change on new value if selected from overlay
                    if (this.last.inEditMode && !escKey && dropTypes.includes(edit.type) // drop down types
                            && (event.detail.overlay.anchor?.id == this.last._edit.input?.id || edit.type == 'list')) {
                        this.editChange()
                        this.editDone(undefined, undefined, { keyCode: selected ? 13 : 0 }) // advance on select
                    }
                }
                new w2field(w2utils.extend({}, edit, {
                    el: input,
                    selected: val,
                    onSelect: doHide,
                    onHide: doHide
                }))
                if (value == null && input) {
                    // if no new value, then select content
                    input.select()
                }
            }
        }
        Object.assign(this.last._edit, { input, edit })
        query(input)
            .off('.w2ui-editable')
            .on('blur.w2ui-editable', (event) => {
                if (this.last.inEditMode) {
                    let type = this.last._edit.edit.type
                    let name = query(input).data('tooltipName') // if popup is open
                    if (dropTypes.includes(type) && name) {
                        // drop downs finish edit when popover is closed
                        return
                    }
                    this.editChange(input, index, column, event)
                    this.editDone()
                }
            })
            .on('mousedown.w2ui-editable', (event) => {
                event.stopPropagation()
            })
            .on('click.w2ui-editable', (event) => {
                expand.call(input, event)
            })
            .on('paste.w2ui-editable', (event) => {
                // clean paste to be plain text
                event.preventDefault()
                let text = event.clipboardData.getData('text/plain')
                document.execCommand('insertHTML', false, text)
            })
            .on('keyup.w2ui-editable', (event) => {
                expand.call(input, event)
            })
            .on('keydown.w2ui-editable', (event) => {
                switch (event.keyCode) {
                    case 8: // backspace;
                        if (edit.type == 'list' && !input._w2field) { // cancel backspace when deleting element
                            event.preventDefault()
                        }
                        break
                    case 9:
                    case 13:
                        event.preventDefault()
                        break
                    case 27: // esc button exits edit mode, but if in a popup, it will also close the popup, hence
                        // if tooltip is open - hide it
                        let name = query(input).data('tooltipName')
                        if (name && name.length > 0) {
                            this.last._edit.escKey = true
                            w2tooltip.hide(name[0])
                            event.preventDefault()
                        }
                        event.stopPropagation()
                        break
                }
                // need timeout so, this handler is executed after key is processed by browser
                setTimeout(() => {
                    switch (event.keyCode) {
                        case 9: { // tab
                            let next = event.shiftKey
                                ? self.prevCell(index, column, true)
                                : self.nextCell(index, column, true)
                            if (next != null) {
                                let recid = self.records[next.index].recid
                                this.editChange(input, index, column, event)
                                this.editDone(index, column, event)
                                if (self.selectType != 'row') {
                                    self.selectNone(true) // no need to trigger select event
                                    self.select({ recid, column: next.colIndex })
                                } else {
                                    self.editField(recid, next.colIndex, null, event)
                                }
                                if (event.preventDefault) event.preventDefault()
                            }
                            break
                        }
                        case 13: { // enter
                            // check if any element is selected in drop down
                            let selected = false
                            let name = query(input).data('tooltipName')
                            if (name && w2tooltip.get(name[0]).selected != null) {
                                selected = true
                            }
                            // if tooltip is not open or no element is selected
                            if (!name || !selected) {
                                this.editChange(input, index, column, event)
                                this.editDone(index, column, event)
                            }
                            break
                        }
                        case 27: { // escape
                            this.last._edit.escKey = false
                            let old = self.parseField(rec, col.field)
                            if (rec.w2ui?.changes?.[col.field] != null) old = rec.w2ui.changes[col.field]
                            if (input._prevValue != null) old = input._prevValue
                            if (input.tagName == 'DIV') {
                                input.innerText = old != null ? old : ''
                            } else {
                                input.value = old != null ? old : ''
                            }
                            this.editDone(index, column, event)
                            setTimeout(() => { self.select({ recid: recid, column: column }) }, 1)
                            break
                        }
                    }
                    // if input too small - expand
                    expand(input)
                }, 1)
            })
        // save previous value
        if (input) input._prevValue = prevValue
        // focus and select
        if (edit.type != 'list') {
            setTimeout(() => {
                if (!this.last.inEditMode) return
                if (input) {
                    input.focus()
                    clearTimeout(this.last.kbd_timer) // keep focus
                    input.resize = expand
                    expand(input)
                }
            }, 50)
        }
        // event after
        edata.finish({ input })
        return
        function expand(input) {
            try {
                let styles = getComputedStyle(input)
                let val = (input.tagName.toUpperCase() == 'DIV' ? input.innerText : input.value)
                let editBox = query(self.box).find('#grid_'+ self.name + '_editable').get(0)
                let style = `font-family: ${styles['font-family']}; font-size: ${styles['font-size']}; white-space: no-wrap;`
                let width = w2utils.getStrWidth(val, style)
                if (width + 20 > editBox.clientWidth) {
                    query(editBox).css('width', width + 20 + 'px')
                }
            } catch (e) {
            }
        }
    }
    editChange(input, index, column, event) {
        // if params are not specified
        input = input ?? this.last._edit.input
        index = index ?? this.last._edit.index
        column = column ?? this.last._edit.column
        event = event ?? {}
        // all other fields
        let summary = index < 0
        index       = index < 0 ? -index - 1 : index
        let records = summary ? this.summary : this.records
        let rec     = records[index]
        let col     = this.columns[column]
        let new_val = (input?.tagName == 'DIV' ? input.innerText : input.value)
        let fld     = input._w2field
        if (fld) {
            if (fld.type == 'list') {
                new_val = fld.selected
            }
            if (Object.keys(new_val).length === 0 || new_val == null) new_val = ''
            if (!w2utils.isPlainObject(new_val)) new_val = fld.clean(new_val)
        }
        if (input.type == 'checkbox') {
            if (rec.w2ui?.editable === false) input.checked = !input.checked
            new_val = input.checked
        }
        let old_val = this.parseField(rec, col.field)
        let prev_val = (rec.w2ui?.changes && rec.w2ui.changes.hasOwnProperty(col.field) ? rec.w2ui.changes[col.field]: old_val)
        // change/restore event
        let edata = {
            target: this.name, input,
            recid: rec.recid, index, column,
            originalEvent: event,
            value: {
                new: new_val,
                previous: prev_val,
                original: old_val,
            }
        }
        if (event.target?._prevValue != null) edata.value.previous = event.target._prevValue
        let count = 0 // just in case to avoid infinite loop
        while (count < 20) {
            count++
            new_val = edata.value.new
            if ((typeof new_val != 'object' && String(old_val) != String(new_val)) ||
                (typeof new_val == 'object' && new_val && new_val.id != old_val
                    && (typeof old_val != 'object' || old_val == null || new_val.id != old_val.id))) {
                // change event
                edata = this.trigger('change', edata)
                if (edata.isCancelled !== true) {
                    if (new_val !== edata.detail.value.new) {
                        // re-evaluate the type of change to be made
                        continue
                    }
                    // default action
                    if ((edata.detail.value.new === '' || edata.detail.value.new == null) && (prev_val === '' || prev_val == null)) {
                        // value did not change, was empty is empty
                    } else {
                        rec.w2ui = rec.w2ui ?? {}
                        rec.w2ui.changes = rec.w2ui.changes ?? {}
                        rec.w2ui.changes[col.field] = edata.detail.value.new
                    }
                    // event after
                    edata.finish()
                }
            } else {
                // restore event
                edata = this.trigger('restore', edata)
                if (edata.isCancelled !== true) {
                    if (new_val !== edata.detail.value.new) {
                        // re-evaluate the type of change to be made
                        continue
                    }
                    // default action
                    if (rec.w2ui?.changes) {
                        delete rec.w2ui.changes[col.field]
                        if (Object.keys(rec.w2ui.changes).length === 0) {
                            delete rec.w2ui.changes
                        }
                    }
                    // event after
                    edata.finish()
                }
            }
            break
        }
    }
    editDone(index, column, event) {
        // if params are not specified
        index = index ?? this.last._edit.index
        column = column ?? this.last._edit.column
        event = event ?? {}
        // removal of input happens when TR is redrawn
        if (this.advanceOnEdit && event.keyCode == 13) {
            let next = event.shiftKey ? this.prevRow(index, column, 1) : this.nextRow(index, column, 1)
            if (next == null) next = index // keep the same
            setTimeout(() => {
                if (this.selectType != 'row') {
                    this.selectNone(true) // no need to trigger select event
                    this.select({ recid: this.records[next].recid, column: column })
                } else {
                    this.editField(this.records[next].recid, column, null, event)
                }
            }, 1)
        }
        let summary = index < 0
        let cell = query(this.last._edit.tr).find('[col="'+ column +'"]')
        let rec  = this.records[index]
        let col  = this.columns[column]
        // need to set before remove, as remove will trigger blur
        this.last.inEditMode = false
        this.last._edit = null
        // remove - by updating cell data
        if (!summary) {
            if (rec.w2ui?.changes?.[col.field] != null) {
                cell.addClass('w2ui-changed')
            } else {
                cell.removeClass('w2ui-changed')
            }
            cell.replace(this.getCellHTML(index, column, summary))
        }
        // remove - spreadsheet
        query(this.box).find('div.w2ui-edit-box').remove()
        // update toolbar buttons
        this.updateToolbar()
        // keep grid in focus if needed
        setTimeout(() => {
            let input = query(this.box).find(`#grid_${this.name}_focus`).get(0)
            if (document.activeElement !== input && !this.last.inEditMode) {
                input.focus()
            }
        }, 10)
    }
    'delete'(force) {
        // event before
        let edata = this.trigger('delete', { target: this.name, force: force })
        if (force) this.message() // close message
        if (edata.isCancelled === true) return
        force = edata.detail.force
        // default action
        let recs = this.getSelection()
        if (recs.length === 0) return
        if (this.msgDelete != '' && !force) {
            this.confirm({
                text: w2utils.lang(this.msgDelete, {
                    count: recs.length,
                    records: w2utils.lang( recs.length == 1 ? 'record' : 'records')
                }),
                width: 380,
                height: 170,
                yes_text: w2utils.lang('Delete'),
                yes_class: 'w2ui-btn-red',
                no_text: w2utils.lang('Cancel'),
            })
                .yes(event => {
                    event.detail.self.close()
                    this.delete(true)
                })
                .no(event => {
                    event.detail.self.close()
                })
            return
        }
        // call delete script
        let url = (typeof this.url != 'object' ? this.url : this.url.remove)
        if (url) {
            this.request('delete')
        } else {
            if (typeof recs[0] != 'object') {
                this.selectNone()
                this.remove.apply(this, recs)
            } else {
                // clear cells
                for (let r = 0; r < recs.length; r++) {
                    let fld = this.columns[recs[r].column].field
                    let ind = this.get(recs[r].recid, true)
                    let rec = this.records[ind]
                    if (ind != null && fld != 'recid') {
                        this.records[ind][fld] = ''
                        if (rec.w2ui?.changes) delete rec.w2ui.changes[fld]
                        // -- style should not be deleted
                        // if (rec.style != null && w2utils.isPlainObject(rec.style) && rec.style[recs[r].column]) {
                        //     delete rec.style[recs[r].column];
                        // }
                    }
                }
                this.update()
            }
        }
        // event after
        edata.finish()
    }
    click(recid, event) {
        let time = Date.now()
        let column = null
        if (this.last.cancelClick == true || (event && event.altKey)) return
        if ((typeof recid == 'object') && (recid !== null)) {
            column = recid.column
            recid  = recid.recid
        }
        if (event == null) event = {}
        // check for double click
        if (time - parseInt(this.last.click_time) < 350 && this.last.click_recid == recid && event.type == 'click') {
            this.dblClick(recid, event)
            return
        }
        // hide bubble
        if (this.last.bubbleEl) {
            this.last.bubbleEl = null
        }
        this.last.click_time  = time
        let last_recid = this.last.click_recid
        this.last.click_recid = recid
        // column user clicked on
        if (column == null && event.target) {
            let trg = event.target
            if (trg.tagName != 'TD') trg = query(trg).closest('td')[0]
            if (query(trg).attr('col') != null) column = parseInt(query(trg).attr('col'))
        }
        // event before
        let edata = this.trigger('click', { target: this.name, recid, column, originalEvent: event })
        if (edata.isCancelled === true) return
        // default action
        let sel = this.getSelection()
        query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', false)
        let ind = this.get(recid, true)
        let selectColumns   = []
        this.last.sel_ind   = ind
        this.last.sel_col   = column
        this.last.sel_recid = recid
        this.last.sel_type  = 'click'
        // multi select with shift key
        let start, end, t1, t2
        if (event.shiftKey && sel.length > 0 && this.multiSelect) {
            if (sel[0].recid) {
                start = this.get(sel[0].recid, true)
                end   = this.get(recid, true)
                if (column > sel[0].column) {
                    t1 = sel[0].column
                    t2 = column
                } else {
                    t1 = column
                    t2 = sel[0].column
                }
                for (let c = t1; c <= t2; c++) selectColumns.push(c)
            } else {
                start = this.get(last_recid, true)
                end   = this.get(recid, true)
            }
            let sel_add = []
            if (start > end) { let tmp = start; start = end; end = tmp }
            let url = this.url?.get ? this.url.get : this.url
            for (let i = start; i <= end; i++) {
                if (this.searchData.length > 0 && !url && !this.last.searchIds.includes(i)) continue
                if (this.selectType == 'row') {
                    sel_add.push(this.records[i].recid)
                } else {
                    for (let sc = 0; sc < selectColumns.length; sc++) {
                        sel_add.push({ recid: this.records[i].recid, column: selectColumns[sc] })
                    }
                }
                //sel.push(this.records[i].recid);
            }
            this.select(sel_add)
        } else {
            let last = this.last.selection
            let flag = (last.indexes.indexOf(ind) != -1 ? true : false)
            let fselect = false
            // if clicked on the checkbox
            if (query(event.target).closest('td').hasClass('w2ui-col-select')) fselect = true
            // clear other if necessary
            if (((!event.ctrlKey && !event.shiftKey && !event.metaKey && !fselect) || !this.multiSelect) && !this.showSelectColumn) {
                if (this.selectType != 'row' && !last.columns[ind]?.includes(column)) flag = false
                this.selectNone(true) // no need to trigger select event
                if (flag === true && sel.length == 1) {
                    this.unselect({ recid: recid, column: column })
                } else {
                    this.select({ recid: recid, column: column })
                }
            } else {
                if (this.selectType != 'row' && !last.columns[ind]?.includes(column)) flag = false
                if (flag === true) {
                    this.unselect({ recid: recid, column: column })
                } else {
                    this.select({ recid: recid, column: column })
                }
            }
        }
        this.status()
        this.initResize()
        // event after
        edata.finish()
    }
    columnClick(field, event) {
        // ignore click if column was resized
        if (this.last.colResizing === true) {
            return
        }
        // event before
        let edata = this.trigger('columnClick', { target: this.name, field: field, originalEvent: event })
        if (edata.isCancelled === true) return
        // default behaviour
        if (this.selectType == 'row') {
            let column = this.getColumn(field)
            if (column && column.sortable) this.sort(field, null, (event && (event.ctrlKey || event.metaKey) ? true : false))
            if (edata.detail.field == 'line-number') {
                if (this.getSelection().length >= this.records.length) {
                    this.selectNone()
                } else {
                    this.selectAll()
                }
            }
        } else {
            if (event.altKey){
                let column = this.getColumn(field)
                if (column && column.sortable) this.sort(field, null, (event && (event.ctrlKey || event.metaKey) ? true : false))
            }
            // select entire column
            if (edata.detail.field == 'line-number') {
                if (this.getSelection().length >= this.records.length) {
                    this.selectNone()
                } else {
                    this.selectAll()
                }
            } else {
                if (!event.shiftKey && !event.metaKey && !event.ctrlKey) {
                    this.selectNone(true)
                }
                let tmp    = this.getSelection()
                let column = this.getColumn(edata.detail.field, true)
                let sel    = []
                let cols   = []
                // check if there was a selection before
                if (tmp.length != 0 && event.shiftKey) {
                    let start = column
                    let end   = tmp[0].column
                    if (start > end) {
                        start = tmp[0].column
                        end   = column
                    }
                    for (let i = start; i<=end; i++) cols.push(i)
                } else {
                    cols.push(column)
                }
                edata = this.trigger('columnSelect', { target: this.name, columns: cols })
                if (edata.isCancelled !== true) {
                    for (let i = 0; i < this.records.length; i++) {
                        sel.push({ recid: this.records[i].recid, column: cols })
                    }
                    this.select(sel)
                }
                edata.finish()
            }
        }
        // event after
        edata.finish()
    }
    columnDblClick(field, event) {
        // event before
        let edata = this.trigger('columnDblClick', { target: this.name, field: field, originalEvent: event })
        if (edata.isCancelled === true) return
        // event after
        edata.finish()
    }
    columnContextMenu(field, event) {
        let edata = this.trigger('columnContextMenu', {target: this.name, field: field, originalEvent: event })
        if (edata.isCancelled === true) return
        if (this.show.columnMenu) {
            w2menu.show({
                type: 'check',
                anchor: document.body,
                originalEvent: event,
                items: this.initColumnOnOff()
            })
            .then(() => {
                query('#w2overlay-context-menu .w2ui-grid-skip')
                    .off('.w2ui-grid')
                    .on('click.w2ui-grid', evt => {
                        evt.stopPropagation()
                    })
                    .on('keypress', evt => {
                        if (evt.keyCode == 13) {
                            this.skip(evt.target.value)
                            this.toolbar.click('w2ui-column-on-off') // close menu
                        }
                    })
            })
            .select((event) => {
                let id = event.detail.item.id
                if (['w2ui-stateSave', 'w2ui-stateReset'].includes(id)) {
                    this[id.substring(5)]()
                } else if (id == 'w2ui-skip') {
                    // empty
                } else {
                    this.columnOnOff(event, event.detail.item.id)
                }
                clearTimeout(this.last.kbd_timer) // keep grid in focus
            })
            clearTimeout(this.last.kbd_timer) // keep grid in focus
        }
        event.preventDefault()
        edata.finish()
    }
    focus(event) {
        // event before
        let edata = this.trigger('focus', { target: this.name, originalEvent: event })
        if (edata.isCancelled === true) return false
        // default behaviour
        this.hasFocus = true
        query(this.box).removeClass('w2ui-inactive').find('.w2ui-inactive').removeClass('w2ui-inactive')
        setTimeout(() => {
            let txt = query(this.box).find(`#grid_${this.name}_focus`).get(0)
            if (txt && document.activeElement != txt) {
                txt.focus()
            }
        }, 10)
        // event after
        edata.finish()
    }
    blur(event) {
        // event before
        let edata = this.trigger('blur', { target: this.name, originalEvent: event })
        if (edata.isCancelled === true) return false
        // default behaviour
        this.hasFocus = false
        query(this.box).addClass('w2ui-inactive').find('.w2ui-selected').addClass('w2ui-inactive')
        query(this.box).find('.w2ui-selection').addClass('w2ui-inactive')
        // event after
        edata.finish()
    }
    keydown(event) {
        // this method is called from w2utils
        let obj = this
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if (obj.keyboard !== true) return
        // trigger event
        let edata = obj.trigger('keydown', { target: obj.name, originalEvent: event })
        if (edata.isCancelled === true) return
        // default behavior
        if (query(this.box).find('.w2ui-message').length > 0) {
            // if there are messages
            if (event.keyCode == 27) this.message()
            return
        }
        let empty   = false
        let records = query(obj.box).find('#grid_'+ obj.name +'_records')
        let sel     = obj.getSelection()
        if (sel.length === 0) empty = true
        let recid   = sel[0] || null
        let columns = []
        let recid2  = sel[sel.length-1]
        if (typeof recid == 'object' && recid != null) {
            recid   = sel[0].recid
            columns = []
            let ii  = 0
            while (true) {
                if (!sel[ii] || sel[ii].recid != recid) break
                columns.push(sel[ii].column)
                ii++
            }
            recid2 = sel[sel.length-1].recid
        }
        let ind      = obj.get(recid, true)
        let ind2     = obj.get(recid2, true)
        let recEL    = query(obj.box).find(`#grid_${obj.name}_rec_${(ind != null ? w2utils.escapeId(obj.records[ind].recid) : 'none')}`)
        let pageSize = Math.floor(records[0].clientHeight / obj.recordHeight)
        let cancel   = false
        let key      = event.keyCode
        let shiftKey = event.shiftKey
        switch (key) {
            case 8: // backspace
            case 46: // delete
                // delete if button is visible
                obj.delete()
                cancel = true
                event.stopPropagation()
                break
            case 27: // escape
                obj.selectNone()
                cancel = true
                break
            case 65: // cmd + A
                if (!event.metaKey && !event.ctrlKey) break
                obj.selectAll()
                cancel = true
                break
            case 13: // enter
                // if expandable columns - expand it
                if (this.selectType == 'row' && obj.show.expandColumn === true) {
                    if (recEL.length <= 0) break
                    obj.toggle(recid, event)
                    cancel = true
                } else { // or enter edit
                    for (let c = 0; c < this.columns.length; c++) {
                        let edit = this.getCellEditable(ind, c)
                        if (edit) {
                            columns.push(parseInt(c))
                            break
                        }
                    }
                    // edit last column that was edited
                    if (this.selectType == 'row' && this.last._edit && this.last._edit.column) {
                        columns = [this.last._edit.column]
                    }
                    if (columns.length > 0) {
                        obj.editField(recid, this.last.editColumn || columns[0], null, event)
                        cancel = true
                    }
                }
                break
            case 37: // left
                moveLeft()
                break
            case 39: // right
                moveRight()
                break
            case 33: // <PgUp>
                moveUp(pageSize)
                break
            case 34: // <PgDn>
                moveDown(pageSize)
                break
            case 35: // <End>
                moveDown(-1)
                break
            case 36: // <Home>
                moveUp(-1)
                break
            case 38: // up
                // ctrl (or cmd) + up -> same as home
                moveUp(event.metaKey || event.ctrlKey ? -1 : 1)
                break
            case 40: // down
                // ctrl (or cmd) + up -> same as end
                moveDown(event.metaKey || event.ctrlKey ? -1 : 1)
                break
            // copy & paste
            case 17: // ctrl key
            case 91: // cmd key
                // SLOW: 10k records take 7.0
                if (empty) break
                // in Safari need to copy to buffer on cmd or ctrl key (otherwise does not work)
                if (w2utils.isSafari) {
                    obj.last.copy_event = obj.copy(false, event)
                    let focus = query(obj.box).find('#grid_'+ obj.name + '_focus')
                    focus.val(obj.last.copy_event.detail.text)
                    focus[0].select()
                }
                break
            case 67: // - c
                // this fill trigger event.onComplete
                if (event.metaKey || event.ctrlKey) {
                    if (w2utils.isSafari) {
                        obj.copy(obj.last.copy_event, event)
                    } else {
                        obj.last.copy_event = obj.copy(false, event)
                        let focus = query(obj.box).find('#grid_'+ obj.name + '_focus')
                        focus.val(obj.last.copy_event.detail.text)
                        focus[0].select()
                        obj.copy(obj.last.copy_event, event)
                    }
                }
                break
            case 88: // x - cut
                if (empty) break
                if (event.ctrlKey || event.metaKey) {
                    if (w2utils.isSafari) {
                        obj.copy(obj.last.copy_event, event)
                    } else {
                        obj.last.copy_event = obj.copy(false, event)
                        let focus = query(obj.box).find('#grid_'+ obj.name + '_focus')
                        focus.val(obj.last.copy_event.detail.text)
                        focus[0].select()
                        obj.copy(obj.last.copy_event, event)
                    }
                }
                break
        }
        let tmp = [32, 187, 189, 192, 219, 220, 221, 186, 222, 188, 190, 191] // other typeable chars
        for (let i = 48; i <= 111; i++) tmp.push(i) // 0-9,a-z,A-Z,numpad
        if (tmp.indexOf(key) != -1 && !event.ctrlKey && !event.metaKey && !cancel) {
            if (columns.length === 0) columns.push(0)
            cancel = false
            // move typed key into edit
            setTimeout(() => {
                let focus = query(obj.box).find('#grid_'+ obj.name + '_focus')
                let key = focus.val()
                focus.val('')
                obj.editField(recid, columns[0], key, event)
            }, 1)
        }
        if (cancel) { // cancel default behaviour
            if (event.preventDefault) event.preventDefault()
        }
        // event after
        edata.finish()
        function moveLeft() {
            if (empty) { // no selection
                selectTopRecord()
                return
            }
            if (obj.selectType == 'row') {
                if (recEL.length <= 0) return
                let tmp = obj.records[ind].w2ui || {}
                if (tmp && tmp.parent_recid != null && (!Array.isArray(tmp.children) || tmp.children.length === 0 || !tmp.expanded)) {
                    obj.unselect(recid)
                    obj.collapse(tmp.parent_recid, event)
                    obj.select(tmp.parent_recid)
                } else {
                    obj.collapse(recid, event)
                }
            } else {
                let prev = obj.prevCell(ind, columns[0])
                if (prev?.index != ind) {
                    prev = null
                } else {
                    prev = prev?.colIndex
                }
                if (!shiftKey && prev == null) {
                    obj.selectNone(true)
                    prev = 0
                }
                if (prev != null) {
                    if (shiftKey && obj.multiSelect) {
                        if (tmpUnselect()) return
                        let tmp    = []
                        let newSel = []
                        let unSel  = []
                        if (columns.indexOf(obj.last.sel_col) === 0 && columns.length > 1) {
                            for (let i = 0; i < sel.length; i++) {
                                if (tmp.indexOf(sel[i].recid) == -1) tmp.push(sel[i].recid)
                                unSel.push({ recid: sel[i].recid, column: columns[columns.length-1] })
                            }
                            obj.unselect(unSel)
                            obj.scrollIntoView(ind, columns[columns.length-1], true)
                        } else {
                            for (let i = 0; i < sel.length; i++) {
                                if (tmp.indexOf(sel[i].recid) == -1) tmp.push(sel[i].recid)
                                newSel.push({ recid: sel[i].recid, column: prev })
                            }
                            obj.select(newSel)
                            obj.scrollIntoView(ind, prev, true)
                        }
                    } else {
                        obj.click({ recid: recid, column: prev }, event)
                        obj.scrollIntoView(ind, prev, true)
                    }
                } else {
                    // if selected more then one, then select first
                    if (!shiftKey) {
                        obj.selectNone(true)
                    }
                }
            }
            cancel = true
        }
        function moveRight() {
            if (empty) {
                selectTopRecord()
                return
            }
            if (obj.selectType == 'row') {
                if (recEL.length <= 0) return
                obj.expand(recid, event)
            } else {
                let next = obj.nextCell(ind, columns[columns.length-1]) // columns is an array of selected columns
                if (next.index != ind) {
                    next = null
                } else {
                    next = next.colIndex
                }
                if (!shiftKey && next == null) {
                    obj.selectNone(true)
                    next = obj.columns.length-1
                }
                if (next != null) {
                    if (shiftKey && key == 39 && obj.multiSelect) {
                        if (tmpUnselect()) return
                        let tmp    = []
                        let newSel = []
                        let unSel  = []
                        if (columns.indexOf(obj.last.sel_col) == columns.length-1 && columns.length > 1) {
                            for (let i = 0; i < sel.length; i++) {
                                if (tmp.indexOf(sel[i].recid) == -1) tmp.push(sel[i].recid)
                                unSel.push({ recid: sel[i].recid, column: columns[0] })
                            }
                            obj.unselect(unSel)
                            obj.scrollIntoView(ind, columns[0], true)
                        } else {
                            for (let i = 0; i < sel.length; i++) {
                                if (tmp.indexOf(sel[i].recid) == -1) tmp.push(sel[i].recid)
                                newSel.push({ recid: sel[i].recid, column: next })
                            }
                            obj.select(newSel)
                            obj.scrollIntoView(ind, next, true)
                        }
                    } else {
                        obj.click({ recid: recid, column: next }, event)
                        obj.scrollIntoView(ind, next, true)
                    }
                } else {
                    // if selected more then one, then select first
                    if (!shiftKey) {
                        obj.selectNone(true)
                    }
                }
            }
            cancel = true
        }
        function moveUp(numRows) {
            if (empty) selectTopRecord()
            if (recEL.length <= 0) return
            // move to the previous record
            let prev = obj.prevRow(ind, obj.selectType == 'row' ? 0 : sel[0].column, numRows)
            if (!shiftKey && prev == null) {
                if (obj.searchData.length != 0 && !url) {
                    prev = obj.last.searchIds[0]
                } else {
                    prev = 0
                }
            }
            if (prev != null) {
                if (shiftKey && obj.multiSelect) { // expand selection
                    if (tmpUnselect()) return
                    if (obj.selectType == 'row') {
                        if (obj.last.sel_ind > prev && obj.last.sel_ind != ind2) {
                            obj.unselect(obj.records[ind2].recid)
                        } else {
                            obj.select(obj.records[prev].recid)
                        }
                    } else {
                        if (obj.last.sel_ind > prev && obj.last.sel_ind != ind2) {
                            prev    = ind2
                            let tmp = []
                            for (let c = 0; c < columns.length; c++) tmp.push({ recid: obj.records[prev].recid, column: columns[c] })
                            obj.unselect(tmp)
                        } else {
                            let tmp = []
                            for (let c = 0; c < columns.length; c++) tmp.push({ recid: obj.records[prev].recid, column: columns[c] })
                            obj.select(tmp)
                        }
                    }
                } else { // move selected record
                    obj.selectNone(true) // no need to trigger select event
                    obj.click({ recid: obj.records[prev].recid, column: columns[0] }, event)
                }
                obj.scrollIntoView(prev, null, true, numRows != 1) // top align record
                if (event.preventDefault) event.preventDefault()
            } else {
                // if selected more then one, then select first
                if (!shiftKey) {
                    obj.selectNone(true)
                }
            }
        }
        function moveDown(numRows) {
            if (empty) selectTopRecord()
            if (recEL.length <= 0) return
            // move to the next record
            let next = obj.nextRow(ind2, obj.selectType == 'row' ? 0 : sel[0].column, numRows)
            if (!shiftKey && next == null) {
                if (obj.searchData.length != 0 && !url) {
                    next = obj.last.searchIds[obj.last.searchIds.length - 1]
                } else {
                    next = obj.records.length - 1
                }
            }
            if (next != null) {
                if (shiftKey && obj.multiSelect) { // expand selection
                    if (tmpUnselect()) return
                    if (obj.selectType == 'row') {
                        if (obj.last.sel_ind < next && obj.last.sel_ind != ind) {
                            obj.unselect(obj.records[ind].recid)
                        } else {
                            obj.select(obj.records[next].recid)
                        }
                    } else {
                        if (obj.last.sel_ind < next && obj.last.sel_ind != ind) {
                            next    = ind
                            let tmp = []
                            for (let c = 0; c < columns.length; c++) tmp.push({ recid: obj.records[next].recid, column: columns[c] })
                            obj.unselect(tmp)
                        } else {
                            let tmp = []
                            for (let c = 0; c < columns.length; c++) tmp.push({ recid: obj.records[next].recid, column: columns[c] })
                            obj.select(tmp)
                        }
                    }
                } else { // move selected record
                    obj.selectNone(true) // no need to trigger select event
                    obj.click({ recid: obj.records[next].recid, column: columns[0] }, event)
                }
                obj.scrollIntoView(next, null, true, numRows != 1) // top align record
                cancel = true
            } else {
                // if selected more then one, then select first
                if (!shiftKey) {
                    obj.selectNone(true) // no need to trigger select event
                }
            }
        }
        function selectTopRecord() {
            if (!obj.records || obj.records.length === 0) return
            let ind = Math.floor(records[0].scrollTop / obj.recordHeight) + 1
            if (!obj.records[ind] || ind < 2) ind = 0
            if (typeof obj.records[ind] === 'undefined') return
            obj.select({ recid: obj.records[ind].recid, column: 0})
        }
        function tmpUnselect () {
            if (obj.last.sel_type != 'click') return false
            if (obj.selectType != 'row') {
                obj.last.sel_type = 'key'
                if (sel.length > 1) {
                    for (let s = 0; s < sel.length; s++) {
                        if (sel[s].recid == obj.last.sel_recid && sel[s].column == obj.last.sel_col) {
                            sel.splice(s, 1)
                            break
                        }
                    }
                    obj.unselect(sel)
                    return true
                }
                return false
            } else {
                obj.last.sel_type = 'key'
                if (sel.length > 1) {
                    sel.splice(sel.indexOf(obj.records[obj.last.sel_ind].recid), 1)
                    obj.unselect(sel)
                    return true
                }
                return false
            }
        }
    }
    scrollIntoView(ind, column, instant, recTop) {
        let buffered = this.records.length
        if (this.searchData.length != 0 && !this.url) buffered = this.last.searchIds.length
        if (buffered === 0) return
        if (ind == null) {
            let sel = this.getSelection()
            if (sel.length === 0) return
            if (w2utils.isPlainObject(sel[0])) {
                ind    = sel[0].index
                column = sel[0].column
            } else {
                ind = this.get(sel[0], true)
            }
        }
        let records = query(this.box).find(`#grid_${this.name}_records`)
        let recWidth  = records[0].clientWidth
        let recHeight = records[0].clientHeight
        let recSTop   = records[0].scrollTop
        let recSLeft  = records[0].scrollLeft
        // if all records in view
        let len = this.last.searchIds.length
        if (len > 0) ind = this.last.searchIds.indexOf(ind) // if search is applied
        // smooth or instant
        records.css({ 'scroll-behavior': instant ? 'auto' : 'smooth' })
        // vertical
        if (recHeight < this.recordHeight * (len > 0 ? len : buffered) && records.length > 0) {
            // scroll to correct one
            let t1 = Math.floor(recSTop / this.recordHeight)
            let t2 = t1 + Math.floor(recHeight / this.recordHeight)
            if (ind == t1) {
                records.prop('scrollTop', recSTop - recHeight / 1.3)
            }
            if (ind == t2) {
                records.prop('scrollTop', recSTop + recHeight / 1.3)
            }
            if (ind < t1 || ind > t2) {
                records.prop('scrollTop', (ind - 1) * this.recordHeight)
            }
            if (recTop === true) {
                records.prop('scrollTop', ind * this.recordHeight)
            }
        }
        // horizontal
        if (column != null) {
            let x1 = 0
            let x2 = 0
            let sb = w2utils.scrollBarSize()
            for (let i = 0; i <= column; i++) {
                let col = this.columns[i]
                if (col.frozen || col.hidden) continue
                x1  = x2
                x2 += parseInt(col.sizeCalculated)
            }
            if (recWidth < x2 - recSLeft) { // right
                records.prop('scrollLeft', x1 - sb)
            } else if (x1 < recSLeft) { // left
                records.prop('scrollLeft', x2 - recWidth + sb * 2)
            }
        }
    }
    scrollToColumn(field) {
        if (field == null)
            return
        let sWidth = 0
        let found  = false
        for (let i = 0; i < this.columns.length; i++) {
            let col = this.columns[i]
            if (col.field == field) {
                found = true
                break
            }
            if (col.frozen || col.hidden)
                continue
            let cSize = parseInt(col.sizeCalculated ? col.sizeCalculated : col.size)
            sWidth   += cSize
        }
        if (!found)
            return
        this.last.scrollLeft = sWidth+1
        this.scroll()
    }

    dblClick(recid, event) {
        // find columns
        let column = null
        if ((typeof recid == 'object') && (recid !== null)) {
            column = recid.column
            recid  = recid.recid
        }
        if (event == null) event = {}
        // column user clicked on
        if (column == null && event.target) {
            let tmp = event.target
            if (tmp.tagName.toUpperCase() != 'TD') tmp = query(tmp).closest('td')[0]
            column = parseInt(query(tmp).attr('col'))
        }
        let index = this.get(recid, true)
        let rec   = this.records[index]
        // event before
        let edata = this.trigger('dblClick', { target: this.name, recid: recid, column: column, originalEvent: event })
        if (edata.isCancelled === true) return
        // default action
        this.selectNone(true) // no need to trigger select event
        let edit = this.getCellEditable(index, column)
        if (edit) {
            this.editField(recid, column, null, event)
        } else {
            this.select({ recid: recid, column: column })
            if (this.show.expandColumn || (rec && rec.w2ui && Array.isArray(rec.w2ui.children))) this.toggle(recid)
        }
        // event after
        edata.finish()
    }
    showContextMenu(recid, column, event) {
        if (this.last.userSelect == 'text') return
        if (event == null) {
            event = { offsetX: 0, offsetY: 0, target: query(this.box).find(`#grid_${this.name}_rec_${recid}`)[0] }
        }
        if (event.offsetX == null) {
            event.offsetX = event.layerX - event.target.offsetLeft
            event.offsetY = event.layerY - event.target.offsetTop
        }
        if (w2utils.isFloat(recid)) recid = parseFloat(recid)
        let sel = this.getSelection()
        if (this.selectType == 'row') {
            if (sel.indexOf(recid) == -1) this.click(recid)
        } else {
            let selected = false
            // check if any selected sel in the right row/column
            for (let i = 0; i < sel.length; i++) {
                if (sel[i].recid == recid || sel[i].column == column) selected = true
            }
            if (!selected && recid != null) this.click({ recid: recid, column: column })
            if (!selected && column != null) this.columnClick(this.columns[column].field, event)
        }
        // event before
        let edata = this.trigger('contextMenu', { target: this.name, originalEvent: event, recid, column })
        if (edata.isCancelled === true) return
        // default action
        if (this.contextMenu.length > 0) {
            w2menu.show({
                anchor: document.body,
                originalEvent: event,
                items: this.contextMenu
            })
            .select((event) => {
                clearTimeout(this.last.kbd_timer) // keep grid in focus
                this.contextMenuClick(recid, column, event)
            })
            clearTimeout(this.last.kbd_timer) // keep grid in focus
        }
        // cancel browser context menu
        event.preventDefault()
        // event after
        edata.finish()
    }
    contextMenuClick(recid, column, event) {
        // event before
        let edata = this.trigger('contextMenuClick', {
            target: this.name, recid, column, originalEvent: event.detail.originalEvent,
            menuEvent: event, menuIndex: event.detail.index, menuItem: event.detail.item
        })
        if (edata.isCancelled === true) return
        // no default action
        edata.finish()
    }
    toggle(recid) {
        let rec  = this.get(recid)
        if (rec == null) return
        rec.w2ui = rec.w2ui ?? {}
        if (rec.w2ui.expanded === true) return this.collapse(recid); else return this.expand(recid)
    }
    expand(recid, noRefresh) {
        let ind  = this.get(recid, true)
        let rec  = this.records[ind]
        rec.w2ui = rec.w2ui ?? {}
        let id   = w2utils.escapeId(recid)
        let children = rec.w2ui.children
        let edata
        if (Array.isArray(children)) {
            if (rec.w2ui.expanded === true || children.length === 0) return false // already shown
            edata = this.trigger('expand', { target: this.name, recid: recid })
            if (edata.isCancelled === true) return false
            rec.w2ui.expanded = true
            children.forEach((child) => {
                child.w2ui = child.w2ui ?? {}
                child.w2ui.parent_recid = rec.recid
                if (child.w2ui.children == null) child.w2ui.children = []
            })
            this.records.splice.apply(this.records, [ind + 1, 0].concat(children))
            if (this.total !== -1) {
                this.total += children.length
            }
            let url = (typeof this.url != 'object' ? this.url : this.url.get)
            if (!url) {
                this.localSort(true, true)
                if (this.searchData.length > 0) {
                    this.localSearch(true)
                }
            }
            if (noRefresh !== true) this.refresh()
            edata.finish()
        } else {
            if (query(this.box).find('#grid_'+ this.name +'_rec_'+ id +'_expanded_row').length > 0 || this.show.expandColumn !== true) return false
            if (rec.w2ui.expanded == 'none') return false
            // insert expand row
            query(this.box).find('#grid_'+ this.name +'_rec_'+ id).after(
                `<tr id="grid_${this.name}_rec_${recid}_expanded_row" class="w2ui-expanded-row">
                    <td colspan="100" class="w2ui-expanded2">
                        <div id="grid_${this.name}_rec_${recid}_expanded"></div>
                    </td>
                    <td class="w2ui-grid-data-last"></td>
                </tr>`)
            query(this.box).find('#grid_'+ this.name +'_frec_'+ id).after(
                `<tr id="grid_${this.name}_frec_${recid}_expanded_row" class="w2ui-expanded-row">
                    ${this.show.lineNumbers ? '<td class="w2ui-col-number"></td>' : ''}
                    <td class="w2ui-grid-data w2ui-expanded1" colspan="100">
                       <div id="grid_${this.name}_frec_${recid}_expanded"></div>
                    </td>
                </tr>`)
            // event before
            edata = this.trigger('expand', { target: this.name, recid: recid,
                box_id: 'grid_'+ this.name +'_rec_'+ recid +'_expanded', fbox_id: 'grid_'+ this.name +'_frec_'+ recid +'_expanded' })
            if (edata.isCancelled === true) {
                query(this.box).find('#grid_'+ this.name +'_rec_'+ id +'_expanded_row').remove()
                query(this.box).find('#grid_'+ this.name +'_frec_'+ id +'_expanded_row').remove()
                return false
            }
            // expand column
            let row1 = query(this.box).find('#grid_'+ this.name +'_rec_'+ recid +'_expanded')
            let row2 = query(this.box).find('#grid_'+ this.name +'_frec_'+ recid +'_expanded')
            let innerHeight = row1.find(':scope div:first-child')[0]?.clientHeight ?? 50
            if (row1[0].clientHeight < innerHeight) {
                row1.css({ height: innerHeight + 'px' })
            }
            if (row2[0].clientHeight < innerHeight) {
                row2.css({ height: innerHeight + 'px' })
            }
            // default action
            query(this.box).find('#grid_'+ this.name +'_rec_'+ id).attr('expanded', 'yes').addClass('w2ui-expanded')
            query(this.box).find('#grid_'+ this.name +'_frec_'+ id).attr('expanded', 'yes').addClass('w2ui-expanded')
            query(this.box).find('#grid_'+ this.name +'_cell_'+ this.get(recid, true) +'_expand div').html('-')
            rec.w2ui.expanded = true
            // event after
            edata.finish()
            this.resizeRecords()
        }
        return true
    }
    collapse(recid, noRefresh) {
        let ind      = this.get(recid, true)
        let rec      = this.records[ind]
        rec.w2ui     = rec.w2ui || {}
        let id       = w2utils.escapeId(recid)
        let children = rec.w2ui.children
        let edata
        if (Array.isArray(children)) {
            if (rec.w2ui.expanded !== true) return false // already hidden
            edata = this.trigger('collapse', { target: this.name, recid: recid })
            if (edata.isCancelled === true) return false
            clearExpanded(rec)
            let stops = []
            for (let r = rec; r != null; r = this.get(r.w2ui.parent_recid))
                stops.push(r.w2ui.parent_recid)
            // stops contains 'undefined' plus the ID of all nodes in the path from 'rec' to the tree root
            let start = ind + 1
            let end   = start
            while (true) {
                if (this.records.length <= end + 1 || this.records[end+1].w2ui == null ||
                    stops.indexOf(this.records[end+1].w2ui.parent_recid) >= 0) {
                    break
                }
                end++
            }
            this.records.splice(start, end - start + 1)
            if (this.total !== -1) {
                this.total -= end - start + 1
            }
            let url     = (typeof this.url != 'object' ? this.url : this.url.get)
            if (!url) {
                if (this.searchData.length > 0) {
                    this.localSearch(true)
                }
            }
            if (noRefresh !== true) this.refresh()
            edata.finish()
        } else {
            if (query(this.box).find('#grid_'+ this.name +'_rec_'+ id +'_expanded_row').length === 0 || this.show.expandColumn !== true) return false
            // event before
            edata = this.trigger('collapse', { target: this.name, recid: recid,
                box_id: 'grid_'+ this.name +'_rec_'+ recid +'_expanded', fbox_id: 'grid_'+ this.name +'_frec_'+ recid +'_expanded' })
            if (edata.isCancelled === true) return false
            // default action
            query(this.box).find('#grid_'+ this.name +'_rec_'+ id).removeAttr('expanded').removeClass('w2ui-expanded')
            query(this.box).find('#grid_'+ this.name +'_frec_'+ id).removeAttr('expanded').removeClass('w2ui-expanded')
            query(this.box).find('#grid_'+ this.name +'_cell_'+ this.get(recid, true) +'_expand div').html('+')
            query(this.box).find('#grid_'+ this.name +'_rec_'+ id +'_expanded').css('height', '0px')
            query(this.box).find('#grid_'+ this.name +'_frec_'+ id +'_expanded').css('height', '0px')
            setTimeout(() => {
                query(this.box).find('#grid_'+ this.name +'_rec_'+ id +'_expanded_row').remove()
                query(this.box).find('#grid_'+ this.name +'_frec_'+ id +'_expanded_row').remove()
                rec.w2ui.expanded = false
                // event after
                edata.finish()
                this.resizeRecords()
            }, 300)
        }
        return true
        function clearExpanded(rec) {
            rec.w2ui.expanded = false
            for (let i = 0; i < rec.w2ui.children.length; i++) {
                let subRec = rec.w2ui.children[i]
                if (subRec.w2ui.expanded) {
                    clearExpanded(subRec)
                }
            }
        }
    }
    sort(field, direction, multiField) { // if no params - clears sort
        // event before
        let edata = this.trigger('sort', { target: this.name, field: field, direction: direction, multiField: multiField })
        if (edata.isCancelled === true) return
        // check if needed to quit
        if (field != null) {
            // default action
            let sortIndex = this.sortData.length
            for (let s = 0; s < this.sortData.length; s++) {
                if (this.sortData[s].field == field) { sortIndex = s; break }
            }
            if (direction == null) {
                if (this.sortData[sortIndex] == null) {
                    direction = 'asc'
                } else {
                    if (this.sortData[sortIndex].direction == null) {
                        this.sortData[sortIndex].direction = ''
                    }
                    switch (this.sortData[sortIndex].direction.toLowerCase()) {
                        case 'asc' : direction = 'desc'; break
                        case 'desc' : direction = 'asc'; break
                        default : direction = 'asc'; break
                    }
                }
            }
            if (this.multiSort === false) { this.sortData = []; sortIndex = 0 }
            if (multiField != true) { this.sortData = []; sortIndex = 0 }
            // set new sort
            if (this.sortData[sortIndex] == null) this.sortData[sortIndex] = {}
            this.sortData[sortIndex].field     = field
            this.sortData[sortIndex].direction = direction
        } else {
            this.sortData = []
        }
        // if local
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if (!url) {
            this.localSort(false, true)
            if (this.searchData.length > 0) this.localSearch(true)
            // reset vertical scroll
            this.last.scrollTop = 0
            query(this.box).find(`#grid_${this.name}_records`).prop('scrollTop', 0)
            // event after
            edata.finish({ direction })
            this.refresh()
        } else {
            // event after
            edata.finish({ direction })
            this.last.fetch.offset = 0
            this.reload()
        }
    }
    copy(flag, oEvent) {
        if (w2utils.isPlainObject(flag)) {
            // event after
            flag.finish()
            return flag.text
        }
        // generate text to copy
        let sel = this.getSelection()
        if (sel.length === 0) return ''
        let text = ''
        if (typeof sel[0] == 'object') { // cell copy
            // find min/max column
            let minCol = sel[0].column
            let maxCol = sel[0].column
            let recs   = []
            for (let s = 0; s < sel.length; s++) {
                if (sel[s].column < minCol) minCol = sel[s].column
                if (sel[s].column > maxCol) maxCol = sel[s].column
                if (recs.indexOf(sel[s].index) == -1) recs.push(sel[s].index)
            }
            recs.sort((a, b) => { return a-b }) // sort function must be for numerical sort
            for (let r = 0 ; r < recs.length; r++) {
                let ind = recs[r]
                for (let c = minCol; c <= maxCol; c++) {
                    let col = this.columns[c]
                    if (col.hidden === true) continue
                    text += this.getCellCopy(ind, c) + '\t'
                }
                text  = text.substr(0, text.length-1) // remove last \t
                text += '\n'
            }
        } else { // row copy
            // copy headers
            for (let c = 0; c < this.columns.length; c++) {
                let col = this.columns[c]
                if (col.hidden === true) continue
                let colName = (col.text ? col.text : col.field)
                if (col.text && col.text.length < 3 && col.tooltip) colName = col.tooltip // if column name is less then 3 char and there is tooltip - use it
                text += '"' + w2utils.stripTags(colName) + '"\t'
            }
            text  = text.substr(0, text.length-1) // remove last \t
            text += '\n'
            // copy selected text
            for (let s = 0; s < sel.length; s++) {
                let ind = this.get(sel[s], true)
                for (let c = 0; c < this.columns.length; c++) {
                    let col = this.columns[c]
                    if (col.hidden === true) continue
                    text += '"' + this.getCellCopy(ind, c) + '"\t'
                }
                text  = text.substr(0, text.length-1) // remove last \t
                text += '\n'
            }
        }
        text = text.substr(0, text.length - 1)
        // if called without params
        let edata
        if (flag == null) {
            // before event
            edata = this.trigger('copy', { target: this.name, text: text,
                cut: (oEvent.keyCode == 88 ? true : false), originalEvent: oEvent })
            if (edata.isCancelled === true) return ''
            text = edata.detail.text
            // event after
            edata.finish()
            return text
        } else if (flag === false) { // only before event
            // before event
            edata = this.trigger('copy', { target: this.name, text: text,
                cut: (oEvent.keyCode == 88 ? true : false), originalEvent: oEvent })
            if (edata.isCancelled === true) return ''
            text = edata.detail.text
            return edata
        }
    }
    /**
     * Gets value to be copied to the clipboard
     * @param ind index of the record
     * @param col_ind index of the column
     * @returns the displayed value of the field's record associated with the cell
     */
    getCellCopy(ind, col_ind) {
        return w2utils.stripTags(this.getCellHTML(ind, col_ind))
    }
    paste(text, event) {
        let sel = this.getSelection()
        let ind = this.get(sel[0].recid, true)
        let col = sel[0].column
        // before event
        let edata = this.trigger('paste', { target: this.name, text: text, index: ind, column: col, originalEvent: event })
        if (edata.isCancelled === true) return
        text = edata.detail.text
        // default action
        if (this.selectType == 'row' || sel.length === 0) {
            console.log('ERROR: You can paste only if grid.selectType = \'cell\' and when at least one cell selected.')
            // event after
            edata.finish()
            return
        }
        if (typeof text !== 'object') {
            let newSel = []
            text = text.split('\n')
            for (let t = 0; t < text.length; t++) {
                let tmp  = text[t].split('\t')
                let cnt  = 0
                let rec  = this.records[ind]
                let cols = []
                if (rec == null) continue
                for (let dt = 0; dt < tmp.length; dt++) {
                    if (!this.columns[col + cnt]) continue
                    setCellPaste(rec, this.columns[col + cnt].field, tmp[dt])
                    cols.push(col + cnt)
                    cnt++
                }
                for (let c = 0; c < cols.length; c++) newSel.push({ recid: rec.recid, column: cols[c] })
                ind++
            }
            this.selectNone(true) // no need to trigger select event
            this.select(newSel)
        } else {
            this.selectNone(true) // no need to trigger select event
            this.select([{ recid: this.records[ind], column: col }])
        }
        this.refresh()
        // event after
        edata.finish()
        function setCellPaste(rec, field, paste) {
            rec.w2ui = rec.w2ui ?? {}
            rec.w2ui.changes = rec.w2ui.changes || {}
            rec.w2ui.changes[field] = paste
        }
    }
    // ==================================================
    // --- Common functions
    resize() {
        let time = Date.now()
        // make sure the box is right
        if (!this.box || query(this.box).attr('name') != this.name) return
        // event before
        let edata = this.trigger('resize', { target: this.name })
        if (edata.isCancelled === true) return
        // resize
        this.resizeBoxes()
        this.resizeRecords()
        // event after
        edata.finish()
        return Date.now() - time
    }
    update({ cells, fullCellRefresh, ignoreColumns } = {}) {
        let time = Date.now()
        let self = this
        if (this.box == null) return 0
        if (Array.isArray(cells)) {
            for (let i = 0; i < cells.length; i++) {
                let index  = cells[i].index
                let column = cells[i].column
                if (index < 0) continue
                if (index == null || column == null) {
                    console.log('ERROR: Wrong argument for grid.update({ cells }), cells should be [{ index: X, column: Y }, ...]')
                    continue
                }
                let rec  = this.records[index] ?? {}
                rec.w2ui = rec.w2ui ?? {}
                rec.w2ui._update = rec.w2ui._update ?? { cells: [] }
                let row1 = rec.w2ui._update.row1
                let row2 = rec.w2ui._update.row2
                if (row1 == null || !row1.isConnected || row2 == null || !row2.isColSelected) {
                    row1 = this.box.querySelector(`#grid_${this.name}_rec_${w2utils.escapeId(rec.recid)}`)
                    row2 = this.box.querySelector(`#grid_${this.name}_frec_${w2utils.escapeId(rec.recid)}`)
                    rec.w2ui._update.row1 = row1
                    rec.w2ui._update.row2 = row2
                }
                _update(rec, row1, row2, index, column)
            }
        } else {
            for (let i = this.last.range_start-1; i <= this.last.range_end; i++) {
                let index = i
                if (this.last.searchIds.length > 0) { // if search is applied
                    index = this.last.searchIds[i]
                } else {
                    index = i
                }
                let rec = this.records[index]
                if (index < 0 || rec == null) continue
                rec.w2ui = rec.w2ui ?? {}
                rec.w2ui._update = rec.w2ui._update ?? { cells: [] }
                let row1 = rec.w2ui._update.row1
                let row2 = rec.w2ui._update.row2
                if (row1 == null || !row1.isConnected || row2 == null || !row2.isColSelected) {
                    row1 = this.box.querySelector(`#grid_${this.name}_rec_${w2utils.escapeId(rec.recid)}`)
                    row2 = this.box.querySelector(`#grid_${this.name}_frec_${w2utils.escapeId(rec.recid)}`)
                    rec.w2ui._update.row1 = row1
                    rec.w2ui._update.row2 = row2
                }
                for (let column = 0; column < this.columns.length; column++) {
                    _update(rec, row1, row2, index, column)
                }
            }
        }
        return Date.now() - time
        function _update(rec, row1, row2, index, column) {
            let pcol = self.columns[column]
            if (Array.isArray(ignoreColumns) && (ignoreColumns.includes(column) || ignoreColumns.includes(pcol.field))) {
                return
            }
            let cell = rec.w2ui._update.cells[column]
            if (cell == null || !cell.isConnected) {
                cell = self.box.querySelector(`#grid_${self.name}_data_${index}_${column}`)
                rec.w2ui._update.cells[column] = cell
            }
            if (cell == null) return
            if (fullCellRefresh) {
                query(cell).replace(self.getCellHTML(index, column, false))
                // need to reselect as it was replaced
                cell = self.box.querySelector(`#grid_${self.name}_data_${index}_${column}`)
                rec.w2ui._update.cells[column] = cell
            } else {
                let div = cell.children[0] // there is always a div inside a cell
                // value, attr, style, className, divAttr -- all on TD level except divAttr
                let { value, style, className } = self.getCellValue(index, column, false, true)
                if (div.innerHTML != value) {
                    div.innerHTML = value
                }
                if (style != '' && cell.style.cssText != style) {
                    cell.style.cssText = style
                }
                if (className != '') {
                    let ignore = ['w2ui-grid-data']
                    let remove = []
                    let add = className.split(' ').filter(cl => !!cl) // remove empty
                    cell.classList.forEach(cl => { if (!ignore.includes(cl)) remove.push(cl)})
                    cell.classList.remove(...remove)
                    cell.classList.add(...add)
                }
            }
            // column styles if any (lower priority)
            if (self.columns[column].style && self.columns[column].style != cell.style.cssText) {
                cell.style.cssText = self.columns[column].style ?? ''
            }
            // record class if any
            if (rec.w2ui.class != null) {
                if (typeof rec.w2ui.class == 'string') {
                    let ignore = ['w2ui-odd', 'w2ui-even', 'w2ui-record']
                    let remove = []
                    let add = rec.w2ui.class.split(' ').filter(cl => !!cl) // remove empty
                    if (row1 && row2) {
                        row1.classList.forEach(cl => { if (!ignore.includes(cl)) remove.push(cl)})
                        row1.classList.remove(...remove)
                        row1.classList.add(...add)
                        row2.classList.remove(...remove)
                        row2.classList.add(...add)
                    }
                }
                if (w2utils.isPlainObject(rec.w2ui.class) && typeof rec.w2ui.class[pcol.field] == 'string') {
                    let ignore = ['w2ui-grid-data']
                    let remove = []
                    let add = rec.w2ui.class[pcol.field].split(' ').filter(cl => !!cl)
                    cell.classList.forEach(cl => { if (!ignore.includes(cl)) remove.push(cl)})
                    cell.classList.remove(...remove)
                    cell.classList.add(...add)
                }
            }
            // record styles if any
            if (rec.w2ui.style != null) {
                if (row1 && row2 && typeof rec.w2ui.style == 'string' && row1.style.cssText !== rec.w2ui.style) {
                    row1.style.cssText = 'height: '+ self.recordHeight + 'px;' + rec.w2ui.style
                    row1.setAttribute('custom_style', rec.w2ui.style)
                    row2.style.cssText = 'height: '+ self.recordHeight + 'px;' + rec.w2ui.style
                    row2.setAttribute('custom_style', rec.w2ui.style)
                }
                if (w2utils.isPlainObject(rec.w2ui.style) && typeof rec.w2ui.style[pcol.field] == 'string'
                        && cell.style.cssText !== rec.w2ui.style[pcol.field]) {
                    cell.style.cssText = rec.w2ui.style[pcol.field]
                }
            }
        }
    }
    refreshCell(recid, field) {
        let index = this.get(recid, true)
        let col_ind = this.getColumn(field, true)
        let isSummary = (this.records[index] && this.records[index].recid == recid ? false : true)
        let cell = query(this.box).find(`${isSummary ? '.w2ui-grid-summary ' : ''}#grid_${this.name}_data_${index}_${col_ind}`)
        if (cell.length == 0) return false
        // set cell html and changed flag
        cell.replace(this.getCellHTML(index, col_ind, isSummary))
        return true
    }
    refreshRow(recid, ind = null) {
        let tr1 = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid))
        let tr2 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(recid))
        if (tr1.length > 0) {
            if (ind == null) ind = this.get(recid, true)
            let line = tr1.attr('line')
            let isSummary = (this.records[ind] && this.records[ind].recid == recid ? false : true)
            // if it is searched, find index in search array
            let url = (typeof this.url != 'object' ? this.url : this.url.get)
            if (this.searchData.length > 0 && !url) for (let s = 0; s < this.last.searchIds.length; s++) if (this.last.searchIds[s] == ind) ind = s
            let rec_html = this.getRecordHTML(ind, line, isSummary)
            tr1.replace(rec_html[0])
            tr2.replace(rec_html[1])
            // apply style to row if it was changed in render functions
            let st = (this.records[ind].w2ui ? this.records[ind].w2ui.style : '')
            if (typeof st == 'string') {
                tr1 = query(this.box).find('#grid_'+ this.name +'_frec_'+ w2utils.escapeId(recid))
                tr2 = query(this.box).find('#grid_'+ this.name +'_rec_'+ w2utils.escapeId(recid))
                tr1.attr('custom_style', st)
                tr2.attr('custom_style', st)
                if (tr1.hasClass('w2ui-selected')) {
                    st = st.replace('background-color', 'none')
                }
                tr1[0].style.cssText = 'height: '+ this.recordHeight + 'px;' + st
                tr2[0].style.cssText = 'height: '+ this.recordHeight + 'px;' + st
            }
            if (isSummary) {
                this.resize()
            }
            return true
        }
        return false
    }
    refresh() {
        let time = Date.now()
        let url  = (typeof this.url != 'object' ? this.url : this.url.get)
        if (this.total <= 0 && !url && this.searchData.length === 0) {
            this.total = this.records.length
        }
        if (!this.box) return
        // event before
        let edata = this.trigger('refresh', { target: this.name })
        if (edata.isCancelled === true) return
        // -- header
        if (this.show.header) {
            query(this.box).find(`#grid_${this.name}_header`).html(w2utils.lang(this.header) +'&#160;').show()
        } else {
            query(this.box).find(`#grid_${this.name}_header`).hide()
        }
        // -- toolbar
        if (this.show.toolbar) {
            query(this.box).find('#grid_'+ this.name +'_toolbar').show()
        } else {
            query(this.box).find('#grid_'+ this.name +'_toolbar').hide()
        }
        // -- make sure search is closed
        this.searchClose()
        // search placeholder
        let sInput = query(this.box).find('#grid_'+ this.name +'_search_all')
        if (!this.multiSearch && this.last.field == 'all' && this.searches.length > 0) {
            this.last.field = this.searches[0].field
            this.last.label = this.searches[0].label
        }
        for (let s = 0; s < this.searches.length; s++) {
            if (this.searches[s].field == this.last.field) this.last.label = this.searches[s].label
        }
        if (this.last.multi) {
            sInput.attr('placeholder', '[' + w2utils.lang('Multiple Fields') + ']')
        } else {
            sInput.attr('placeholder', w2utils.lang('Search') + ' ' + w2utils.lang(this.last.label, true))
        }
        if (sInput.val() != this.last.search) {
            let val = this.last.search
            let tmp = sInput._w2field
            if (tmp) val = tmp.format(val)
            sInput.val(val)
        }
        this.refreshSearch()
        this.refreshBody()
        // -- footer
        if (this.show.footer) {
            query(this.box).find(`#grid_${this.name}_footer`).html(this.getFooterHTML()).show()
        } else {
            query(this.box).find(`#grid_${this.name}_footer`).hide()
        }
        // all selected?
        let sel = this.last.selection,
            areAllSelected = (this.records.length > 0 && sel.indexes.length == this.records.length),
            areAllSearchedSelected = (sel.indexes.length > 0 && this.searchData.length !== 0 && sel.indexes.length == this.last.searchIds.length)
        if (areAllSelected || areAllSearchedSelected) {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', true)
        } else {
            query(this.box).find('#grid_'+ this.name +'_check_all').prop('checked', false)
        }
        // show number of selected
        this.status()
        // collapse all records
        let rows = this.find({ 'w2ui.expanded': true }, true, true)
        for (let r = 0; r < rows.length; r++) {
            let tmp = this.records[rows[r]].w2ui
            if (tmp && !Array.isArray(tmp.children)) {
                tmp.expanded = false
            }
        }
        // mark selection
        if (this.markSearch) {
            setTimeout(() => {
                // mark all search strings
                let search = []
                for (let s = 0; s < this.searchData.length; s++) {
                    let sdata = this.searchData[s]
                    let fld   = this.getSearch(sdata.field)
                    if (!fld || fld.hidden) continue
                    let ind = this.getColumn(sdata.field, true)
                    search.push({ field: sdata.field, search: sdata.value, col: ind })
                }
                if (search.length > 0) {
                    search.forEach((item) => {
                        let el = query(this.box).find('td[col="'+ item.col +'"]:not(.w2ui-head)')
                        w2utils.marker(el, item.search)
                    })
                }
            }, 50)
        }
        this.updateToolbar(this.last.selection)
        // event after
        edata.finish()
        this.resize()
        this.addRange('selection')
        setTimeout(() => { // allow to render first
            this.resize() // needed for horizontal scroll to show (do not remove)
            this.scroll()
        }, 1)
        if (this.reorderColumns && !this.last.columnDrag) {
            this.last.columnDrag = this.initColumnDrag()
        } else if (!this.reorderColumns && this.last.columnDrag) {
            this.last.columnDrag.remove()
        }
        return Date.now() - time
    }
    refreshSearch() {
        if (this.multiSearch && this.searchData.length > 0) {
            if (query(this.box).find('.w2ui-grid-searches').length == 0) {
                query(this.box).find('.w2ui-grid-toolbar')
                    .css('height', (this.last.toolbar_height + 35) + 'px')
                    .append(`<div id="grid_${this.name}_searches" class="w2ui-grid-searches"></div>`)
            }
            let searches = `
                <span id="grid_${this.name}_search_logic" class="w2ui-grid-search-logic"></span>
                <div class="grid-search-line"></div>`
            this.searchData.forEach((sd, sd_ind) => {
                let ind = this.getSearch(sd.field, true)
                let sf = this.searches[ind]
                let display
                if (Array.isArray(sd.value)) {
                    display = `<span class="grid-search-count">${sd.value.length}</span>`
                } else if (sf && sf.type == 'list') {
                    display = !!sd.text && sd.text !== sd.value ? `: ${sd.text}` : `: ${sd.value}`
                } else {
                    display = `: ${sd.value}`
                }
                if (sf && sf.type == 'date') {
                    if (sd.operator == 'between') {
                        let dsp1 = sd.value[0]
                        let dsp2 = sd.value[1]
                        if (Number(dsp1) === dsp1) {
                            dsp1 = w2utils.formatDate(dsp1)
                        }
                        if (Number(dsp2) === dsp2) {
                            dsp2 = w2utils.formatDate(dsp2)
                        }
                        display = `: ${dsp1} - ${dsp2}`
                    } else {
                        let dsp = sd.value
                        if (Number(dsp) == dsp) {
                            dsp = w2utils.formatDate(dsp)
                        }
                        let oper = sd.operator
                        if (oper == 'more') oper = 'since'
                        if (oper == 'less') oper = 'before'
                        if (oper.substr(0, 5) == 'more:') {
                            oper = 'since'
                        }
                        display = `: ${oper} ${dsp}`
                    }
                }
                searches += `<span class="w2ui-action" data-click="searchFieldTooltip|${ind}|${sd_ind}|this">
                    ${sf ? sf.label : ''}
                    ${display}
                    <span class="icon-chevron-down"></span>
                </span>`
            })
            // clear and save
            searches += `
                ${this.show.searchSave
                    ? `<div class="grid-search-line"></div>
                       <button class="w2ui-btn grid-search-btn" data-click="searchSave">${w2utils.lang('Save')}</button>
                      `
                    : ''
}
                <button class="w2ui-btn grid-search-btn btn-remove"
                    data-click="searchReset">X</button>
            `
            query(this.box).find(`#grid_${this.name}_searches`).html(searches)
            query(this.box).find(`#grid_${this.name}_search_logic`).html(w2utils.lang(this.last.logic == 'AND' ? 'All' : 'Any'))
        } else {
            query(this.box).find('.w2ui-grid-toolbar')
                .css('height', this.last.toolbar_height + 'px')
                .find('.w2ui-grid-searches')
                .remove()
        }
        if (this.searchSelected) {
            query(this.box).find(`#grid_${this.name}_search_all`).val(' ').prop('readOnly', true)
            query(this.box).find(`#grid_${this.name}_search_name`).show().find('.name-text').html(this.searchSelected.text)
        } else {
            query(this.box).find(`#grid_${this.name}_search_all`).prop('readOnly', false)
            query(this.box).find(`#grid_${this.name}_search_name`).hide().find('.name-text').html('')
        }
        w2utils.bindEvents(query(this.box).find(`#grid_${this.name}_searches .w2ui-action, #grid_${this.name}_searches button`), this)
    }
    refreshBody() {
        this.scroll() // need to calculate virtual scrolling for columns
        let recHTML  = this.getRecordsHTML()
        let colHTML  = this.getColumnsHTML()
        let bodyHTML =
            '<div id="grid_'+ this.name +'_frecords" class="w2ui-grid-frecords" style="margin-bottom: '+ (w2utils.scrollBarSize() - 1) +'px;">'+
                recHTML[0] +
            '</div>'+
            '<div id="grid_'+ this.name +'_records" class="w2ui-grid-records">' +
                recHTML[1] +
            '</div>'+
            '<div id="grid_'+ this.name +'_scroll1" class="w2ui-grid-scroll1" style="height: '+ w2utils.scrollBarSize() +'px"></div>'+
            // Columns need to be after to be able to overlap
            '<div id="grid_'+ this.name +'_fcolumns" class="w2ui-grid-fcolumns">'+
            '    <table><tbody>'+ colHTML[0] +'</tbody></table>'+
            '</div>'+
            '<div id="grid_'+ this.name +'_columns" class="w2ui-grid-columns">'+
            '    <table><tbody>'+ colHTML[1] +'</tbody></table>'+
            '</div>'+
            `<div class="w2ui-intersection-marker" style="display: none; height: ${this.recordHeight-5}px">
               <div class="top-marker"></div>
               <div class="bottom-marker"></div>
            </div>`
        let gridBody = query(this.box).find(`#grid_${this.name}_body`, this.box).html(bodyHTML)
        let records  = query(this.box).find(`#grid_${this.name}_records`, this.box)
        let frecords = query(this.box).find(`#grid_${this.name}_frecords`, this.box)
        if (this.selectType == 'row') {
            records.on('mouseover mouseout', { delegate: 'tr' }, (event) => {
                let recid = query(event.delegate).attr('recid')
                query(this.box).find(`#grid_${this.name}_frec_${w2utils.escapeId(recid)}`)
                    .toggleClass('w2ui-record-hover', event.type == 'mouseover')
            })
            frecords.on('mouseover mouseout', { delegate: 'tr' }, (event) => {
                let recid = query(event.delegate).attr('recid')
                query(this.box).find(`#grid_${this.name}_rec_${w2utils.escapeId(recid)}`)
                    .toggleClass('w2ui-record-hover', event.type == 'mouseover')
            })
        }
        if (w2utils.isIOS) {
            records.append(frecords)
                .on('click', { delegate: 'tr' }, (event) => {
                    let recid = query(event.delegate).attr('recid')
                    this.dblClick(recid, event)
                })
        } else {
            records.add(frecords)
                .on('click', { delegate: 'tr' }, (event) => {
                    let recid = query(event.delegate).attr('recid')
                    // do not generate click if empty record is clicked
                    if (recid != '-none-') {
                        this.click(recid, event)
                    }
                })
                .on('contextmenu', { delegate: 'tr' }, (event) => {
                    let recid = query(event.delegate).attr('recid')
                    let td = query(event.target).closest('td')
                    let column = parseInt(td.attr('col') ?? -1)
                    this.showContextMenu(recid, column, event)
                })
                .on('mouseover', { delegate: 'tr' }, (event) => {
                    this.last.rec_out = false
                    let index = query(event.delegate).attr('index')
                    let recid = query(event.delegate).attr('recid')
                    if (index !== this.last.rec_over) {
                        this.last.rec_over = index
                        // setTimeout is needed for correct event order enter/leave
                        setTimeout(() => {
                            delete this.last.rec_out
                            let edata = this.trigger('mouseEnter', { target: this.name, originalEvent: event, index, recid })
                            edata.finish()
                        })
                    }
                })
                .on('mouseout', { delegate: 'tr' }, (event) => {
                    let index = query(event.delegate).attr('index')
                    let recid = query(event.delegate).attr('recid')
                    this.last.rec_out = true
                    // setTimeouts are needed for correct event order enter/leave
                    setTimeout(() => {
                        let recLeave = () => {
                            let edata = this.trigger('mouseLeave', { target: this.name, originalEvent: event, index, recid })
                            edata.finish()
                        }
                        if (index !== this.last.rec_over) {
                            recLeave()
                        }
                        setTimeout(() => {
                            if (this.last.rec_out) {
                                delete this.last.rec_out
                                delete this.last.rec_over
                                recLeave()
                            }
                        })
                    })
                })
        }
        // enable scrolling on frozen records,
        gridBody
            .data('scroll', { lastDelta: 0, lastTime: 0 })
            .find('.w2ui-grid-frecords')
            .on('mousewheel DOMMouseScroll ', (event) => {
                event.preventDefault()
                // TODO: improve, scroll is not smooth, if scrolled to the end, it takes a while to return
                let scroll = gridBody.data('scroll')
                let container = gridBody.find('.w2ui-grid-records')
                let amount = typeof event.wheelDelta != null ? -event.wheelDelta : (event.detail || event.deltaY)
                let newScrollTop = container.prop('scrollTop')
                scroll.lastDelta += amount
                amount = Math.round(scroll.lastDelta)
                gridBody.data('scroll', scroll)
                // make scroll amount dependent on visible rows
                // amount *= (Math.round(records.prop('clientHeight') / self.recordHeight) - 1) * self.recordHeight / 4
                container.get(0).scroll({ top: newScrollTop + amount, behavior: 'smooth' })
            })
        // scroll on records (and frozen records)
        records.off('.body-global')
            .on('scroll.body-global', { delegate: '.w2ui-grid-records' }, event => {
                this.scroll(event)
            })
        query(this.box).find('.w2ui-grid-body') // gridBody
            .off('.body-global')
            // header column click
            .on('click.body-global dblclick.body-global contextmenu.body-global', { delegate: 'td.w2ui-head' }, event => {
                let col_ind = query(event.delegate).attr('col')
                let col = this.columns[col_ind] ?? { field: col_ind } // it could be line number
                switch (event.type) {
                    case 'click':
                        this.columnClick(col.field, event)
                        break
                    case 'dblclick':
                        this.columnDblClick(col.field, event)
                        break
                    case 'contextmenu':
                        this.columnContextMenu(col.field, event)
                        break
                }
            })
            .on('mouseover.body-global', { delegate: '.w2ui-col-header' }, event => {
                let col = query(event.delegate).parent().attr('col')
                this.columnTooltipShow(col, event)
                query(event.delegate)
                    .off('.tooltip')
                    .on('mouseleave.tooltip', () => {
                        this.columnTooltipHide(col, event)
                    })
            })
            // select all
            .on('click.body-global', { delegate: 'input.w2ui-select-all' }, event => {
                if (event.delegate.checked) { this.selectAll() } else { this.selectNone() }
                event.stopPropagation()
                clearTimeout(this.last.kbd_timer) // keep grid in focus
            })
            // tree-like grid (or expandable column) expand/collapse
            .on('click.body-global', { delegate: '.w2ui-show-children, .w2ui-col-expand' }, event => {
                event.stopPropagation()
                this.toggle(query(event.target).parents('tr').attr('recid'))
            })
            // info bubbles
            .on('click.body-global mouseover.body-global', { delegate: '.w2ui-info' }, event => {
                let td = query(event.delegate).closest('td')
                let tr = td.parent()
                let col = this.columns[td.attr('col')]
                let isSummary = tr.parents('.w2ui-grid-body').hasClass('w2ui-grid-summary')
                if (['mouseenter', 'mouseover'].includes(col.info?.showOn?.toLowerCase()) && event.type == 'mouseover') {
                    this.showBubble(tr.attr('index'), td.attr('col'), isSummary)
                        .then(() => {
                            query(event.delegate)
                                .off('.tooltip')
                                .on('mouseleave.tooltip', () => { w2tooltip.hide(this.name + '-bubble') })
                        })
                } else if (event.type == 'click') {
                    w2tooltip.hide(this.name + '-bubble')
                    this.showBubble(tr.attr('index'), td.attr('col'), isSummary)
                }
            })
            // clipborad copy icon
            .on('mouseover.body-global', { delegate: '.w2ui-clipboard-copy' }, event => {
                if (event.delegate._tooltipShow) return
                let td = query(event.delegate).parent()
                let tr = td.parent()
                let col = this.columns[td.attr('col')]
                let isSummary = tr.parents('.w2ui-grid-body').hasClass('w2ui-grid-summary')
                w2tooltip.show({
                    name: this.name + '-bubble',
                    anchor: event.delegate,
                    html: w2utils.lang(typeof col.clipboardCopy == 'string' ? col.clipboardCopy : 'Copy to clipboard'),
                    position: 'top|bottom',
                    offsetY: -2
                })
                .hide(evt => {
                    event.delegate._tooltipShow = false
                    query(event.delegate).off('.tooltip')
                })
                query(event.delegate)
                    .off('.tooltip')
                    .on('mouseleave.tooltip', evt => {
                        w2tooltip.hide(this.name + '-bubble')
                    })
                    .on('click.tooltip', evt => {
                        evt.stopPropagation()
                        w2tooltip.update(this.name + '-bubble', w2utils.lang('Copied'))
                        this.clipboardCopy(tr.attr('index'), td.attr('col'), isSummary)
                    })
                event.delegate._tooltipShow = true
            })
            .on('click.body-global', { delegate: '.w2ui-editable-checkbox' }, event => {
                let dt = query(event.delegate).data()
                this.editChange.call(this, event.delegate, dt.changeind, dt.colind, event)
                this.updateToolbar()
            })
        // show empty message
        if (this.records.length === 0 && this.msgEmpty) {
            query(this.box).find(`#grid_${this.name}_body`)
                .append(`<div id="grid_${this.name}_empty_msg" class="w2ui-grid-empty-msg"><div>${w2utils.lang(this.msgEmpty)}</div></div>`)
        } else if (query(this.box).find(`#grid_${this.name}_empty_msg`).length > 0) {
            query(this.box).find(`#grid_${this.name}_empty_msg`).remove()
        }
        // show summary records
        if (this.summary.length > 0) {
            let sumHTML = this.getSummaryHTML()
            query(this.box).find(`#grid_${this.name}_fsummary`).html(sumHTML[0]).show()
            query(this.box).find(`#grid_${this.name}_summary`).html(sumHTML[1]).show()
        } else {
            query(this.box).find(`#grid_${this.name}_fsummary`).hide()
            query(this.box).find(`#grid_${this.name}_summary`).hide()
        }
    }
    render(box) {
        let time = Date.now()
        let obj  = this
        if (typeof box == 'string') box = query(box).get(0)
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // default action
        if (box != null) {
            // clean previous box
            if (query(this.box).find(`#grid_${this.name}_body`).length > 0) {
                query(this.box)
                    .removeAttr('name')
                    .removeClass('w2ui-reset w2ui-grid w2ui-inactive')
                    .html('')
            }
            this.box = box
        }
        if (!this.box) return
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        // reset needed if grid existed
        this.reset(true)
        // --- default search field
        if (!this.last.field) {
            if (!this.multiSearch || !this.show.searchAll) {
                let tmp = 0
                while (tmp < this.searches.length && (this.searches[tmp].hidden || this.searches[tmp].simple === false)) tmp++
                if (tmp >= this.searches.length) {
                    // all searches are hidden
                    this.last.field = ''
                    this.last.label = ''
                } else {
                    this.last.field = this.searches[tmp].field
                    this.last.label = this.searches[tmp].label
                }
            } else {
                this.last.field = 'all'
                this.last.label = 'All Fields'
            }
        }
        // insert elements
        query(this.box)
            .attr('name', this.name)
            .addClass('w2ui-reset w2ui-grid w2ui-inactive')
            .html('<div class="w2ui-grid-box">'+
                  '    <div id="grid_'+ this.name +'_header" class="w2ui-grid-header"></div>'+
                  '    <div id="grid_'+ this.name +'_toolbar" class="w2ui-grid-toolbar"></div>'+
                  '    <div id="grid_'+ this.name +'_body" class="w2ui-grid-body"></div>'+
                  '    <div id="grid_'+ this.name +'_fsummary" class="w2ui-grid-body w2ui-grid-summary"></div>'+
                  '    <div id="grid_'+ this.name +'_summary" class="w2ui-grid-body w2ui-grid-summary"></div>'+
                  '    <div id="grid_'+ this.name +'_footer" class="w2ui-grid-footer"></div>'+
                  '    <textarea id="grid_'+ this.name +'_focus" class="w2ui-grid-focus-input" '+
                            (this.tabIndex ? 'tabindex="' + this.tabIndex + '"' : '')+
                            (w2utils.isIOS ? 'readonly' : '') +'></textarea>'+ // readonly needed on android not to open keyboard
                  '</div>')
        if (this.selectType != 'row') query(this.box).addClass('w2ui-ss')
        if (query(this.box).length > 0) query(this.box)[0].style.cssText += this.style
        // init toolbar
        this.initToolbar()
        if (this.toolbar != null) this.toolbar.render(query(this.box).find('#grid_'+ this.name +'_toolbar')[0])
        this.last.toolbar_height = query(this.box).find(`#grid_${this.name}_toolbar`).prop('offsetHeight')
        // re-init search_all
        if (this.last.field && this.last.field != 'all') {
            let sd = this.searchData
            setTimeout(() => { this.searchInitInput(this.last.field, (sd.length == 1 ? sd[0].value : null)) }, 1)
        }
        // init footer
        query(this.box).find(`#grid_${this.name}_footer`).html(this.getFooterHTML())
        // refresh
        if (!this.last.state) this.last.state = this.stateSave(true) // initial default state
        this.stateRestore()
        if (url) { this.clear(); this.refresh() } // show empty grid (need it) - should it be only for remote data source
        // if hidden searches - apply it
        let hasHiddenSearches = false
        for (let i = 0; i < this.searches.length; i++) {
            if (this.searches[i].hidden) { hasHiddenSearches = true; break }
        }
        if (hasHiddenSearches) {
            this.searchReset(false) // will call reload
            if (!url) setTimeout(() => { this.searchReset() }, 1)
        } else {
            this.reload()
        }
        // focus
        query(this.box).find(`#grid_${this.name}_focus`)
            .on('focus', (event) => {
                clearTimeout(this.last.kbd_timer)
                if (!this.hasFocus) this.focus()
            })
            .on('blur', (event) => {
                clearTimeout(this.last.kbd_timer)
                this.last.kbd_timer = setTimeout(() => {
                    if (this.hasFocus) { this.blur() }
                }, 100) // need this timer to be 100 ms
            })
            .on('paste', (event) => {
                let cd = (event.clipboardData ? event.clipboardData : null)
                if (cd) {
                    let items = cd.items
                    if (items.length == 2) {
                        if (items.length == 2 && items[1].kind == 'file') {
                            items = [items[1]]
                        }
                        if (items.length == 2 && items[0].type == 'text/plain' && items[1].type == 'text/html') {
                            items = [items[1]]
                        }
                    }
                    let items2send = []
                    // might contain data in different formats, but it is a single paste
                    for (let index in items) {
                        let item = items[index]
                        if (item.kind === 'file') {
                            let file = item.getAsFile()
                            items2send.push({ kind: 'file', data: file })
                        } else if (item.kind === 'string' && (item.type === 'text/plain' || item.type === 'text/html')) {
                            event.preventDefault()
                            let text = cd.getData('text/plain')
                            if (text.indexOf('\r') != -1 && text.indexOf('\n') == -1) {
                                text = text.replace(/\r/g, '\n')
                            }
                            items2send.push({ kind: (item.type == 'text/html' ? 'html' : 'text'), data: text })
                        }
                    }
                    if (items2send.length === 1 && items2send[0].kind != 'file') {
                        items2send = items2send[0].data
                    }
                    w2ui[this.name].paste(items2send, event)
                    event.preventDefault()
                }
            })
            .on('keydown', function (event) {
                w2ui[obj.name].keydown.call(w2ui[obj.name], event)
            })
        // init mouse events for mouse selection
        let edataCol // event for column select
        query(this.box).off('mousedown.mouseStart').on('mousedown.mouseStart', mouseStart)
        this.updateToolbar()
        // event after
        edata.finish()
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        return Date.now() - time
        function mouseStart (event) {
            if (event.which != 1) return // if not left mouse button
            // restore css user-select
            if (obj.last.userSelect == 'text') {
                obj.last.userSelect = ''
                query(obj.box).find('.w2ui-grid-body').css('user-select', 'none')
            }
            // regular record select
            if (obj.selectType == 'row' && (query(event.target).parents().hasClass('w2ui-head') || query(event.target).hasClass('w2ui-head'))) return
            if (obj.last.move && obj.last.move.type == 'expand') return
            // if altKey - alow text selection
            if (event.altKey) {
                query(obj.box).find('.w2ui-grid-body').css('user-select', 'text')
                obj.selectNone()
                obj.last.move       = { type: 'text-select' }
                obj.last.userSelect = 'text'
            } else {
                let tmp  = event.target
                let pos  = {
                    x: event.offsetX - 10,
                    y: event.offsetY - 10
                }
                let tmps = false
                while (tmp) {
                    if (tmp.classList && tmp.classList.contains('w2ui-grid')) break
                    if (tmp.tagName && tmp.tagName.toUpperCase() == 'TD') tmps = true
                    if (tmp.tagName && tmp.tagName.toUpperCase() != 'TR' && tmps == true) {
                        pos.x += tmp.offsetLeft
                        pos.y += tmp.offsetTop
                    }
                    tmp = tmp.parentNode
                }
                obj.last.move = {
                    x      : event.screenX,
                    y      : event.screenY,
                    divX   : 0,
                    divY   : 0,
                    focusX : pos.x,
                    focusY : pos.y,
                    recid  : query(event.target).parents('tr').attr('recid'),
                    column : parseInt(event.target.tagName.toUpperCase() == 'TD' ? query(event.target).attr('col') : query(event.target).parents('td').attr('col')),
                    type   : 'select',
                    ghost  : false,
                    start  : true
                }
                if (obj.last.move.recid == null) obj.last.move.type = 'select-column'
                // set focus to grid
                let target = event.target
                let $input = query(obj.box).find('#grid_'+ obj.name + '_focus')
                // move input next to cursor so screen does not jump
                if (obj.last.move) {
                    let sLeft  = obj.last.move.focusX
                    let sTop   = obj.last.move.focusY
                    let $owner = query(target).parents('table').parent()
                    if ($owner.hasClass('w2ui-grid-records') || $owner.hasClass('w2ui-grid-frecords')
                            || $owner.hasClass('w2ui-grid-columns') || $owner.hasClass('w2ui-grid-fcolumns')
                            || $owner.hasClass('w2ui-grid-summary')) {
                        sLeft = obj.last.move.focusX - query(obj.box).find('#grid_'+ obj.name +'_records').prop('scrollLeft')
                        sTop  = obj.last.move.focusY - query(obj.box).find('#grid_'+ obj.name +'_records').prop('scrollTop')
                    }
                    if (query(target).hasClass('w2ui-grid-footer') || query(target).parents('div.w2ui-grid-footer').length > 0) {
                        sTop = query(obj.box).find('#grid_'+ obj.name +'_footer').get(0).offsetTop
                    }
                    // if clicked on toolbar
                    if ($owner.hasClass('w2ui-scroll-wrapper') && $owner.parent().hasClass('w2ui-toolbar')) {
                        sLeft = obj.last.move.focusX - $owner.prop('scrollLeft')
                    }
                    $input.css({
                        left: sLeft - 10,
                        top : sTop
                    })
                }
                // if toolbar input is clicked
                setTimeout(() => {
                    if (!obj.last.inEditMode) {
                        if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
                            target.focus()
                        } else {
                            if ($input.get(0) !== document.active) $input.get(0)?.focus({ preventScroll: true })
                        }
                    }
                }, 50)
                // disable click select for this condition
                if (!obj.multiSelect && !obj.reorderRows && obj.last.move.type == 'drag') {
                    delete obj.last.move
                }
            }
            if (obj.reorderRows == true) {
                let el = event.target
                if (el.tagName.toUpperCase() != 'TD') el = query(el).parents('td')[0]
                if (query(el).hasClass('w2ui-col-number') || query(el).hasClass('w2ui-col-order')) {
                    obj.selectNone()
                    obj.last.move.reorder = true
                    // suppress hover
                    let eColor = query(obj.box).find('.w2ui-even.w2ui-empty-record').css('background-color')
                    let oColor = query(obj.box).find('.w2ui-odd.w2ui-empty-record').css('background-color')
                    query(obj.box).find('.w2ui-even td').filter(':not(.w2ui-col-number)').css('background-color', eColor)
                    query(obj.box).find('.w2ui-odd td').filter(':not(.w2ui-col-number)').css('background-color', oColor)
                    // display empty record and ghost record
                    let mv = obj.last.move
                    let recs = query(obj.box).find('.w2ui-grid-records')
                    if (!mv.ghost) {
                        let row    = query(obj.box).find(`#grid_${obj.name}_rec_${mv.recid}`)
                        let tmp    = row.parents('table').find('tr:first-child').get(0).cloneNode(true)
                        mv.offsetY = event.offsetY
                        mv.from    = mv.recid
                        mv.pos     = { top: row.get(0).offsetTop-1, left: row.get(0).offsetLeft }
                        mv.ghost   = query(row.get(0).cloneNode(true))
                        mv.ghost.removeAttr('id')
                        mv.ghost.find('td').css({
                            'border-top': '1px solid silver',
                            'border-bottom': '1px solid silver'
                        })
                        row.find('td').remove()
                        row.append(`<td colspan="1000"><div class="w2ui-reorder-empty" style="height: ${(obj.recordHeight - 2)}px"></div></td>`)
                        recs.append('<div id="grid_'+ obj.name + '_ghost_line" style="position: absolute; z-index: 999999; pointer-events: none; width: 100%;"></div>')
                        recs.append('<table id="grid_'+ obj.name + '_ghost" style="position: absolute; z-index: 999998; opacity: 0.9; pointer-events: none;"></table>')
                        query(obj.box).find('#grid_'+ obj.name + '_ghost').append(tmp).append(mv.ghost)
                    }
                    let ghost = query(obj.box).find('#grid_'+ obj.name + '_ghost')
                    ghost.css({
                        top  : mv.pos.top + 'px',
                        left : mv.pos.left + 'px'
                    })
                } else {
                    obj.last.move.reorder = false
                }
            }
            query(document)
                .on('mousemove.w2ui-' + obj.name, mouseMove)
                .on('mouseup.w2ui-' + obj.name, mouseStop)
            // needed when grid grids are nested, see issue #1275
            event.stopPropagation()
        }
        function mouseMove(event) {
            if (!event.target.tagName) {
                // element has no tagName - most likely the target is the #document itself
                // this can happen is you click+drag and move the mouse out of the DOM area,
                // e.g. into the browser's toolbar area
                return
            }
            let mv = obj.last.move
            if (!mv || ['select', 'select-column'].indexOf(mv.type) == -1) return
            mv.divX = (event.screenX - mv.x)
            mv.divY = (event.screenY - mv.y)
            if (Math.abs(mv.divX) <= 1 && Math.abs(mv.divY) <= 1) return // only if moved more then 1px
            obj.last.cancelClick = true
            if (obj.reorderRows == true && obj.last.move.reorder) {
                let tmp   = query(event.target).parents('tr')
                let recid = tmp.attr('recid')
                if (recid == '-none-') recid = 'bottom'
                if (recid != mv.from) {
                    // let row1 = query(obj.box).find('#grid_'+ obj.name + '_rec_'+ mv.recid)
                    let row2 = query(obj.box).find('#grid_'+ obj.name + '_rec_'+ recid)
                    query(obj.box).find('.insert-before')
                    row2.addClass('insert-before')
                    // MOVABLE GHOST
                    // if (event.screenY - mv.lastY < 0) row1.after(row2); else row2.after(row1);
                    mv.lastY = event.screenY
                    mv.to = recid
                    // line to insert before
                    let pos = { top: row2.get(0)?.offsetTop, left: row2.get(0)?.offsetLeft }
                    let ghost_line = query(obj.box).find('#grid_'+ obj.name + '_ghost_line')
                    if (pos) {
                        ghost_line.css({
                            top  : pos.top + 'px',
                            left : mv.pos.left + 'px',
                            'border-top': '2px solid #769EFC'
                        })
                    } else {
                        ghost_line.css({
                            'border-top': '2px solid transparent'
                        })
                    }
                }
                let ghost = query(obj.box).find('#grid_'+ obj.name + '_ghost')
                ghost.css({
                    top  : (mv.pos.top + mv.divY) + 'px',
                    left : mv.pos.left + 'px'
                })
                return
            }
            if (mv.start && mv.recid) {
                obj.selectNone()
                mv.start = false
            }
            let newSel = []
            let recid  = (event.target.tagName.toUpperCase() == 'TR' ? query(event.target).attr('recid') : query(event.target).parents('tr').attr('recid'))
            if (recid == null) {
                // select by dragging columns
                if (obj.selectType == 'row') return
                if (obj.last.move && obj.last.move.type == 'select') return
                let col = parseInt(query(event.target).parents('td').attr('col'))
                if (isNaN(col)) {
                    obj.removeRange('column-selection')
                    query(obj.box).find('.w2ui-grid-columns .w2ui-col-header, .w2ui-grid-fcolumns .w2ui-col-header').removeClass('w2ui-col-selected')
                    query(obj.box).find('.w2ui-col-number').removeClass('w2ui-row-selected')
                    delete mv.colRange
                } else {
                    // add all columns in between
                    let newRange = col + '-' + col
                    if (mv.column < col) newRange = mv.column + '-' + col
                    if (mv.column > col) newRange = col + '-' + mv.column
                    // array of selected columns
                    let cols = []
                    let tmp  = newRange.split('-')
                    for (let ii = parseInt(tmp[0]); ii <= parseInt(tmp[1]); ii++) {
                        cols.push(ii)
                    }
                    if (mv.colRange != newRange) {
                        edataCol = obj.trigger('columnSelect', { target: obj.name, columns: cols })
                        if (edataCol.isCancelled !== true) {
                            if (mv.colRange == null) obj.selectNone()
                            // highlight columns
                            let tmp = newRange.split('-')
                            query(obj.box).find('.w2ui-grid-columns .w2ui-col-header, .w2ui-grid-fcolumns .w2ui-col-header').removeClass('w2ui-col-selected')
                            for (let j = parseInt(tmp[0]); j <= parseInt(tmp[1]); j++) {
                                query(obj.box).find('#grid_'+ obj.name +'_column_' + j + ' .w2ui-col-header').addClass('w2ui-col-selected')
                            }
                            query(obj.box).find('.w2ui-col-number').not('.w2ui-head').addClass('w2ui-row-selected')
                            // show new range
                            mv.colRange = newRange
                            obj.removeRange('column-selection')
                            obj.addRange({
                                name  : 'column-selection',
                                range : [{ recid: obj.records[0].recid, column: tmp[0] }, { recid: obj.records[obj.records.length-1].recid, column: tmp[1] }],
                                style : 'background-color: rgba(90, 145, 234, 0.1)'
                            })
                        }
                    }
                }
            } else { // regular selection
                let ind1 = obj.get(mv.recid, true)
                // this happens when selection is started on summary row
                if (ind1 == null || (obj.records[ind1] && obj.records[ind1].recid != mv.recid)) return
                let ind2 = obj.get(recid, true)
                // this happens when selection is extended into summary row (a good place to implement scrolling)
                if (ind2 == null) return
                let col1 = parseInt(mv.column)
                let col2 = parseInt(event.target.tagName.toUpperCase() == 'TD' ? query(event.target).attr('col') : query(event.target).parents('td').attr('col'))
                if (isNaN(col1) && isNaN(col2)) { // line number select entire record
                    col1 = 0
                    col2 = obj.columns.length-1
                }
                if (ind1 > ind2) { let tmp = ind1; ind1 = ind2; ind2 = tmp }
                // check if need to refresh
                let tmp = 'ind1:'+ ind1 +',ind2;'+ ind2 +',col1:'+ col1 +',col2:'+ col2
                if (mv.range == tmp) return
                mv.range = tmp
                for (let i = ind1; i <= ind2; i++) {
                    if (obj.last.searchIds.length > 0 && obj.last.searchIds.indexOf(i) == -1) continue
                    if (obj.selectType != 'row') {
                        if (col1 > col2) { let tmp = col1; col1 = col2; col2 = tmp }
                        for (let c = col1; c <= col2; c++) {
                            if (obj.columns[c].hidden) continue
                            newSel.push({ recid: obj.records[i].recid, column: parseInt(c) })
                        }
                    } else {
                        newSel.push(obj.records[i].recid)
                    }
                }
                if (obj.selectType != 'row') {
                    let sel = obj.getSelection()
                    // add more items
                    let tmp = []
                    for (let ns = 0; ns < newSel.length; ns++) {
                        let flag = false
                        for (let s = 0; s < sel.length; s++) if (newSel[ns].recid == sel[s].recid && newSel[ns].column == sel[s].column) flag = true
                        if (!flag) tmp.push({ recid: newSel[ns].recid, column: newSel[ns].column })
                    }
                    obj.select(tmp)
                    // remove items
                    tmp = []
                    for (let s = 0; s < sel.length; s++) {
                        let flag = false
                        for (let ns = 0; ns < newSel.length; ns++) if (newSel[ns].recid == sel[s].recid && newSel[ns].column == sel[s].column) flag = true
                        if (!flag) tmp.push({ recid: sel[s].recid, column: sel[s].column })
                    }
                    obj.unselect(tmp)
                } else {
                    if (obj.multiSelect) {
                        let sel = obj.getSelection()
                        for (let ns = 0; ns < newSel.length; ns++) {
                            if (sel.indexOf(newSel[ns]) == -1) obj.select(newSel[ns]) // add more items
                        }
                        for (let s = 0; s < sel.length; s++) {
                            if (newSel.indexOf(sel[s]) == -1) obj.unselect(sel[s]) // remove items
                        }
                    }
                }
            }
        }
        function mouseStop (event) {
            let mv = obj.last.move
            setTimeout(() => { delete obj.last.cancelClick }, 1)
            if (query(event.target).parents().hasClass('.w2ui-head') || query(event.target).hasClass('.w2ui-head')) return
            if (mv && ['select', 'select-column'].indexOf(mv.type) != -1) {
                if (mv.colRange != null && edataCol.isCancelled !== true) {
                    let tmp = mv.colRange.split('-')
                    let sel = []
                    for (let i = 0; i < obj.records.length; i++) {
                        let cols = []
                        for (let j = parseInt(tmp[0]); j <= parseInt(tmp[1]); j++) cols.push(j)
                        sel.push({ recid: obj.records[i].recid, column: cols })
                    }
                    obj.removeRange('column-selection')
                    edataCol.finish()
                    obj.select(sel)
                }
                if (obj.reorderRows == true && obj.last.move.reorder) {
                    if (mv.to != null) {
                        // event
                        let edata = obj.trigger('reorderRow', { target: obj.name, recid: mv.from, moveBefore: mv.to })
                        if (edata.isCancelled === true) {
                            resetRowReorder()
                            delete obj.last.move
                            return
                        }
                        // default behavior
                        let ind1 = obj.get(mv.from, true)
                        let ind2 = obj.get(mv.to, true)
                        if (mv.to == 'bottom') ind2 = obj.records.length // end of list
                        let tmp = obj.records[ind1]
                        // swap records
                        if (ind1 != null && ind2 != null) {
                            obj.records.splice(ind1, 1)
                            if (ind1 > ind2) {
                                obj.records.splice(ind2, 0, tmp)
                            } else {
                                obj.records.splice(ind2 - 1, 0, tmp)
                            }
                        }
                        // clear sortData
                        obj.sortData = []
                        query(obj.box)
                            .find(`#grid_${obj.name}_columns .w2ui-col-header`)
                            .removeClass('w2ui-col-sorted')
                        resetRowReorder()
                        // event after
                        edata.finish()
                    } else {
                        resetRowReorder()
                    }
                }
            }
            delete obj.last.move
            query(document).off('.w2ui-' + obj.name)
        }
        function resetRowReorder() {
            query(obj.box).find(`#grid_${obj.name}_ghost`).remove()
            query(obj.box).find(`#grid_${obj.name}_ghost_line`).remove()
            obj.refresh()
            delete obj.last.move
        }
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        // remove all events
        query(this.box).off()
        // clean up
        if (typeof this.toolbar == 'object' && this.toolbar.destroy) this.toolbar.destroy()
        if (query(this.box).find(`#grid_${this.name}_body`).length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-reset w2ui-grid w2ui-inactive')
                .html('')
        }
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
    }
    // ===========================================
    // --- Internal Functions
    initColumnOnOff() {
        let items = [
            { id: 'line-numbers', text: 'Line #', checked: this.show.lineNumbers }
        ]
        // columns
        for (let c = 0; c < this.columns.length; c++) {
            let col = this.columns[c]
            let text = this.columns[c].text
            if (col.hideable === false) continue
            if (!text && this.columns[c].tooltip) text = this.columns[c].tooltip
            if (!text) text = '- column '+ (parseInt(c) + 1) +' -'
            items.push({ id: col.field, text: w2utils.stripTags(text), checked: !col.hidden })
        }
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if ((url && this.show.skipRecords) || this.show.saveRestoreState) {
            items.push({ text: '--' })
        }
        // skip records
        if (this.show.skipRecords) {
            let skip = w2utils.lang('Skip') +
                `<input id="${this.name}_skip" type="text" class="w2ui-input w2ui-grid-skip" value="${this.offset}">` +
                w2utils.lang('records')
            items.push({ id: 'w2ui-skip', text: skip, group: false, icon: 'w2ui-icon-empty' })
        }
        // save/restore state
        if (this.show.saveRestoreState) {
            items.push(
                { id: 'w2ui-stateSave', text: w2utils.lang('Save Grid State'), icon: 'w2ui-icon-empty', group: false },
                { id: 'w2ui-stateReset', text: w2utils.lang('Restore Default State'), icon: 'w2ui-icon-empty', group: false }
            )
        }
        let selected = []
        items.forEach(item => {
            item.text = w2utils.lang(item.text) // translate
            if (item.checked) selected.push(item.id)
        })
        this.toolbar.set('w2ui-column-on-off', { selected, items })
        return items
    }
    initColumnDrag(box) {
        // throw error if using column groups
        if (this.columnGroups && this.columnGroups.length) {
            throw 'Draggable columns are not currently supported with column groups.'
        }
        let self = this
        let dragData = {
            pressed: false,
            targetPos: null,
            columnHead: null
        }
        let hasInvalidClass = (target, lastColumn) => {
            let iClass = ['w2ui-col-number', 'w2ui-col-expand', 'w2ui-col-select']
            if (lastColumn !== true) iClass.push('w2ui-head-last')
            for (let i = 0; i < iClass.length; i++) {
                if (query(target).closest('.w2ui-head').hasClass(iClass[i])) {
                    return true
                }
            }
            return false
        }
        // attach original event listener
        query(self.box)
            .off('.colDrag')
            .on('mousedown.colDrag', dragColStart)
        function dragColStart(event) {
            if (dragData.pressed || dragData.numberPreColumnsPresent === 0 || event.button !== 0) return
            let edata, columns, origColumn, origColumnNumber
            let preColHeadersSelector = '.w2ui-head.w2ui-col-number, .w2ui-head.w2ui-col-expand, .w2ui-head.w2ui-col-select'
            // do nothing if it is not a header
            if (!query(event.target).parents().hasClass('w2ui-head') || hasInvalidClass(event.target)) return
            dragData.pressed = true
            dragData.initialX = event.pageX
            dragData.initialY = event.pageY
            dragData.numberPreColumnsPresent = query(self.box).find(preColHeadersSelector).length
            // start event for drag start
            dragData.columnHead  = origColumn = query(event.target).closest('.w2ui-head')
            dragData.originalPos = origColumnNumber = parseInt(origColumn.attr('col'), 10)
            edata = self.trigger('columnDragStart', { originalEvent: event, origColumnNumber, target: origColumn[0] })
            if (edata.isCancelled === true) return false
            columns = dragData.columns = query(self.box).find('.w2ui-head:not(.w2ui-head-last)')
            // add events
            query(document).on('mouseup.colDrag', dragColEnd)
            query(document).on('mousemove.colDrag', dragColOver)
            let col = self.columns[dragData.originalPos]
            let colText = w2utils.lang(typeof col.text == 'function' ? col.text(col) : col.text)
            dragData.ghost = query.html(`<span col="${dragData.originalPos}">${colText}</span>`)[0]
            query(document.body).append(dragData.ghost)
            query(dragData.ghost)
                .css({
                    display: 'none',
                    left: event.pageX,
                    top: event.pageY,
                    opacity: 1,
                    margin: '3px 0 0 20px',
                    padding: '3px',
                    'background-color': 'white',
                    position: 'fixed',
                    'z-index': 999999,
                })
                .addClass('.w2ui-grid-ghost')

            // establish current offsets
            dragData.offsets = []
            for (let i = 0, l = columns.length; i < l; i++) {
                let rect = columns[i].getBoundingClientRect()
                dragData.offsets.push(rect.left)
            }
            // conclude event
            edata.finish()
        }
        function dragColOver(event) {
            if (!dragData.pressed || !dragData.columnHead) return
            let cursorX = event.pageX
            let cursorY = event.pageY
            if (!hasInvalidClass(event.target, true)) {
                markIntersection(event)
            }
            trackGhost(cursorX, cursorY)
        }
        function dragColEnd(event) {
            if (!dragData.pressed || !dragData.columnHead) return
            dragData.pressed = false
            let edata, target, selected, columnConfig
            let finish = () => {
                let ghosts = query(self.box).find('.w2ui-grid-ghost')
                query(self.box).find('.w2ui-intersection-marker').hide()
                query(dragData.ghost).remove()
                ghosts.remove()
                // dragData.columns.css({ overflow: '' }).children('div').css({ overflow: '' });
                query(document).off('.colDrag')
                dragData = {}
            }
            // if no move, then click event for sorting
            if (event.pageX == dragData.initialX && event.pageY == dragData.initialY) {
                self.columnClick(self.columns[dragData.originalPos].field, event)
                finish()
                return
            }
            // start event for drag start
            edata = self.trigger('columnDragEnd', { originalEvent: event, target: dragData.columnHead[0], dragData })
            if (edata.isCancelled === true) return false
            selected = self.columns[dragData.originalPos]
            columnConfig = self.columns
            if (dragData.originalPos != dragData.targetPos && dragData.targetPos != null) {
                columnConfig.splice(dragData.targetPos, 0, w2utils.clone(selected))
                columnConfig.splice(columnConfig.indexOf(selected), 1)
            }
            finish()
            self.refresh()
            edata.finish({ targetColumn: target - 1 })
        }
        function markIntersection(event) {
            // if mouse over is not over table
            if (query(event.target).closest('td').length == 0) {
                return
            }
            // if mouse over invalid column
            let rect1 = query(self.box).find('.w2ui-grid-body').get(0).getBoundingClientRect()
            let rect2 = query(event.target).closest('td').get(0).getBoundingClientRect()
            query(self.box).find('.w2ui-intersection-marker')
                .show()
                .css({
                    left: (rect2.left - rect1.left) + 'px'
                })
            let td = query(event.target).closest('td')
            dragData.targetPos = td.hasClass('w2ui-head-last') ? self.columns.length : parseInt(td.attr('col'))
            return
        }
        function trackGhost(cursorX, cursorY){
            query(dragData.ghost)
                .css({
                    left : (cursorX - 10) + 'px',
                    top  : (cursorY - 10) + 'px'
                })
                .show()
        }
        // return an object to remove drag if it has ever been enabled
        return {
            remove() {
                query(self.box).off('.colDrag')
                self.last.columnDrag = false
            }
        }
    }
    columnOnOff(event, field) {
        // event before
        let edata = this.trigger('columnOnOff', { target: this.name, field: field, originalEvent: event })
        if (edata.isCancelled === true) return
        // collapse expanded rows
        let rows = this.find({ 'w2ui.expanded': true }, true)
        for (let r = 0; r < rows.length; r++) {
            let tmp = this.records[r].w2ui
            if (tmp && !Array.isArray(tmp.children)) {
                this.records[r].w2ui.expanded = false
            }
        }
        // show/hide
        if (field == 'line-numbers') {
            this.show.lineNumbers = !this.show.lineNumbers
            this.refresh()
        } else {
            let col = this.getColumn(field)
            if (col.hidden) {
                this.showColumn(col.field)
            } else {
                this.hideColumn(col.field)
            }
        }
        // event after
        edata.finish()
    }
    initToolbar() {
        // if it is already initiazlied
        if (this.toolbar.render != null) {
            return
        }
        let tb_items = this.toolbar.items || []
        this.toolbar.items = []
        this.toolbar = new w2toolbar(w2utils.extend({}, this.toolbar, { name: this.name +'_toolbar', owner: this }))
        if (this.show.toolbarReload) {
            this.toolbar.items.push(w2utils.extend({}, this.buttons.reload))
        }
        if (this.show.toolbarColumns) {
            this.toolbar.items.push(w2utils.extend({}, this.buttons.columns))
        }
        if (this.show.toolbarSearch) {
            let html =`
                <div class="w2ui-grid-search-input">
                    ${this.buttons.search.html}
                    <div id="grid_${this.name}_search_name" class="w2ui-grid-search-name">
                        <span class="name-icon w2ui-icon-search"></span>
                        <span class="name-text"></span>
                        <span class="name-cross w2ui-action" data-click="searchReset">x</span>
                    </div>
                    <input type="text" id="grid_${this.name}_search_all" class="w2ui-search-all" tabindex="-1"
                        autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"
                        placeholder="${w2utils.lang(this.last.label, true)}" value="${this.last.search}"
                        data-focus="searchSuggest" data-click="stop"
                    >
                    <div class="w2ui-search-drop w2ui-action" data-click="searchOpen"
                            style="${this.multiSearch ? '' : 'display: none'}">
                        <span class="w2ui-icon-drop"></span>
                    </div>
                </div>`
            this.toolbar.items.push({
                id: 'w2ui-search',
                type: 'html',
                html,
                onRefresh: async (event) => {
                    await event.complete
                    let input = query(this.box).find(`#grid_${this.name}_search_all`)
                    w2utils.bindEvents(query(this.box).find(`#grid_${this.name}_search_all, .w2ui-action`), this)
                    // slow down live search calls
                    let slowSearch = w2utils.debounce((event) => {
                        let val = event.target.value
                        if (this.liveSearch && this.last.liveText != val) {
                            this.last.liveText = val
                            this.search(this.last.field, val)
                        }
                        if (event.keyCode == 40) { // arrow down
                            this.searchSuggest(true)
                        }
                    }, 250)
                    input
                        .on('change', event => {
                            if (!this.liveSearch) {
                                this.search(this.last.field, event.target.value)
                                this.searchSuggest(true, true, this)
                            }
                        })
                        .on('blur', () => { this.last.liveText = '' })
                        .on('keyup', slowSearch)
                }
            })
        }
        if (Array.isArray(tb_items)) {
            let ids = tb_items.map(item => item.id)
            if (this.show.toolbarAdd && !ids.includes(this.buttons.add.id)) {
                this.toolbar.items.push(w2utils.extend({}, this.buttons.add))
            }
            if (this.show.toolbarEdit && !ids.includes(this.buttons.edit.id)) {
                this.toolbar.items.push(w2utils.extend({}, this.buttons.edit))
            }
            if (this.show.toolbarDelete && !ids.includes(this.buttons.delete.id)) {
                this.toolbar.items.push(w2utils.extend({}, this.buttons.delete))
            }
            if (this.show.toolbarSave && !ids.includes(this.buttons.save.id)) {
                if (this.show.toolbarAdd || this.show.toolbarDelete || this.show.toolbarEdit) {
                    this.toolbar.items.push({ type: 'break', id: 'w2ui-break2' })
                }
                this.toolbar.items.push(w2utils.extend({}, this.buttons.save))
            }
            // fill in overwritten items with default buttons
            // ids are w2ui-* but in this.buttons the map is just [add, edit, delete]
            // must specify at least {id, name} in this.toolbar.items if you want to keep order
            tb_items = tb_items.map(item => this.buttons[item.name]
                                            ? w2utils.extend({}, this.buttons[item.name], item) : item)
        }
        // add original buttons
        this.toolbar.items.push(...tb_items)
        // =============================================
        // ------ Toolbar onClick processing
        this.toolbar.on('click', (event) => {
            let edata = this.trigger('toolbar', { target: event.target, originalEvent: event })
            if (edata.isCancelled === true) return
            let edata2
            switch (event.detail.item.id) {
                case 'w2ui-reload':
                    edata2 = this.trigger('reload', { target: this.name })
                    if (edata2.isCancelled === true) return false
                    this.reload()
                    edata2.finish()
                    break
                case 'w2ui-column-on-off':
                    // TODO: tap on columns will hide menu before opening, only in grid not in toolbar
                    if (event.detail.subItem) {
                        let id = event.detail.subItem.id
                        if (['w2ui-stateSave', 'w2ui-stateReset'].includes(id)) {
                            this[id.substring(5)]()
                        } else if (id == 'w2ui-skip') {
                            // empty
                        } else {
                            this.columnOnOff(event, event.detail.subItem.id)
                        }
                    } else {
                        this.initColumnOnOff()
                        // init input control with records to skip
                        setTimeout(() => {
                            query(`#w2overlay-${this.name}_toolbar-drop .w2ui-grid-skip`)
                                .off('.w2ui-grid')
                                .on('click.w2ui-grid', evt => {
                                    evt.stopPropagation()
                                })
                                .on('keypress', evt => {
                                    if (evt.keyCode == 13) {
                                        this.skip(evt.target.value)
                                        this.toolbar.click('w2ui-column-on-off') // close menu
                                    }
                                })
                        }, 100)
                    }
                    break
                case 'w2ui-add':
                    // events
                    edata2 = this.trigger('add', { target: this.name, recid: null })
                    if (edata2.isCancelled === true) return false
                    edata2.finish()
                    break
                case 'w2ui-edit': {
                    let sel   = this.getSelection()
                    let recid = null
                    if (sel.length == 1) recid = sel[0]
                    // events
                    edata2 = this.trigger('edit', { target: this.name, recid: recid })
                    if (edata2.isCancelled === true) return false
                    edata2.finish()
                    break
                }
                case 'w2ui-delete':
                    this.delete()
                    break
                case 'w2ui-save':
                    this.save()
                    break
            }
            // no default action
            edata.finish()
        })
        this.toolbar.on('refresh', (event) => {
            if (event.target == 'w2ui-search') {
                let sd = this.searchData
                setTimeout(() => {
                    this.searchInitInput(this.last.field, (sd.length == 1 ? sd[0].value : null))
                }, 1)
            }
        })
    }
    initResize() {
        let obj = this
        query(this.box).find('.w2ui-resizer')
            .off('.grid-col-resize')
            .on('click.grid-col-resize', function(event) {
                if (event.stopPropagation) event.stopPropagation(); else event.cancelBubble = true
                if (event.preventDefault) event.preventDefault()
            })
            .on('mousedown.grid-col-resize', function(event) {
                if (!event) event = window.event
                obj.last.colResizing = true
                obj.last.tmp         = {
                    x   : event.screenX,
                    y   : event.screenY,
                    gx  : event.screenX,
                    gy  : event.screenY,
                    col : parseInt(query(this).attr('name'))
                }
                // find tds that will be resized
                obj.last.tmp.tds = query(obj.box).find('#grid_'+ obj.name +'_body table tr:first-child td[col="'+ obj.last.tmp.col +'"]')
                if (event.stopPropagation) event.stopPropagation(); else event.cancelBubble = true
                if (event.preventDefault) event.preventDefault()
                // fix sizes
                for (let c = 0; c < obj.columns.length; c++) {
                    if (obj.columns[c].hidden) continue
                    if (obj.columns[c].sizeOriginal == null) obj.columns[c].sizeOriginal = obj.columns[c].size
                    obj.columns[c].size = obj.columns[c].sizeCalculated
                }
                let edata = { phase: 'before', type: 'columnResize', target: obj.name, column: obj.last.tmp.col, field: obj.columns[obj.last.tmp.col].field }
                edata = obj.trigger(w2utils.extend(edata, { resizeBy: 0, originalEvent: event }))
                // set move event
                let timer
                let mouseMove = function(event) {
                    if (obj.last.colResizing != true) return
                    if (!event) event = window.event
                    // event before
                    edata = obj.trigger(w2utils.extend(edata, { resizeBy: (event.screenX - obj.last.tmp.gx), originalEvent: event }))
                    if (edata.isCancelled === true) { edata.isCancelled = false; return }
                    // default action
                    obj.last.tmp.x                     = (event.screenX - obj.last.tmp.x)
                    obj.last.tmp.y                     = (event.screenY - obj.last.tmp.y)
                    let newWidth                       = (parseInt(obj.columns[obj.last.tmp.col].size) + obj.last.tmp.x) + 'px'
                    obj.columns[obj.last.tmp.col].size = newWidth
                    if (timer) clearTimeout(timer)
                    timer = setTimeout(() => {
                        obj.resizeRecords()
                        obj.scroll()
                    }, 100)
                    // quick resize
                    obj.last.tmp.tds.css({ width: newWidth })
                    // reset
                    obj.last.tmp.x = event.screenX
                    obj.last.tmp.y = event.screenY
                }
                let mouseUp = function(event) {
                    query(document).off('.grid-col-resize')
                    obj.resizeRecords()
                    obj.scroll()
                    // event after
                    edata.finish({ originalEvent: event })
                    // need timeout to finish processing events
                    setTimeout(() => { obj.last.colResizing = false }, 1)
                }
                query(document)
                    .off('.grid-col-resize')
                    .on('mousemove.grid-col-resize', mouseMove)
                    .on('mouseup.grid-col-resize', mouseUp)
            })
            .on('dblclick.grid-col-resize', function(event) {
                let colId = parseInt(query(this).attr('name')),
                    col = obj.columns[colId],
                    maxDiff = 0
                if (col.autoResize === false) {
                    return true
                }
                if (event.stopPropagation) event.stopPropagation(); else event.cancelBubble = true
                if (event.preventDefault) event.preventDefault()
                query(obj.box).find('.w2ui-grid-records td[col="' + colId + '"] > div', obj.box).each(() => {
                    let thisDiff = this.offsetWidth - this.scrollWidth
                    if (thisDiff < maxDiff) {
                        maxDiff = thisDiff - 3 // 3px buffer needed for Firefox
                    }
                })
                // event before
                let edata = { phase: 'before', type: 'columnAutoResize', target: obj.name, column: col, field: col.field }
                edata     = obj.trigger(w2utils.extend(edata, { resizeBy: Math.abs(maxDiff), originalEvent: event }))
                if (edata.isCancelled === true) { edata.isCancelled = false; return }
                if (maxDiff < 0) {
                    col.size = Math.min(parseInt(col.size) + Math.abs(maxDiff), col.max || Infinity) + 'px'
                    obj.resizeRecords()
                    obj.resizeRecords() // Why do we have to call it twice in order to show the scrollbar?
                    obj.scroll()
                }
                // event after
                edata.finish({ originalEvent: event })
            })
            .each(el => {
                let td = query(el).get(0).parentNode
                query(el).css({
                    'height'      : td.clientHeight + 'px',
                    'margin-left' : (td.clientWidth - 3) + 'px'
                })
            })
    }
    resizeBoxes() {
        // elements
        let header   = query(this.box).find(`#grid_${this.name}_header`)
        let toolbar  = query(this.box).find(`#grid_${this.name}_toolbar`)
        let fsummary = query(this.box).find(`#grid_${this.name}_fsummary`)
        let summary  = query(this.box).find(`#grid_${this.name}_summary`)
        let footer   = query(this.box).find(`#grid_${this.name}_footer`)
        let body     = query(this.box).find(`#grid_${this.name}_body`)
        if (this.show.header) {
            header.css({
                top:   '0px',
                left:  '0px',
                right: '0px'
            })
        }
        if (this.show.toolbar) {
            toolbar.css({
                top:   (0 + (this.show.header ? w2utils.getSize(header, 'height') : 0)) + 'px',
                left:  '0px',
                right: '0px'
            })
        }
        if (this.summary.length > 0) {
            fsummary.css({
                bottom: (0 + (this.show.footer ? w2utils.getSize(footer, 'height') : 0)) + 'px'
            })
            summary.css({
                bottom: (0 + (this.show.footer ? w2utils.getSize(footer, 'height') : 0)) + 'px',
                right: '0px'
            })
        }
        if (this.show.footer) {
            footer.css({
                bottom: '0px',
                left:  '0px',
                right: '0px'
            })
        }
        body.css({
            top: (0 + (this.show.header ? w2utils.getSize(header, 'height') : 0) + (this.show.toolbar ? w2utils.getSize(toolbar, 'height') : 0)) + 'px',
            bottom: (0 + (this.show.footer ? w2utils.getSize(footer, 'height') : 0) + (this.summary.length > 0 ? w2utils.getSize(summary, 'height') : 0)) + 'px',
            left:   '0px',
            right:  '0px'
        })
    }
    resizeRecords() {
        let obj = this
        // remove empty records
        query(this.box).find('.w2ui-empty-record').remove()
        // -- Calculate Column size in PX
        let box             = query(this.box)
        let grid            = query(this.box).find(':scope > div.w2ui-grid-box')
        let header          = query(this.box).find(`#grid_${this.name}_header`)
        let toolbar         = query(this.box).find(`#grid_${this.name}_toolbar`)
        let summary         = query(this.box).find(`#grid_${this.name}_summary`)
        let fsummary        = query(this.box).find(`#grid_${this.name}_fsummary`)
        let footer          = query(this.box).find(`#grid_${this.name}_footer`)
        let body            = query(this.box).find(`#grid_${this.name}_body`)
        let columns         = query(this.box).find(`#grid_${this.name}_columns`)
        let fcolumns        = query(this.box).find(`#grid_${this.name}_fcolumns`)
        let records         = query(this.box).find(`#grid_${this.name}_records`)
        let frecords        = query(this.box).find(`#grid_${this.name}_frecords`)
        let scroll1         = query(this.box).find(`#grid_${this.name}_scroll1`)
        let lineNumberWidth = String(this.total).length * 8 + 10
        if (lineNumberWidth < 34) lineNumberWidth = 34 // 3 digit width
        if (this.lineNumberWidth != null) lineNumberWidth = this.lineNumberWidth
        let bodyOverflowX = false
        let bodyOverflowY = false
        let sWidth = 0
        for (let i = 0; i < this.columns.length; i++) {
            if (this.columns[i].frozen || this.columns[i].hidden) continue
            let cSize = parseInt(this.columns[i].sizeCalculated ? this.columns[i].sizeCalculated : this.columns[i].size)
            sWidth += cSize
        }
        if (records[0]?.clientWidth < sWidth) bodyOverflowX = true
        if (body[0]?.clientHeight - (columns[0]?.clientHeight ?? 0)
                < (query(records).find(':scope > table')[0]?.clientHeight ?? 0) + (bodyOverflowX ? w2utils.scrollBarSize() : 0)) {
            bodyOverflowY = true
        }
        // body might be expanded by data
        if (!this.fixedBody) {
            // allow it to render records, then resize
            let bodyHeight = w2utils.getSize(columns, 'height')
                + w2utils.getSize(query(this.box).find('#grid_'+ this.name +'_records table'), 'height')
                + (bodyOverflowX ? w2utils.scrollBarSize() : 0)
            let calculatedHeight = bodyHeight
                + (this.show.header ? w2utils.getSize(header, 'height') : 0)
                + (this.show.toolbar ? w2utils.getSize(toolbar, 'height') : 0)
                + (summary.css('display') != 'none' ? w2utils.getSize(summary, 'height') : 0)
                + (this.show.footer ? w2utils.getSize(footer, 'height') : 0)
            grid.css('height', calculatedHeight + 'px')
            body.css('height', bodyHeight + 'px')
            box.css('height', w2utils.getSize(grid, 'height') + 'px')
        } else {
            // fixed body height
            let calculatedHeight = grid[0]?.clientHeight
                - (this.show.header ? w2utils.getSize(header, 'height') : 0)
                - (this.show.toolbar ? w2utils.getSize(toolbar, 'height') : 0)
                - (summary.css('display') != 'none' ? w2utils.getSize(summary, 'height') : 0)
                - (this.show.footer ? w2utils.getSize(footer, 'height') : 0)
            body.css('height', calculatedHeight + 'px')
        }
        let buffered = this.records.length
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if (this.searchData.length != 0 && !url) buffered = this.last.searchIds.length
        // apply overflow
        if (!this.fixedBody) { bodyOverflowY = false }
        if (bodyOverflowX || bodyOverflowY) {
            columns.find(':scope > table > tbody > tr:nth-child(1) td.w2ui-head-last')
                .css('width', w2utils.scrollBarSize() + 'px')
                .show()
            records.css({
                top: ((this.columnGroups.length > 0 && this.show.columns ? 1 : 0) + w2utils.getSize(columns, 'height')) +'px',
                '-webkit-overflow-scrolling': 'touch',
                'overflow-x': (bodyOverflowX ? 'auto' : 'hidden'),
                'overflow-y': (bodyOverflowY ? 'auto' : 'hidden')
            })
        } else {
            columns.find(':scope > table > tbody > tr:nth-child(1) td.w2ui-head-last').hide()
            records.css({
                top: ((this.columnGroups.length > 0 && this.show.columns ? 1 : 0) + w2utils.getSize(columns, 'height')) +'px',
                overflow: 'hidden'
            })
            if (records.length > 0) { this.last.scrollTop = 0; this.last.scrollLeft = 0 } // if no scrollbars, always show top
        }
        if (bodyOverflowX) {
            frecords.css('margin-bottom', w2utils.scrollBarSize() + 'px')
            scroll1.show()
        } else {
            frecords.css('margin-bottom', 0)
            scroll1.hide()
        }
        frecords.css({ overflow: 'hidden', top: records.css('top') })
        if (this.show.emptyRecords && !bodyOverflowY) {
            let max = Math.floor((records[0]?.clientHeight ?? 0) / this.recordHeight) - 1
            let leftover = 0
            if (records[0]) leftover = records[0].scrollHeight - max * this.recordHeight
            if (leftover >= this.recordHeight) {
                leftover -= this.recordHeight
                max++
            }
            if (this.fixedBody) {
                for (let di = buffered; di < max; di++) {
                    addEmptyRow(di, this.recordHeight, this)
                }
                addEmptyRow(max, leftover, this)
            }
        }
        function addEmptyRow(row, height, grid) {
            let html1 = ''
            let html2 = ''
            let htmlp = ''
            html1    += '<tr class="'+ (row % 2 ? 'w2ui-even' : 'w2ui-odd') + ' w2ui-empty-record" recid="-none-" style="height: '+ height +'px">'
            html2    += '<tr class="'+ (row % 2 ? 'w2ui-even' : 'w2ui-odd') + ' w2ui-empty-record" recid="-none-" style="height: '+ height +'px">'
            if (grid.show.lineNumbers) html1 += '<td class="w2ui-col-number"></td>'
            if (grid.show.selectColumn) html1 += '<td class="w2ui-grid-data w2ui-col-select"></td>'
            if (grid.show.expandColumn) html1 += '<td class="w2ui-grid-data w2ui-col-expand"></td>'
            html2 += '<td class="w2ui-grid-data-spacer" col="start" style="border-right: 0"></td>'
            if (grid.reorderRows) html2 += '<td class="w2ui-grid-data w2ui-col-order" col="order"></td>'
            for (let j = 0; j < grid.columns.length; j++) {
                let col = grid.columns[j]
                if ((col.hidden || j < grid.last.colStart || j > grid.last.colEnd) && !col.frozen) continue
                htmlp = '<td class="w2ui-grid-data" '+ (col.attr != null ? col.attr : '') +' col="'+ j +'"></td>'
                if (col.frozen) html1 += htmlp; else html2 += htmlp
            }
            html1 += '<td class="w2ui-grid-data-last"></td> </tr>'
            html2 += '<td class="w2ui-grid-data-last" col="end"></td> </tr>'
            query(grid.box).find('#grid_'+ grid.name +'_frecords > table').append(html1)
            query(grid.box).find('#grid_'+ grid.name +'_records > table').append(html2)
        }
        let width_box, percent
        if (body.length > 0) {
            let width_max = parseInt(body[0].clientWidth)
                - (bodyOverflowY ? w2utils.scrollBarSize() : 0)
                - (this.show.lineNumbers ? lineNumberWidth : 0)
                - (this.reorderRows ? 26 : 0)
                - (this.show.selectColumn ? 26 : 0)
                - (this.show.expandColumn ? 26 : 0)
                - 1 // left is 1px due to border width
            width_box = width_max
            percent   = 0
            // gridMinWidth processing
            let restart = false
            for (let i = 0; i < this.columns.length; i++) {
                let col = this.columns[i]
                if (col.gridMinWidth > 0) {
                    if (col.gridMinWidth > width_box && col.hidden !== true) {
                        col.hidden = true
                        restart    = true
                    }
                    if (col.gridMinWidth < width_box && col.hidden === true) {
                        col.hidden = false
                        restart    = true
                    }
                }
            }
            if (restart === true) {
                this.refresh()
                return
            }
            // assign PX column s
            for (let i = 0; i < this.columns.length; i++) {
                let col = this.columns[i]
                if (col.hidden) continue
                if (String(col.size).substr(String(col.size).length-2).toLowerCase() == 'px') {
                    width_max -= parseFloat(col.size)
                    this.columns[i].sizeCalculated = col.size
                    this.columns[i].sizeType = 'px'
                } else {
                    percent += parseFloat(col.size)
                    this.columns[i].sizeType = '%'
                    delete col.sizeCorrected
                }
            }
            // if sum != 100% -- reassign proportionally
            if (percent != 100 && percent > 0) {
                for (let i = 0; i < this.columns.length; i++) {
                    let col = this.columns[i]
                    if (col.hidden) continue
                    if (col.sizeType == '%') {
                        col.sizeCorrected = Math.round(parseFloat(col.size) * 100 * 100 / percent) / 100 + '%'
                    }
                }
            }
            // calculate % columns
            for (let i = 0; i < this.columns.length; i++) {
                let col = this.columns[i]
                if (col.hidden) continue
                if (col.sizeType == '%') {
                    if (this.columns[i].sizeCorrected != null) {
                        // make it 1px smaller, so margin of error can be calculated correctly
                        this.columns[i].sizeCalculated = Math.floor(width_max * parseFloat(col.sizeCorrected) / 100) - 1 + 'px'
                    } else {
                        // make it 1px smaller, so margin of error can be calculated correctly
                        this.columns[i].sizeCalculated = Math.floor(width_max * parseFloat(col.size) / 100) - 1 + 'px'
                    }
                }
            }
        }
        // fix margin of error that is due percentage calculations
        let width_cols = 0
        for (let i = 0; i < this.columns.length; i++) {
            let col = this.columns[i]
            if (col.hidden) continue
            if (col.min == null) col.min = 20
            if (parseInt(col.sizeCalculated) < parseInt(col.min)) col.sizeCalculated = col.min + 'px'
            if (parseInt(col.sizeCalculated) > parseInt(col.max)) col.sizeCalculated = col.max + 'px'
            width_cols += parseInt(col.sizeCalculated)
        }
        let width_diff = parseInt(width_box) - parseInt(width_cols)
        if (width_diff > 0 && percent > 0) {
            let i = 0
            while (true) {
                let col = this.columns[i]
                if (col == null) { i = 0; continue }
                if (col.hidden || col.sizeType == 'px') { i++; continue }
                col.sizeCalculated = (parseInt(col.sizeCalculated) + 1) + 'px'
                width_diff--
                if (width_diff === 0) break
                i++
            }
        } else if (width_diff > 0) {
            columns.find(':scope > table > tbody > tr:nth-child(1) td.w2ui-head-last')
                .css('width', w2utils.scrollBarSize() + 'px')
                .show()
        }
        // find width of frozen columns
        let fwidth = 1
        if (this.show.lineNumbers) fwidth += lineNumberWidth
        if (this.show.selectColumn) fwidth += 26
        // if (this.reorderRows) fwidth += 26;
        if (this.show.expandColumn) fwidth += 26
        for (let i = 0; i < this.columns.length; i++) {
            if (this.columns[i].hidden) continue
            if (this.columns[i].frozen) fwidth += parseInt(this.columns[i].sizeCalculated)
        }
        fcolumns.css('width', fwidth + 'px')
        frecords.css('width', fwidth + 'px')
        fsummary.css('width', fwidth + 'px')
        scroll1.css('width', fwidth + 'px')
        columns.css('left', fwidth + 'px')
        records.css('left', fwidth + 'px')
        summary.css('left', fwidth + 'px')
        // resize columns
        columns.find(':scope > table > tbody > tr:nth-child(1) td')
            .add(fcolumns.find(':scope > table > tbody > tr:nth-child(1) td'))
            .each(el => {
                // line numbers
                if (query(el).hasClass('w2ui-col-number')) {
                    query(el).css('width', lineNumberWidth + 'px')
                }
                // records
                let ind = query(el).attr('col')
                if (ind != null) {
                    if (ind == 'start') {
                        let width = 0
                        for (let i = 0; i < obj.last.colStart; i++) {
                            if (!obj.columns[i] || obj.columns[i].frozen || obj.columns[i].hidden) continue
                            width += parseInt(obj.columns[i].sizeCalculated)
                        }
                        query(el).css('width', width + 'px')
                    }
                    if (obj.columns[ind]) query(el).css('width', obj.columns[ind].sizeCalculated) // already has px
                }
                // last column
                if (query(el).hasClass('w2ui-head-last')) {
                    if (obj.last.colEnd + 1 < obj.columns.length) {
                        let width = 0
                        for (let i = obj.last.colEnd + 1; i < obj.columns.length; i++) {
                            if (!obj.columns[i] || obj.columns[i].frozen || obj.columns[i].hidden) continue
                            width += parseInt(obj.columns[i].sizeCalculated)
                        }
                        query(el).css('width', width + 'px')
                    } else {
                        query(el).css('width', w2utils.scrollBarSize() + (width_diff > 0 && percent === 0 ? width_diff : 0) + 'px')
                    }
                }
            })
        // if there are column groups - hide first row (needed for sizing)
        if (columns.find(':scope > table > tbody > tr').length == 3) {
            columns.find(':scope > table > tbody > tr:nth-child(1) td')
                .add(fcolumns.find(':scope > table > tbody > tr:nth-child(1) td'))
                .html('').css({
                    'height' : '0',
                    'border' : '0',
                    'padding': '0',
                    'margin' : '0'
                })
        }
        // resize records
        records.find(':scope > table > tbody > tr:nth-child(1) td')
            .add(frecords.find(':scope > table > tbody > tr:nth-child(1) td'))
            .each(el => {
                // line numbers
                if (query(el).hasClass('w2ui-col-number')) {
                    query(el).css('width', lineNumberWidth + 'px')
                }
                // records
                let ind = query(el).attr('col')
                if (ind != null) {
                    if (ind == 'start') {
                        let width = 0
                        for (let i = 0; i < obj.last.colStart; i++) {
                            if (!obj.columns[i] || obj.columns[i].frozen || obj.columns[i].hidden) continue
                            width += parseInt(obj.columns[i].sizeCalculated)
                        }
                        query(el).css('width', width + 'px')
                    }
                    if (obj.columns[ind]) query(el).css('width', obj.columns[ind].sizeCalculated)
                }
                // last column
                if (query(el).hasClass('w2ui-grid-data-last') && query(el).parents('.w2ui-grid-frecords').length === 0) { // not in frecords
                    if (obj.last.colEnd + 1 < obj.columns.length) {
                        let width = 0
                        for (let i = obj.last.colEnd + 1; i < obj.columns.length; i++) {
                            if (!obj.columns[i] || obj.columns[i].frozen || obj.columns[i].hidden) continue
                            width += parseInt(obj.columns[i].sizeCalculated)
                        }
                        query(el).css('width', width + 'px')
                    } else {
                        query(el).css('width', (width_diff > 0 && percent === 0 ? width_diff : 0) + 'px')
                    }
                }
            })
        // resize summary
        summary.find(':scope > table > tbody > tr:nth-child(1) td')
            .add(fsummary.find(':scope > table > tbody > tr:nth-child(1) td'))
            .each(el => {
                // line numbers
                if (query(el).hasClass('w2ui-col-number')) {
                    query(el).css('width', lineNumberWidth + 'px')
                }
                // records
                let ind = query(el).attr('col')
                if (ind != null) {
                    if (ind == 'start') {
                        let width = 0
                        for (let i = 0; i < obj.last.colStart; i++) {
                            if (!obj.columns[i] || obj.columns[i].frozen || obj.columns[i].hidden) continue
                            width += parseInt(obj.columns[i].sizeCalculated)
                        }
                        query(el).css('width', width + 'px')
                    }
                    if (obj.columns[ind]) query(el).css('width', obj.columns[ind].sizeCalculated)
                }
                // last column
                if (query(el).hasClass('w2ui-grid-data-last') && query(el).parents('.w2ui-grid-frecords').length === 0) { // not in frecords
                    query(el).css('width', w2utils.scrollBarSize() + (width_diff > 0 && percent === 0 ? width_diff : 0) + 'px')
                }
            })
        this.initResize()
        this.refreshRanges()
        // apply last scroll if any
        if ((this.last.scrollTop || this.last.scrollLeft) && records.length > 0) {
            columns.prop('scrollLeft', this.last.scrollLeft)
            records.prop('scrollTop', this.last.scrollTop)
            records.prop('scrollLeft', this.last.scrollLeft)
        }
        // Improved performance when scrolling through tables
        columns.css('will-change', 'scroll-position')
    }
    getSearchesHTML() {
        let html = `
            <div class="search-title">
                ${w2utils.lang('Advanced Search')}
                <span class="search-logic" style="${this.show.searchLogic ? '' : 'display: none'}">
                    <select id="grid_${this.name}_logic" class="w2ui-input">
                        <option value="AND" ${this.last.logic == 'AND' ? 'selected' : ''}>${w2utils.lang('All')}</option>
                        <option value="OR" ${this.last.logic == 'OR' ? 'selected' : ''}>${w2utils.lang('Any')}</option>
                    </select>
                </span>
            </div>
            <table cellspacing="0"><tbody>
        `
        for (let i = 0; i < this.searches.length; i++) {
            let s  = this.searches[i]
            s.type = String(s.type).toLowerCase()
            if (s.hidden) continue
            if (s.attr == null) s.attr = ''
            if (s.text == null) s.text = ''
            if (s.style == null) s.style = ''
            if (s.type == null) s.type = 'text'
            if (s.label == null && s.caption != null) {
                console.log('NOTICE: grid search.caption property is deprecated, please use search.label. Search ->', s)
                s.label = s.caption
            }
            let operator =`<select id="grid_${this.name}_operator_${i}" class="w2ui-input" data-change="initOperator|${i}">
                    ${this.getOperators(s.type, s.operators)}
                </select>`
            html += `<tr>
                        <td class="caption">${(w2utils.lang(s.label) || '')}</td>
                        <td class="operator">${operator}</td>
                        <td class="value">`
            let tmpStyle
            switch (s.type) {
                case 'text':
                case 'alphanumeric':
                case 'hex':
                case 'color':
                case 'list':
                case 'combo':
                case 'enum':
                    tmpStyle = 'width: 250px;'
                    if (['hex', 'color'].indexOf(s.type) != -1) tmpStyle = 'width: 90px;'
                    html += `<input rel="search" type="text" id="grid_${this.name}_field_${i}" name="${s.field}"
                               class="w2ui-input" style="${tmpStyle + s.style}" ${s.attr}>`
                    break
                case 'int':
                case 'float':
                case 'money':
                case 'currency':
                case 'percent':
                case 'date':
                case 'time':
                case 'datetime':
                    tmpStyle = 'width: 90px;'
                    if (s.type == 'datetime') tmpStyle = 'width: 140px;'
                    html += `<input id="grid_${this.name}_field_${i}" name="${s.field}" ${s.attr} rel="search" type="text"
                                class="w2ui-input" style="${tmpStyle + s.style}">
                            <span id="grid_${this.name}_range_${i}" style="display: none">&#160;-&#160;&#160;
                                <input rel="search" type="text" class="w2ui-input" style="${tmpStyle + s.style}" id="grid_${this.name}_field2_${i}" name="${s.field}" ${s.attr}>
                            </span>`
                    break
                case 'select':
                    html += `<select rel="search" class="w2ui-input" style="${s.style}" id="grid_${this.name}_field_${i}"
                                name="${s.field}" ${s.attr}></select>`
                    break
            }
            html += s.text +
                    '    </td>' +
                    '</tr>'
        }
        html += `<tr>
            <td colspan="2" class="actions">
                <button type="button" class="w2ui-btn close-btn" data-click="searchClose">${w2utils.lang('Close')}</button>
            </td>
            <td class="actions">
                <button type="button" class="w2ui-btn" data-click="searchReset">${w2utils.lang('Reset')}</button>
                <button type="button" class="w2ui-btn w2ui-btn-blue" data-click="search">${w2utils.lang('Search')}</button>
            </td>
        </tr></tbody></table>`
        return html
    }
    getOperators(type, opers) {
        let operators = this.operators[this.operatorsMap[type]] || []
        if (opers != null && Array.isArray(opers)) {
            operators = opers
        }
        let html = ''
        operators.forEach(oper => {
            let displayText = oper
            let operValue = oper
            if (Array.isArray(oper)) {
                displayText = oper[1]
                operValue = oper[0]
            } else if (w2utils.isPlainObject(oper)) {
                displayText = oper.text
                operValue = oper.oper
            }
            if (displayText == null) displayText = oper
            html += `<option name="11" value="${operValue}">${w2utils.lang(displayText)}</option>\n`
        })
        return html
    }
    initOperator(ind) {
        let options
        let search  = this.searches[ind]
        let sdata   = this.getSearchData(search.field)
        let overlay = query(`#w2overlay-${this.name}-search-overlay`)
        let $rng    = overlay.find(`#grid_${this.name}_range_${ind}`)
        let $fld1   = overlay.find(`#grid_${this.name}_field_${ind}`)
        let $fld2   = overlay.find(`#grid_${this.name}_field2_${ind}`)
        let $oper   = overlay.find(`#grid_${this.name}_operator_${ind}`)
        let oper    = $oper.val()
        $fld1.show()
        $rng.hide()
        // init based on operator value
        switch (oper) {
            case 'between':
                $rng.show()
                break
            case 'null':
            case 'not null':
                $fld1.hide()
                $fld1.val(oper) // need to insert something for search to activate
                $fld1.trigger('change')
                break
        }
        // init based on search type
        switch (search.type) {
            case 'text':
            case 'alphanumeric':
                let fld = $fld1[0]._w2field
                if (fld) { fld.reset() }
                break
            case 'int':
            case 'float':
            case 'hex':
            case 'color':
            case 'money':
            case 'currency':
            case 'percent':
            case 'date':
            case 'time':
            case 'datetime':
                if (!$fld1[0]._w2field) {
                    // init fields
                    new w2field(search.type, { el: $fld1[0], ...search.options })
                    new w2field(search.type, { el: $fld2[0], ...search.options })
                    setTimeout(() => { // convert to date if it is number
                        $fld1.trigger('keydown')
                        $fld2.trigger('keydown')
                    }, 1)
                }
                break
            case 'list':
            case 'combo':
            case 'enum':
                options = search.options
                if (search.type == 'list') options.selected = {}
                if (search.type == 'enum') options.selected = []
                if (sdata) options.selected = sdata.value
                if (!$fld1[0]._w2field) {
                    let fld = new w2field(search.type, { el: $fld1[0], ...options })
                    if (sdata && sdata.text != null) {
                        fld.set({ id: sdata.value, text: sdata.text })
                    }
                }
                break
            case 'select':
                // build options
                options = '<option value="">--</option>'
                for (let i = 0; i < search.options.items.length; i++) {
                    let si = search.options.items[i]
                    if (w2utils.isPlainObject(search.options.items[i])) {
                        let val = si.id
                        let txt = si.text
                        if (val == null && si.value != null) val = si.value
                        if (txt == null && si.text != null) txt = si.text
                        if (val == null) val = ''
                        options += '<option value="'+ val +'">'+ txt +'</option>'
                    } else {
                        options += '<option value="'+ si +'">'+ si +'</option>'
                    }
                }
                $fld1.html(options)
                break
        }
    }
    initSearches() {
        let overlay = query(`#w2overlay-${this.name}-search-overlay`)
        // init searches
        for (let ind = 0; ind < this.searches.length; ind++) {
            let search  = this.searches[ind]
            let sdata   = this.getSearchData(search.field)
            search.type = String(search.type).toLowerCase()
            if (typeof search.options != 'object') search.options = {}
            // operators
            let operator  = search.operator
            let operators = [...this.operators[this.operatorsMap[search.type]]] || [] // need a copy
            if (search.operators) operators = search.operators
            // normalize
            if (w2utils.isPlainObject(operator)) operator = operator.oper
            operators.forEach((oper, ind) => {
                if (w2utils.isPlainObject(oper)) operators[ind] = oper.oper
            })
            if (sdata && sdata.operator) {
                operator = sdata.operator
            }
            // default operator
            let def = this.defaultOperator[this.operatorsMap[search.type]]
            if (operators.indexOf(operator) == -1) {
                operator = def
            }
            overlay.find(`#grid_${this.name}_operator_${ind}`).val(operator)
            this.initOperator(ind)
            // populate field value
            let $fld1 = overlay.find(`#grid_${this.name}_field_${ind}`)
            let $fld2 = overlay.find(`#grid_${this.name}_field2_${ind}`)
            if (sdata != null) {
                if (!Array.isArray(sdata.value)) {
                    if (sdata.value != null) $fld1.val(sdata.value).trigger('change')
                } else {
                    if (['in', 'not in'].includes(sdata.operator)) {
                        $fld1[0]._w2field.set(sdata.value)
                    } else {
                        $fld1.val(sdata.value[0]).trigger('change')
                        $fld2.val(sdata.value[1]).trigger('change')
                    }
                }
            }
        }
        // add on change event
        overlay.find('.w2ui-grid-search-advanced *[rel=search]')
            .on('keypress', evnt => {
                if (evnt.keyCode == 13) {
                    this.search()
                    w2tooltip.hide(this.name + '-search-overlay')
                }
            })
    }
    getColumnsHTML() {
        let self = this
        let html1 = ''
        let html2 = ''
        if (this.show.columnHeaders) {
            if (this.columnGroups.length > 0) {
                let tmp1 = getColumns(true)
                let tmp2 = getGroups()
                let tmp3 = getColumns(false)
                html1    = tmp1[0] + tmp2[0] + tmp3[0]
                html2    = tmp1[1] + tmp2[1] + tmp3[1]
            } else {
                let tmp = getColumns(true)
                html1   = tmp[0]
                html2   = tmp[1]
            }
        }
        return [html1, html2]
        function getGroups() {
            let html1 = '<tr>'
            let html2 = '<tr>'
            let tmpf  = ''
            // add empty group at the end
            let tmp = self.columnGroups.length - 1
            if (self.columnGroups[tmp].text == null && self.columnGroups[tmp].caption != null) {
                console.log('NOTICE: grid columnGroup.caption property is deprecated, please use columnGroup.text. Group -> ', self.columnGroups[tmp])
                self.columnGroups[tmp].text = self.columnGroups[tmp].caption
            }
            if (self.columnGroups[self.columnGroups.length-1].text != '') self.columnGroups.push({ text: '' })
            if (self.show.lineNumbers) {
                html1 += '<td class="w2ui-head w2ui-col-number" col="line-number">' +
                         '    <div>&#160;</div>' +
                         '</td>'
            }
            if (self.show.selectColumn) {
                html1 += '<td class="w2ui-head w2ui-col-select" col="select">' +
                         '    <div style="height: 25px">&#160;</div>' +
                         '</td>'
            }
            if (self.show.expandColumn) {
                html1 += '<td class="w2ui-head w2ui-col-expand" col="expand">' +
                         '    <div style="height: 25px">&#160;</div>' +
                         '</td>'
            }
            let ii = 0
            html2 += `<td id="grid_${self.name}_column_start" class="w2ui-head" col="start" style="border-right: 0"></td>`
            if (self.reorderRows) {
                html2 += '<td class="w2ui-head w2ui-col-order" col="order">' +
                         '    <div style="height: 25px">&#160;</div>' +
                         '</td>'
            }
            for (let i = 0; i < self.columnGroups.length; i++) {
                let colg = self.columnGroups[i]
                let col  = self.columns[ii] || {}
                if (colg.colspan != null) colg.span = colg.colspan
                if (colg.span == null || colg.span != parseInt(colg.span)) colg.span = 1
                if (col.text == null && col.caption != null) {
                    console.log('NOTICE: grid column.caption property is deprecated, please use column.text. Column ->', col)
                    col.text = col.caption
                }
                let colspan = 0
                for (let jj = ii; jj < ii + colg.span; jj++) {
                    if (self.columns[jj] && !self.columns[jj].hidden) {
                        colspan++
                    }
                }
                if (i == self.columnGroups.length-1) {
                    colspan = 100 // last column
                }
                if (colspan <= 0) {
                    // do nothing here, all columns in the group are hidden.
                } else if (colg.main === true) {
                    let sortStyle = ''
                    for (let si = 0; si < self.sortData.length; si++) {
                        if (self.sortData[si].field == col.field) {
                            if ((self.sortData[si].direction || '').toLowerCase() === 'asc') sortStyle = 'w2ui-sort-up'
                            if ((self.sortData[si].direction || '').toLowerCase() === 'desc') sortStyle = 'w2ui-sort-down'
                        }
                    }
                    let resizer = ''
                    if (col.resizable !== false) {
                        resizer = `<div class="w2ui-resizer" name="${ii}"></div>`
                    }
                    let text = w2utils.lang(typeof col.text == 'function' ? col.text(col) : col.text)
                    tmpf = `<td id="grid_${self.name}_column_${ii}" class="w2ui-head ${sortStyle}" col="${ii}" `+
                           `    rowspan="2" colspan="${colspan}">`+ resizer +
                           `    <div class="w2ui-col-group w2ui-col-header ${sortStyle ? 'w2ui-col-sorted' : ''}">` +
                           `        <div class="${sortStyle}"></div>` + (!text ? '&#160;' : text) +
                           '    </div>'+
                           '</td>'
                    if (col && col.frozen) html1 += tmpf; else html2 += tmpf
                } else {
                    let gText = w2utils.lang(typeof colg.text == 'function' ? colg.text(colg) : colg.text)
                    tmpf = `<td id="grid_${self.name}_column_${ii}" class="w2ui-head" col="${ii}" colspan="${colspan}">` +
                           `    <div class="w2ui-col-group" style="${colg.style ?? ''}">${!gText ? '&#160;' : gText}</div>` +
                           '</td>'
                    if (col && col.frozen) html1 += tmpf; else html2 += tmpf
                }
                ii += colg.span
            }
            html1 += '<td></td></tr>' // need empty column for border-right
            html2 += `<td id="grid_${self.name}_column_end" class="w2ui-head" col="end"></td></tr>`
            return [html1, html2]
        }
        function getColumns(main) {
            let html1 = '<tr>'
            let html2 = '<tr>'
            if (self.show.lineNumbers) {
                html1 += '<td class="w2ui-head w2ui-col-number" col="line-number">' +
                        '    <div>#</div>' +
                        '</td>'
            }
            if (self.show.selectColumn) {
                html1 += '<td class="w2ui-head w2ui-col-select" col="select">' +
                        '    <div>' +
                        `        <input type="checkbox" id="grid_${self.name}_check_all" class="w2ui-select-all" tabindex="-1"` +
                        `            style="${self.multiSelect == false ? 'display: none;' : ''}"` +
                        '        >' +
                        '    </div>' +
                        '</td>'
            }
            if (self.show.expandColumn) {
                html1 += '<td class="w2ui-head w2ui-col-expand" col="expand">' +
                        '    <div>&#160;</div>' +
                        '</td>'
            }
            let ii = 0
            let id = 0
            let colg
            html2 += `<td id="grid_${self.name}_column_start" class="w2ui-head" col="start" style="border-right: 0"></td>`
            if (self.reorderRows) {
                html2 += '<td class="w2ui-head w2ui-col-order" col="order">'+
                        '    <div>&#160;</div>'+
                        '</td>'
            }
            for (let i = 0; i < self.columns.length; i++) {
                let col = self.columns[i]
                if (col.text == null && col.caption != null) {
                    console.log('NOTICE: grid column.caption property is deprecated, please use column.text. Column -> ', col)
                    col.text = col.caption
                }
                if (col.size == null) col.size = '100%'
                if (i == id) { // always true on first iteration
                    colg = self.columnGroups[ii++] || {}
                    id   = id + colg.span
                }
                if ((i < self.last.colStart || i > self.last.colEnd) && !col.frozen)
                    continue
                if (col.hidden)
                    continue
                if (colg.main !== true || main) { // grouping of columns
                    let colCellHTML = self.getColumnCellHTML(i)
                    if (col && col.frozen) html1 += colCellHTML; else html2 += colCellHTML
                }
            }
            html1 += '<td class="w2ui-head w2ui-head-last"><div>&#160;</div></td>'
            html2 += '<td class="w2ui-head w2ui-head-last" col="end"><div>&#160;</div></td>'
            html1 += '</tr>'
            html2 += '</tr>'
            return [html1, html2]
        }
    }
    getColumnCellHTML(i) {
        let col = this.columns[i]
        if (col == null) return ''
        // reorder style
        let reorderCols = (this.reorderColumns && (!this.columnGroups || !this.columnGroups.length)) ? ' w2ui-col-reorderable ' : ''
        // sort style
        let sortStyle = ''
        for (let si = 0; si < this.sortData.length; si++) {
            if (this.sortData[si].field == col.field) {
                if ((this.sortData[si].direction || '').toLowerCase() === 'asc') sortStyle = 'w2ui-sort-up'
                if ((this.sortData[si].direction || '').toLowerCase() === 'desc') sortStyle = 'w2ui-sort-down'
            }
        }
        // col selected
        let tmp      = this.last.selection.columns
        let selected = false
        for (let t in tmp) {
            for (let si = 0; si < tmp[t].length; si++) {
                if (tmp[t][si] == i) selected = true
            }
        }
        let text = w2utils.lang(typeof col.text == 'function' ? col.text(col) : col.text)
        let html = '<td id="grid_'+ this.name + '_column_' + i +'" col="'+ i +'" class="w2ui-head '+ sortStyle + reorderCols + '">' +
                         (col.resizable !== false ? '<div class="w2ui-resizer" name="'+ i +'"></div>' : '') +
                    '    <div class="w2ui-col-header '+ (sortStyle ? 'w2ui-col-sorted' : '') +' '+ (selected ? 'w2ui-col-selected' : '') +'">'+
                    '        <div class="'+ sortStyle +'"></div>'+
                            (!text ? '&#160;' : text) +
                    '    </div>'+
                    '</td>'
        return html
    }
    columnTooltipShow(ind, event) {
        let $el  = query(this.box).find('#grid_'+ this.name + '_column_'+ ind)
        let item = this.columns[ind]
        let pos  = this.columnTooltip
        w2tooltip.show({
            name: this.name + '-column-tooltip',
            anchor: $el.get(0),
            html: item?.tooltip,
            position: pos,
        })
    }
    columnTooltipHide(ind, event) {
        w2tooltip.hide(this.name + '-column-tooltip')
    }
    getRecordsHTML() {
        let buffered = this.records.length
        let url      = (typeof this.url != 'object' ? this.url : this.url.get)
        if (this.searchData.length != 0 && !url) buffered = this.last.searchIds.length
        // larger number works better with chrome, smaller with FF.
        if (buffered > this.vs_start) this.last.show_extra = this.vs_extra; else this.last.show_extra = this.vs_start
        let records = query(this.box).find(`#grid_${this.name}_records`)
        let limit   = Math.floor((records.get(0)?.clientHeight || 0) / this.recordHeight) + this.last.show_extra + 1
        if (!this.fixedBody || limit > buffered) limit = buffered
        // always need first record for resizing purposes
        let rec_html = this.getRecordHTML(-1, 0)
        let html1    = '<table><tbody>' + rec_html[0]
        let html2    = '<table><tbody>' + rec_html[1]
        // first empty row with height
        html1 += '<tr id="grid_'+ this.name + '_frec_top" line="top" style="height: '+ 0 +'px">'+
                 '    <td colspan="2000"></td>'+
                 '</tr>'
        html2 += '<tr id="grid_'+ this.name + '_rec_top" line="top" style="height: '+ 0 +'px">'+
                 '    <td colspan="2000"></td>'+
                 '</tr>'
        for (let i = 0; i < limit; i++) {
            rec_html = this.getRecordHTML(i, i+1)
            html1   += rec_html[0]
            html2   += rec_html[1]
        }
        let h2 = (buffered - limit) * this.recordHeight
        html1 += '<tr id="grid_' + this.name + '_frec_bottom" rec="bottom" line="bottom" style="height: ' + h2 + 'px; vertical-align: top">' +
                '    <td colspan="2000" style="border-right: 1px solid #D6D5D7;"></td>'+
                '</tr>'+
                '<tr id="grid_'+ this.name +'_frec_more" style="display: none; ">'+
                '    <td colspan="2000" class="w2ui-load-more"></td>'+
                '</tr>'+
                '</tbody></table>'
        html2 += '<tr id="grid_' + this.name + '_rec_bottom" rec="bottom" line="bottom" style="height: ' + h2 + 'px; vertical-align: top">' +
                '    <td colspan="2000" style="border: 0"></td>'+
                '</tr>'+
                '<tr id="grid_'+ this.name +'_rec_more" style="display: none">'+
                '    <td colspan="2000" class="w2ui-load-more"></td>'+
                '</tr>'+
                '</tbody></table>'
        this.last.range_start = 0
        this.last.range_end   = limit
        return [html1, html2]
    }
    getSummaryHTML() {
        if (this.summary.length === 0) return
        let rec_html = this.getRecordHTML(-1, 0) // need this in summary too for colspan to work properly
        let html1    = '<table><tbody>' + rec_html[0]
        let html2    = '<table><tbody>' + rec_html[1]
        for (let i = 0; i < this.summary.length; i++) {
            rec_html = this.getRecordHTML(i, i+1, true)
            html1   += rec_html[0]
            html2   += rec_html[1]
        }
        html1 += '</tbody></table>'
        html2 += '</tbody></table>'
        return [html1, html2]
    }
    scroll(event) {
        let obj      = this
        let url      = (typeof this.url != 'object' ? this.url : this.url.get)
        let records  = query(this.box).find(`#grid_${this.name}_records`)
        let frecords = query(this.box).find(`#grid_${this.name}_frecords`)
        // sync scroll positions
        if (event) {
            let sTop  = event.target.scrollTop
            let sLeft = event.target.scrollLeft
            this.last.scrollTop  = sTop
            this.last.scrollLeft = sLeft
            let cols = query(this.box).find(`#grid_${this.name}_columns`)[0]
            let summary = query(this.box).find(`#grid_${this.name}_summary`)[0]
            if (cols) cols.scrollLeft = sLeft
            if (summary) summary.scrollLeft = sLeft
            if (frecords[0]) frecords[0].scrollTop = sTop
        }
        // hide bubble
        if (this.last.bubbleEl) {
            w2tooltip.hide(this.name + '-bubble')
            this.last.bubbleEl = null
        }
        // column virtual scroll
        let colStart = null
        let colEnd   = null
        if (this.disableCVS || this.columnGroups.length > 0) {
            // disable virtual scroll
            colStart = 0
            colEnd   = this.columns.length - 1
        } else {
            let sWidth = records.prop('clientWidth')
            let cLeft  = 0
            for (let i = 0; i < this.columns.length; i++) {
                if (this.columns[i].frozen || this.columns[i].hidden) continue
                let cSize = parseInt(this.columns[i].sizeCalculated ? this.columns[i].sizeCalculated : this.columns[i].size)
                if (cLeft + cSize + 30 > this.last.scrollLeft && colStart == null) colStart = i
                if (cLeft + cSize - 30 > this.last.scrollLeft + sWidth && colEnd == null) colEnd = i
                cLeft += cSize
            }
            if (colEnd == null) colEnd = this.columns.length - 1
        }
        if (colStart != null) {
            if (colStart < 0) colStart = 0
            if (colEnd < 0) colEnd = 0
            if (colStart == colEnd) {
                if (colStart > 0) colStart--; else colEnd++ // show at least one column
            }
            // ---------
            if (colStart != this.last.colStart || colEnd != this.last.colEnd) {
                let $box = query(this.box)
                let deltaStart = Math.abs(colStart - this.last.colStart)
                let deltaEnd   = Math.abs(colEnd - this.last.colEnd)
                // add/remove columns for small jumps
                if (deltaStart < 5 && deltaEnd < 5) {
                    let $cfirst = $box.find(`.w2ui-grid-columns #grid_${this.name}_column_start`)
                    let $clast  = $box.find('.w2ui-grid-columns .w2ui-head-last')
                    let $rfirst = $box.find(`#grid_${this.name}_records .w2ui-grid-data-spacer`)
                    let $rlast  = $box.find(`#grid_${this.name}_records .w2ui-grid-data-last`)
                    let $sfirst = $box.find(`#grid_${this.name}_summary .w2ui-grid-data-spacer`)
                    let $slast  = $box.find(`#grid_${this.name}_summary .w2ui-grid-data-last`)
                    // remove on left
                    if (colStart > this.last.colStart) {
                        for (let i = this.last.colStart; i < colStart; i++) {
                            $box.find('#grid_'+ this.name +'_columns #grid_'+ this.name +'_column_'+ i).remove() // column
                            $box.find('#grid_'+ this.name +'_records td[col="'+ i +'"]').remove() // record
                            $box.find('#grid_'+ this.name +'_summary td[col="'+ i +'"]').remove() // summary
                        }
                    }
                    // remove on right
                    if (colEnd < this.last.colEnd) {
                        for (let i = this.last.colEnd; i > colEnd; i--) {
                            $box.find('#grid_'+ this.name +'_columns #grid_'+ this.name +'_column_'+ i).remove() // column
                            $box.find('#grid_'+ this.name +'_records td[col="'+ i +'"]').remove() // record
                            $box.find('#grid_'+ this.name +'_summary td[col="'+ i +'"]').remove() // summary
                        }
                    }
                    // add on left
                    if (colStart < this.last.colStart) {
                        for (let i = this.last.colStart - 1; i >= colStart; i--) {
                            if (this.columns[i] && (this.columns[i].frozen || this.columns[i].hidden)) continue
                            $cfirst.after(this.getColumnCellHTML(i)) // column
                            // record
                            $rfirst.each(el => {
                                let index = query(el).parent().attr('index')
                                let td    = '<td class="w2ui-grid-data" col="'+ i +'" style="height: 0px"></td>' // width column
                                if (index != null) td = this.getCellHTML(parseInt(index), i, false)
                                query(el).after(td)
                            })
                            // summary
                            $sfirst.each(el => {
                                let index = query(el).parent().attr('index')
                                let td    = '<td class="w2ui-grid-data" col="'+ i +'" style="height: 0px"></td>' // width column
                                if (index != null) td = this.getCellHTML(parseInt(index), i, true)
                                query(el).after(td)
                            })
                        }
                    }
                    // add on right
                    if (colEnd > this.last.colEnd) {
                        for (let i = this.last.colEnd + 1; i <= colEnd; i++) {
                            if (this.columns[i] && (this.columns[i].frozen || this.columns[i].hidden)) continue
                            $clast.before(this.getColumnCellHTML(i)) // column
                            // record
                            $rlast.each(el => {
                                let index = query(el).parent().attr('index')
                                let td    = '<td class="w2ui-grid-data" col="'+ i +'" style="height: 0px"></td>' // width column
                                if (index != null) td = this.getCellHTML(parseInt(index), i, false)
                                query(el).before(td)
                            })
                            // summary
                            $slast.each(el => {
                                let index = query(el).parent().attr('index') || -1
                                let td    = this.getCellHTML(parseInt(index), i, true)
                                query(el).before(td)
                            })
                        }
                    }
                    this.last.colStart = colStart
                    this.last.colEnd   = colEnd
                    this.resizeRecords()
                } else {
                    this.last.colStart = colStart
                    this.last.colEnd   = colEnd
                    // dot not just call this.refresh();
                    let colHTML   = this.getColumnsHTML()
                    let recHTML   = this.getRecordsHTML()
                    let sumHTML   = this.getSummaryHTML()
                    let $columns  = $box.find(`#grid_${this.name}_columns`)
                    let $records  = $box.find(`#grid_${this.name}_records`)
                    let $frecords = $box.find(`#grid_${this.name}_frecords`)
                    let $summary  = $box.find(`#grid_${this.name}_summary`)
                    $columns.find('tbody').html(colHTML[1])
                    $frecords.html(recHTML[0])
                    $records.prepend(recHTML[1])
                    if (sumHTML != null) $summary.html(sumHTML[1])
                    // need timeout to clean up (otherwise scroll problem)
                    setTimeout(() => {
                        $records.find(':scope > table').filter(':not(table:first-child)').remove()
                        if ($summary[0]) $summary[0].scrollLeft = this.last.scrollLeft
                    }, 1)
                    this.resizeRecords()
                }
            }
        }
        // perform virtual scroll
        let buffered = this.records.length
        if (buffered > this.total && this.total !== -1) buffered = this.total
        if (this.searchData.length != 0 && !url) buffered = this.last.searchIds.length
        if (buffered === 0 || records.length === 0 || records.prop('clientHeight') === 0) return
        if (buffered > this.vs_start) this.last.show_extra = this.vs_extra; else this.last.show_extra = this.vs_start
        // update footer
        let t1 = Math.round(records.prop('scrollTop') / this.recordHeight + 1)
        let t2 = t1 + (Math.round(records.prop('clientHeight') / this.recordHeight) - 1)
        if (t1 > buffered) t1 = buffered
        if (t2 >= buffered - 1) t2 = buffered
        query(this.box).find('#grid_'+ this.name + '_footer .w2ui-footer-right').html(
            (this.show.statusRange
                ? w2utils.formatNumber(this.offset + t1) + '-' + w2utils.formatNumber(this.offset + t2) +
                    (this.total != -1 ? ' ' + w2utils.lang('of') + ' ' + w2utils.formatNumber(this.total) : '')
                    : '') +
            (url && this.show.statusBuffered ? ' ('+ w2utils.lang('buffered') + ' '+ w2utils.formatNumber(buffered) +
                    (this.offset > 0 ? ', skip ' + w2utils.formatNumber(this.offset) : '') + ')' : '')
        )
        // only for local data source, else no extra records loaded
        if (!url && (!this.fixedBody || (this.total != -1 && this.total <= this.vs_start))) return
        // regular processing
        let start = Math.floor(records.prop('scrollTop') / this.recordHeight) - this.last.show_extra
        let end   = start + Math.floor(records.prop('clientHeight') / this.recordHeight) + this.last.show_extra * 2 + 1
        // let div  = start - this.last.range_start;
        if (start < 1) start = 1
        if (end > this.total && this.total != -1) end = this.total
        let tr1  = records.find('#grid_'+ this.name +'_rec_top')
        let tr2  = records.find('#grid_'+ this.name +'_rec_bottom')
        let tr1f = frecords.find('#grid_'+ this.name +'_frec_top')
        let tr2f = frecords.find('#grid_'+ this.name +'_frec_bottom')
        // if row is expanded
        if (String(tr1.next().prop('id')).indexOf('_expanded_row') != -1) {
            tr1.next().remove()
            tr1f.next().remove()
        }
        if (this.total > end && String(tr2.prev().prop('id')).indexOf('_expanded_row') != -1) {
            tr2.prev().remove()
            tr2f.prev().remove()
        }
        let first = parseInt(tr1.next().attr('line'))
        let last  = parseInt(tr2.prev().attr('line'))
        let tmp, tmp1, tmp2, rec_start, rec_html
        if (first < start || first == 1 || this.last.pull_refresh) { // scroll down
            if (end <= last + this.last.show_extra - 2 && end != this.total) return
            this.last.pull_refresh = false
            // remove from top
            while (true) {
                tmp1 = frecords.find('#grid_'+ this.name +'_frec_top').next()
                tmp2 = records.find('#grid_'+ this.name +'_rec_top').next()
                if (tmp2.attr('line') == 'bottom') break
                if (parseInt(tmp2.attr('line')) < start) {
                    tmp1.remove()
                    tmp2.remove()
                } else {
                    break
                }
            }
            // add at bottom
            tmp = records.find('#grid_'+ this.name +'_rec_bottom').prev()
            rec_start = tmp.attr('line')
            if (rec_start == 'top') rec_start = start
            for (let i = parseInt(rec_start) + 1; i <= end; i++) {
                if (!this.records[i-1]) continue
                tmp2 = this.records[i-1].w2ui
                if (tmp2 && !Array.isArray(tmp2.children)) {
                    tmp2.expanded = false
                }
                rec_html = this.getRecordHTML(i-1, i)
                tr2.before(rec_html[1])
                tr2f.before(rec_html[0])
            }
            markSearch()
            setTimeout(() => { this.refreshRanges() }, 0)
        } else { // scroll up
            if (start >= first - this.last.show_extra + 2 && start > 1) return
            // remove from bottom
            while (true) {
                tmp1 = frecords.find('#grid_'+ this.name +'_frec_bottom').prev()
                tmp2 = records.find('#grid_'+ this.name +'_rec_bottom').prev()
                if (tmp2.attr('line') == 'top') break
                if (parseInt(tmp2.attr('line')) > end) {
                    tmp1.remove()
                    tmp2.remove()
                } else {
                    break
                }
            }
            // add at top
            tmp       = records.find('#grid_'+ this.name +'_rec_top').next()
            rec_start = tmp.attr('line')
            if (rec_start == 'bottom') rec_start = end
            for (let i = parseInt(rec_start) - 1; i >= start; i--) {
                if (!this.records[i-1]) continue
                tmp2 = this.records[i-1].w2ui
                if (tmp2 && !Array.isArray(tmp2.children)) {
                    tmp2.expanded = false
                }
                rec_html = this.getRecordHTML(i-1, i)
                tr1.after(rec_html[1])
                tr1f.after(rec_html[0])
            }
            markSearch()
            setTimeout(() => { this.refreshRanges() }, 0)
        }
        // first/last row size
        let h1 = (start - 1) * this.recordHeight
        let h2 = (buffered - end) * this.recordHeight
        if (h2 < 0) h2 = 0
        tr1.css('height', h1 + 'px')
        tr1f.css('height', h1 + 'px')
        tr2.css('height', h2 + 'px')
        tr2f.css('height', h2 + 'px')
        this.last.range_start = start
        this.last.range_end   = end
        // load more if needed
        let s = Math.floor(records.prop('scrollTop') / this.recordHeight)
        let e = s + Math.floor(records.prop('clientHeight') / this.recordHeight)
        if (e + 10 > buffered && this.last.pull_more !== true && (buffered < this.total - this.offset || (this.total == -1 && this.last.fetch.hasMore))) {
            if (this.autoLoad === true) {
                this.last.pull_more   = true
                this.last.fetch.offset += this.limit
                this.request('load')
            }
            // scroll function
            let more = query(this.box).find('#grid_'+ this.name +'_rec_more, #grid_'+ this.name +'_frec_more')
            more.show()
                .eq(1) // only main table
                .off('.load-more')
                .on('click.load-more', function() {
                    // show spinner
                    query(this).find('td').html('<div><div style="width: 20px; height: 20px;" class="w2ui-spinner"></div></div>')
                    // load more
                    obj.last.pull_more   = true
                    obj.last.fetch.offset += obj.limit
                    obj.request('load')
                })
                .find('td')
                .html(obj.autoLoad
                    ? '<div><div style="width: 20px; height: 20px;" class="w2ui-spinner"></div></div>'
                    : '<div style="padding-top: 15px">'+ w2utils.lang('Load ${count} more...', { count: obj.limit }) + '</div>'
                )
        }
        function markSearch() {
            // mark search
            if (!obj.markSearch) return
            clearTimeout(obj.last.marker_timer)
            obj.last.marker_timer = setTimeout(() => {
                // mark all search strings
                let search = []
                for (let s = 0; s < obj.searchData.length; s++) {
                    let sdata = obj.searchData[s]
                    let fld   = obj.getSearch(sdata.field)
                    if (!fld || fld.hidden) continue
                    let ind = obj.getColumn(sdata.field, true)
                    search.push({ field: sdata.field, search: sdata.value, col: ind })
                }
                if (search.length > 0) {
                    search.forEach((item) => {
                        let el = query(obj.box).find('td[col="'+ item.col +'"]:not(.w2ui-head)')
                        w2utils.marker(el, item.search)
                    })
                }
            }, 50)
        }
    }
    getRecordHTML(ind, lineNum, summary) {
        let tmph      = ''
        let rec_html1 = ''
        let rec_html2 = ''
        let sel       = this.last.selection
        let record
        // first record needs for resize purposes
        if (ind == -1) {
            rec_html1 += '<tr line="0">'
            rec_html2 += '<tr line="0">'
            if (this.show.lineNumbers) rec_html1 += '<td class="w2ui-col-number" style="height: 0px"></td>'
            if (this.show.selectColumn) rec_html1 += '<td class="w2ui-col-select" style="height: 0px"></td>'
            if (this.show.expandColumn) rec_html1 += '<td class="w2ui-col-expand" style="height: 0px"></td>'
            rec_html2 += '<td class="w2ui-grid-data w2ui-grid-data-spacer" col="start" style="height: 0px; width: 0px"></td>'
            if (this.reorderRows) rec_html2 += '<td class="w2ui-col-order" style="height: 0px"></td>'
            for (let i = 0; i < this.columns.length; i++) {
                let col = this.columns[i]
                tmph    = '<td class="w2ui-grid-data" col="'+ i +'" style="height: 0px;"></td>'
                if (col.frozen && !col.hidden) {
                    rec_html1 += tmph
                } else {
                    if (col.hidden || i < this.last.colStart || i > this.last.colEnd) continue
                    rec_html2 += tmph
                }
            }
            rec_html1 += '<td class="w2ui-grid-data-last" style="height: 0px"></td>'
            rec_html2 += '<td class="w2ui-grid-data-last" col="end" style="height: 0px"></td>'
            rec_html1 += '</tr>'
            rec_html2 += '</tr>'
            return [rec_html1, rec_html2]
        }
        // regular record
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if (summary !== true) {
            if (this.searchData.length > 0 && !url) {
                if (ind >= this.last.searchIds.length) return ''
                ind    = this.last.searchIds[ind]
                record = this.records[ind]
            } else {
                if (ind >= this.records.length) return ''
                record = this.records[ind]
            }
        } else {
            if (ind >= this.summary.length) return ''
            record = this.summary[ind]
        }
        if (!record) return ''
        if (record.recid == null && this.recid != null) {
            let rid = this.parseField(record, this.recid)
            if (rid != null) record.recid = rid
        }
        let isRowSelected = false
        if (sel.indexes.indexOf(ind) != -1) isRowSelected = true
        let rec_style = (record.w2ui ? record.w2ui.style : '')
        if (rec_style == null || typeof rec_style != 'string') rec_style = ''
        let rec_class = (record.w2ui ? record.w2ui.class : '')
        if (rec_class == null || typeof rec_class != 'string') rec_class = ''
        // render TR
        rec_html1 += '<tr id="grid_'+ this.name +'_frec_'+ record.recid +'" recid="'+ record.recid +'" line="'+ lineNum +'" index="'+ ind +'" '+
            ' class="'+ (lineNum % 2 === 0 ? 'w2ui-even' : 'w2ui-odd') + ' w2ui-record ' + rec_class +
                (isRowSelected && this.selectType == 'row' ? ' w2ui-selected' : '') +
                (record.w2ui && record.w2ui.editable === false ? ' w2ui-no-edit' : '') +
                (record.w2ui && record.w2ui.expanded === true ? ' w2ui-expanded' : '') + '" ' +
            ' style="height: '+ this.recordHeight +'px; '+ (!isRowSelected && rec_style != '' ? rec_style : rec_style.replace('background-color', 'none')) +'" '+
                (rec_style != '' ? 'custom_style="'+ rec_style +'"' : '') +
            '>'
        rec_html2 += '<tr id="grid_'+ this.name +'_rec_'+ record.recid +'" recid="'+ record.recid +'" line="'+ lineNum +'" index="'+ ind +'" '+
            ' class="'+ (lineNum % 2 === 0 ? 'w2ui-even' : 'w2ui-odd') + ' w2ui-record ' + rec_class +
                (isRowSelected && this.selectType == 'row' ? ' w2ui-selected' : '') +
                (record.w2ui && record.w2ui.editable === false ? ' w2ui-no-edit' : '') +
                (record.w2ui && record.w2ui.expanded === true ? ' w2ui-expanded' : '') + '" ' +
            ' style="height: '+ this.recordHeight +'px; '+ (!isRowSelected && rec_style != '' ? rec_style : rec_style.replace('background-color', 'none')) +'" '+
                (rec_style != '' ? 'custom_style="'+ rec_style +'"' : '') +
            '>'
        if (this.show.lineNumbers) {
            rec_html1 += '<td id="grid_'+ this.name +'_cell_'+ ind +'_number' + (summary ? '_s' : '') + '" '+
                        '   class="w2ui-col-number '+ (isRowSelected ? ' w2ui-row-selected' : '') +'"'+
                            (this.reorderRows ? ' style="cursor: move"' : '') + '>'+
                            (summary !== true ? this.getLineHTML(lineNum, record) : '') +
                        '</td>'
        }
        if (this.show.selectColumn) {
            rec_html1 +=
                    '<td id="grid_'+ this.name +'_cell_'+ ind +'_select' + (summary ? '_s' : '') + '" class="w2ui-grid-data w2ui-col-select">'+
                        (summary !== true && !(record.w2ui && record.w2ui.hideCheckBox === true) ?
                        '    <div>'+
                        '        <input class="w2ui-grid-select-check" type="checkbox" tabindex="-1" '+
                                    (isRowSelected ? 'checked="checked"' : '') + ' style="pointer-events: none"/>'+
                        '    </div>'
                        :
                        '' ) +
                    '</td>'
        }
        if (this.show.expandColumn) {
            let tmp_img = ''
            if (record.w2ui?.expanded === true) tmp_img = '-'; else tmp_img = '+'
            if ((record.w2ui?.expanded == 'none' || !Array.isArray(record.w2ui?.children) || !record.w2ui?.children.length)) tmp_img = '+'
            if (record.w2ui?.expanded == 'spinner') tmp_img = '<div class="w2ui-spinner" style="width: 16px; margin: -2px 2px;"></div>'
            rec_html1 +=
                    '<td id="grid_'+ this.name +'_cell_'+ ind +'_expand' + (summary ? '_s' : '') + '" class="w2ui-grid-data w2ui-col-expand">'+
                        (summary !== true ? `<div>${tmp_img}</div>` : '' ) +
                    '</td>'
        }
        // insert empty first column
        rec_html2 += '<td class="w2ui-grid-data-spacer" col="start" style="border-right: 0"></td>'
        if (this.reorderRows) {
            rec_html2 +=
                    '<td id="grid_'+ this.name +'_cell_'+ ind +'_order' + (summary ? '_s' : '') + '" class="w2ui-grid-data w2ui-col-order" col="order">'+
                        (summary !== true ? '<div title="Drag to reorder">&nbsp;</div>' : '' ) +
                    '</td>'
        }
        let col_ind  = 0
        let col_skip = 0
        while (true) {
            let col_span = 1
            let col      = this.columns[col_ind]
            if (col == null) break
            if (col.hidden) {
                col_ind++
                if (col_skip > 0) col_skip--
                continue
            }
            if (col_skip > 0) {
                col_ind++
                if (this.columns[col_ind] == null) break
                record.w2ui.colspan[this.columns[col_ind-1].field] = 0 // need it for other methods
                col_skip--
                continue
            } else if (record.w2ui) {
                let tmp1 = record.w2ui.colspan
                let tmp2 = this.columns[col_ind].field
                if (tmp1 && tmp1[tmp2] === 0) {
                    delete tmp1[tmp2] // if no longer colspan then remove 0
                }
            }
            // column virtual scroll
            if ((col_ind < this.last.colStart || col_ind > this.last.colEnd) && !col.frozen) {
                col_ind++
                continue
            }
            if (record.w2ui) {
                if (typeof record.w2ui.colspan == 'object') {
                    let span = parseInt(record.w2ui.colspan[col.field]) || null
                    if (span > 1) {
                        // if there are hidden columns, then no colspan on them
                        let hcnt = 0
                        for (let i = col_ind; i < col_ind + span; i++) {
                            if (i >= this.columns.length) break
                            if (this.columns[i].hidden) hcnt++
                        }
                        col_span = span - hcnt
                        col_skip = span - 1
                    }
                }
            }
            let rec_cell = this.getCellHTML(ind, col_ind, summary, col_span)
            if (col.frozen) rec_html1 += rec_cell; else rec_html2 += rec_cell
            col_ind++
        }
        rec_html1 += '<td class="w2ui-grid-data-last"></td>'
        rec_html2 += '<td class="w2ui-grid-data-last" col="end"></td>'
        rec_html1 += '</tr>'
        rec_html2 += '</tr>'
        return [rec_html1, rec_html2]
    }
    getLineHTML(lineNum) {
        return '<div>' + lineNum + '</div>'
    }
    getCellHTML(ind, col_ind, summary, col_span) {
        let obj = this
        let col = this.columns[col_ind]
        if (col == null) return ''
        let record  = (summary !== true ? this.records[ind] : this.summary[ind])
        // value, attr, style, className, divAttr
        let { value, style, className, attr, divAttr } = this.getCellValue(ind, col_ind, summary, true)
        let edit = (ind !== -1 ? this.getCellEditable(ind, col_ind) : '')
        let divStyle = 'max-height: '+ parseInt(this.recordHeight) +'px;' + (col.clipboardCopy ? 'margin-right: 20px' : '')
        let isChanged = !summary && record?.w2ui?.changes && record.w2ui.changes[col.field] != null
        let sel = this.last.selection
        let isRowSelected = false
        let infoBubble    = ''
        if (sel.indexes.indexOf(ind) != -1) isRowSelected = true
        if (col_span == null) {
            if (record?.w2ui?.colspan && record.w2ui.colspan[col.field]) {
                col_span = record.w2ui.colspan[col.field]
            } else {
                col_span = 1
            }
        }
        // expand icon
        if (col_ind === 0 && Array.isArray(record?.w2ui?.children)) {
            let level  = 0
            let subrec = this.get(record.w2ui.parent_recid, true)
            while (true) {
                if (subrec != null) {
                    level++
                    let tmp = this.records[subrec].w2ui
                    if (tmp != null && tmp.parent_recid != null) {
                        subrec = this.get(tmp.parent_recid, true)
                    } else {
                        break
                    }
                } else {
                    break
                }
            }
            if (record.w2ui.parent_recid) {
                for (let i = 0; i < level; i++) {
                    infoBubble += '<span class="w2ui-show-children w2ui-icon-empty"></span>'
                }
            }
            let className = record.w2ui.children.length > 0
                ? (record.w2ui.expanded ? 'w2ui-icon-collapse' : 'w2ui-icon-expand')
                : 'w2ui-icon-empty'
            infoBubble += `<span class="w2ui-show-children ${className}"></span>`
        }
        // info bubble
        if (col.info === true) col.info = {}
        if (col.info != null) {
            let infoIcon = 'w2ui-icon-info'
            if (typeof col.info.icon == 'function') {
                infoIcon = col.info.icon(record, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
            } else if (typeof col.info.icon == 'object') {
                infoIcon = col.info.icon[this.parseField(record, col.field)] || ''
            } else if (typeof col.info.icon == 'string') {
                infoIcon = col.info.icon
            }
            let infoStyle = col.info.style || ''
            if (typeof col.info.style == 'function') {
                infoStyle = col.info.style(record, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
            } else if (typeof col.info.style == 'object') {
                infoStyle = col.info.style[this.parseField(record, col.field)] || ''
            } else if (typeof col.info.style == 'string') {
                infoStyle = col.info.style
            }
            infoBubble += `<span class="w2ui-info ${infoIcon}" style="${infoStyle}"></span>`
        }
        let data = value
        // if editable checkbox
        if (edit && ['checkbox', 'check'].indexOf(edit.type) != -1) {
            let changeInd = summary ? -(ind + 1) : ind
            divStyle += 'text-align: center;'
            data  = `<input tabindex="-1" type="checkbox" class="w2ui-editable-checkbox"
                            data-changeInd="${changeInd}" data-colInd="${col_ind}" ${data ? 'checked="checked"' : ''}>`
            infoBubble    = ''
        }
        data = `<div style="${divStyle}" ${getTitle(data)} ${divAttr}>${infoBubble}${String(data)}</div>`
        if (data == null) data = ''
        // --> cell TD
        if (typeof col.render == 'string') {
            let tmp = col.render.toLowerCase().split(':')
            if (['number', 'int', 'float', 'money', 'currency', 'percent', 'size'].indexOf(tmp[0]) != -1) {
                style += 'text-align: right;'
            }
        }
        if (record?.w2ui) {
            if (typeof record.w2ui.style == 'object') {
                if (typeof record.w2ui.style[col_ind] == 'string') style += record.w2ui.style[col_ind] + ';'
                if (typeof record.w2ui.style[col.field] == 'string') style += record.w2ui.style[col.field] + ';'
            }
            if (typeof record.w2ui.class == 'object') {
                if (typeof record.w2ui.class[col_ind] == 'string') className += record.w2ui.class[col_ind] + ' '
                if (typeof record.w2ui.class[col.field] == 'string') className += record.w2ui.class[col.field] + ' '
            }
        }
        let isCellSelected = false
        if (isRowSelected && sel.columns[ind]?.includes(col_ind)) isCellSelected = true
        // clipboardCopy
        let clipboardIcon
        if (col.clipboardCopy){
            clipboardIcon = '<span class="w2ui-clipboard-copy w2ui-icon-paste"></span>'
        }
        // data
        data = '<td class="w2ui-grid-data'+ (isCellSelected ? ' w2ui-selected' : '') + ' ' + className +
                    (isChanged ? ' w2ui-changed' : '') + '" '+
                '   id="grid_'+ this.name +'_data_'+ ind +'_'+ col_ind +'" col="'+ col_ind +'" '+
                '   style="'+ style + (col.style != null ? col.style : '') +'" '+
                    (col.attr != null ? col.attr : '') + attr +
                    (col_span > 1 ? 'colspan="'+ col_span + '"' : '') +
                '>' + data + (clipboardIcon && w2utils.stripTags(data) ? clipboardIcon : '') +'</td>'
        // summary top row
        if (ind === -1 && summary === true) {
            data = '<td class="w2ui-grid-data" col="'+ col_ind +'" style="height: 0px; '+ style + '" '+
                        (col_span > 1 ? 'colspan="'+ col_span + '"' : '') +
                    '></td>'
        }
        return data
        function getTitle(cellData){
            let title
            if (obj.show.recordTitles) {
                if (col.title != null) {
                    if (typeof col.title == 'function') {
                        title = col.title.call(obj, record, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
                    }
                    if (typeof col.title == 'string') title = col.title
                } else {
                    title = w2utils.stripTags(String(cellData).replace(/"/g, '\'\''))
                }
            }
            return (title != null) ? 'title="' + String(title) + '"' : ''
        }
    }
    clipboardCopy(ind, col_ind, summary) {
        let rec = summary ? this.summary[ind] : this.records[ind]
        let col = this.columns[col_ind]
        let txt = (col ? this.parseField(rec, col.field) : '')
        if (typeof col.clipboardCopy == 'function') {
            txt = col.clipboardCopy(rec, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
        }
        query(this.box).find('#grid_' + this.name + '_focus').text(txt).get(0).select()
        document.execCommand('copy')
    }
    showBubble(ind, col_ind, summary) {
        let info = this.columns[col_ind].info
        if (!info) return
        let html = ''
        let rec  = this.records[ind]
        let el   = query(this.box).find(`${summary ? '.w2ui-grid-summary' : ''} #grid_${this.name}_data_${ind}_${col_ind} .w2ui-info`)
        if (this.last.bubbleEl) {
            w2tooltip.hide(this.name + '-bubble')
        }
        this.last.bubbleEl = el
        // if no fields defined - show all
        if (info.fields == null) {
            info.fields = []
            for (let i = 0; i < this.columns.length; i++) {
                let col = this.columns[i]
                info.fields.push(col.field + (typeof col.render == 'string' ? ':' + col.render : ''))
            }
        }
        let fields = info.fields
        if (typeof fields == 'function') {
            fields = fields(rec, { self: this, index: ind, colIndex: col_ind, summary: !!summary }) // custom renderer
        }
        // generate html
        if (typeof info.render == 'function') {
            html = info.render(rec, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
        } else if (Array.isArray(fields)) {
            // display mentioned fields
            html = '<table cellpadding="0" cellspacing="0">'
            for (let i = 0; i < fields.length; i++) {
                let tmp = String(fields[i]).split(':')
                if (tmp[0] == '' || tmp[0] == '-' || tmp[0] == '--' || tmp[0] == '---') {
                    html += '<tr><td colspan=2><div style="border-top: '+ (tmp[0] == '' ? '0' : '1') +'px solid #C1BEBE; margin: 6px 0px;"></div></td></tr>'
                    continue
                }
                let col = this.getColumn(tmp[0])
                if (col == null) col = { field: tmp[0], caption: tmp[0] } // if not found in columns
                let val = (col ? this.parseField(rec, col.field) : '')
                if (tmp.length > 1) {
                    if (w2utils.formatters[tmp[1]]) {
                        val = w2utils.formatters[tmp[1]](val, tmp[2] || null, rec)
                    } else {
                        console.log('ERROR: w2utils.formatters["'+ tmp[1] + '"] does not exists.')
                    }
                }
                if (info.showEmpty !== true && (val == null || val == '')) continue
                if (info.maxLength != null && typeof val == 'string' && val.length > info.maxLength) val = val.substr(0, info.maxLength) + '...'
                html += '<tr><td>' + col.text + '</td><td>' + ((val === 0 ? '0' : val) || '') + '</td></tr>'
            }
            html += '</table>'
        } else if (w2utils.isPlainObject(fields)) {
            // display some fields
            html = '<table cellpadding="0" cellspacing="0">'
            for (let caption in fields) {
                let fld = fields[caption]
                if (fld == '' || fld == '-' || fld == '--' || fld == '---') {
                    html += '<tr><td colspan=2><div style="border-top: '+ (fld == '' ? '0' : '1') +'px solid #C1BEBE; margin: 6px 0px;"></div></td></tr>'
                    continue
                }
                let tmp = String(fld).split(':')
                let col = this.getColumn(tmp[0])
                if (col == null) col = { field: tmp[0], caption: tmp[0] } // if not found in columns
                let val = (col ? this.parseField(rec, col.field) : '')
                if (tmp.length > 1) {
                    if (w2utils.formatters[tmp[1]]) {
                        val = w2utils.formatters[tmp[1]](val, tmp[2] || null, rec)
                    } else {
                        console.log('ERROR: w2utils.formatters["'+ tmp[1] + '"] does not exists.')
                    }
                }
                if (typeof fld == 'function') {
                    val = fld(rec, { self: this, index: ind, colIndex: col_ind, summary: !!summary })
                }
                if (info.showEmpty !== true && (val == null || val == '')) continue
                if (info.maxLength != null && typeof val == 'string' && val.length > info.maxLength) val = val.substr(0, info.maxLength) + '...'
                html += '<tr><td>' + caption + '</td><td>' + ((val === 0 ? '0' : val) || '') + '</td></tr>'
            }
            html += '</table>'
        }
        return w2tooltip.show(w2utils.extend({
            name: this.name + '-bubble',
            html,
            anchor: el.get(0),
            position: 'top|bottom',
            class: 'w2ui-info-bubble',
            style: '',
            hideOn: ['doc-click']
        }, info.options ?? {}))
            .hide(() => [
                this.last.bubbleEl = null
            ])
    }
    // return null or the editable object if the given cell is editable
    getCellEditable(ind, col_ind) {
        let col = this.columns[col_ind]
        let rec = this.records[ind]
        if (!rec || !col) return null
        let edit = (rec.w2ui ? rec.w2ui.editable : null)
        if (edit === false) return null
        if (edit == null || edit === true) {
            edit = (Object.keys(col.editable ?? {}).length > 0 ? col.editable : null)
            if (typeof edit === 'function') {
                let value = this.getCellValue(ind, col_ind, false)
                // same arguments as col.render()
                edit = edit.call(this, rec, { self: this, value, index: ind, colIndex: col_ind })
            }
        }
        return edit
    }
    getCellValue(ind, col_ind, summary, extra) {
        let col = this.columns[col_ind]
        let record = (summary !== true ? this.records[ind] : this.summary[ind])
        let value = this.parseField(record, col.field)
        let className = '', style = '', attr = '', divAttr = ''
        // if change by inline editing
        if (record?.w2ui?.changes?.[col.field] != null) {
            value = record.w2ui.changes[col.field]
        }
        // if there is a cell renderer
        if (col.render != null && ind !== -1) {
            if (typeof col.render == 'function' && record != null) {
                let html
                try {
                    html = col.render(record, { self: this, value, index: ind, colIndex: col_ind, summary: !!summary })
                } catch (e) {
                    throw new Error(`Render function for column "${col.field}" in grid "${this.name}": -- ` + e.message)
                }
                if (html != null && typeof html == 'object' && typeof html != 'function') {
                    if (html.id != null && html.text != null) {
                        // normalized menu kind of return
                        value = html.text
                    } else if (typeof html.html == 'string') {
                        value = (html.html || '').trim()
                    } else {
                        value = ''
                        console.log('ERROR: render function should return a primitive or an object of the following structure.',
                            { html: '', attr: '', style: '', class: '', divAttr: '' })
                    }
                    attr = html.attr ?? ''
                    style = html.style ?? ''
                    className = html.class ?? ''
                    divAttr = html.divAttr ?? ''
                } else {
                    value = String(html || '').trim()
                }
            }
            // if it is an object
            if (typeof col.render == 'object') {
                let tmp = col.render[value]
                if (tmp != null && tmp !== '') {
                    value = tmp
                }
            }
            // formatters
            if (typeof col.render == 'string') {
                let strInd = col.render.toLowerCase().indexOf(':')
                let tmp = []
                if (strInd == -1) {
                    tmp[0] = col.render.toLowerCase()
                    tmp[1] = ''
                } else {
                    tmp[0] = col.render.toLowerCase().substr(0, strInd)
                    tmp[1] = col.render.toLowerCase().substr(strInd + 1)
                }
                // formatters
                let func = w2utils.formatters[tmp[0]]
                if (col.options && col.options.autoFormat === false) {
                    func = null
                }
                value = (typeof func == 'function' ? func(value, tmp[1], record) : '')
            }
        }
        if (value == null) value = ''
        return !extra ? value : { value, attr, style, className, divAttr }
    }
    getFooterHTML() {
        return '<div>'+
            '    <div class="w2ui-footer-left"></div>'+
            '    <div class="w2ui-footer-right"></div>'+
            '    <div class="w2ui-footer-center"></div>'+
            '</div>'
    }
    status(msg) {
        if (msg != null) {
            query(this.box).find(`#grid_${this.name}_footer`).find('.w2ui-footer-left').html(msg)
        } else {
            // show number of selected
            let msgLeft = ''
            let sel     = this.getSelection()
            if (sel.length > 0) {
                if (this.show.statusSelection && sel.length > 1) {
                    msgLeft = String(sel.length).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1' + w2utils.settings.groupSymbol) + ' ' + w2utils.lang('selected')
                }
                if (this.show.statusRecordID && sel.length == 1) {
                    let tmp = sel[0]
                    if (typeof tmp == 'object') tmp = tmp.recid + ', '+ w2utils.lang('Column') +': '+ tmp.column
                    msgLeft = w2utils.lang('Record ID') + ': '+ tmp + ' '
                }
            }
            query(this.box).find('#grid_'+ this.name +'_footer .w2ui-footer-left').html(msgLeft)
        }
    }
    lock(msg, showSpinner) {
        let args = Array.from(arguments)
        args.unshift(this.box)
        setTimeout(() => {
            // hide empty msg if any
            query(this.box).find('#grid_'+ this.name +'_empty_msg').remove()
            w2utils.lock(...args)
        }, 10)
    }
    unlock(speed) {
        setTimeout(() => {
            // do not unlock if there is a message
            if (query(this.box).find('.w2ui-message').hasClass('w2ui-closing')) return
            w2utils.unlock(this.box, speed)
        }, 25) // needed timer so if server fast, it will not flash
    }
    stateSave(returnOnly) {
        let state = {
            columns: [],
            show: w2utils.clone(this.show),
            last: {
                search: this.last.search,
                multi : this.last.multi,
                logic : this.last.logic,
                label : this.last.label,
                field : this.last.field,
                scrollTop : this.last.scrollTop,
                scrollLeft: this.last.scrollLeft
            },
            sortData  : [],
            searchData: []
        }
        let prop_val
        for (let i = 0; i < this.columns.length; i++) {
            let col          = this.columns[i]
            let col_save_obj = {}
            // iterate properties to save
            Object.keys(this.stateColProps).forEach((prop, idx) => {
                if (this.stateColProps[prop]){
                    // check if the property is defined on the column
                    if (col[prop] !== undefined){
                        prop_val = col[prop]
                    } else {
                        // use fallback or null
                        prop_val = this.colTemplate[prop] || null
                    }
                    col_save_obj[prop] = prop_val
                }
            })
            state.columns.push(col_save_obj)
        }
        for (let i = 0; i < this.sortData.length; i++) state.sortData.push(w2utils.clone(this.sortData[i]))
        for (let i = 0; i < this.searchData.length; i++) state.searchData.push(w2utils.clone(this.searchData[i]))
        // event before
        let edata = this.trigger('stateSave', { target: this.name, state: state })
        if (edata.isCancelled === true) {
            return
        }
        // save into local storage
        if (returnOnly !== true) {
            this.cacheSave('state', state)
        }
        // event after
        edata.finish()
        return state
    }
    stateRestore(newState) {
        let url = (typeof this.url != 'object' ? this.url : this.url.get)
        if (!newState) {
            newState = this.cache('state')
        }
        // event before
        let edata = this.trigger('stateRestore', { target: this.name, state: newState })
        if (edata.isCancelled === true) {
            return
        }
        // default behavior
        if (w2utils.isPlainObject(newState)) {
            w2utils.extend(this.show, newState.show ?? {})
            w2utils.extend(this.last, newState.last ?? {})
            let sTop  = this.last.scrollTop
            let sLeft = this.last.scrollLeft
            for (let c = 0; c < newState.columns?.length; c++) {
                let tmp       = newState.columns[c]
                let col_index = this.getColumn(tmp.field, true)
                if (col_index !== null) {
                    w2utils.extend(this.columns[col_index], tmp)
                    // restore column order from saved state
                    if (c !== col_index) this.columns.splice(c, 0, this.columns.splice(col_index, 1)[0])
                }
            }
            this.sortData.splice(0, this.sortData.length)
            for (let c = 0; c < newState.sortData?.length; c++) {
                this.sortData.push(newState.sortData[c])
            }
            this.searchData.splice(0, this.searchData.length)
            for (let c = 0; c < newState.searchData?.length; c++) {
                this.searchData.push(newState.searchData[c])
            }
            // apply sort and search
            setTimeout(() => {
                // needs timeout as records need to be populated
                // ez 10.09.2014 this -->
                if (!url) {
                    if (this.sortData.length > 0) this.localSort()
                    if (this.searchData.length > 0) this.localSearch()
                }
                this.last.scrollTop  = sTop
                this.last.scrollLeft = sLeft
                this.refresh()
            }, 1)
            console.log(`INFO (w2ui): state restored for "${this.name}"`)
        }
        // event after
        edata.finish()
        return true
    }
    stateReset() {
        this.stateRestore(this.last.state)
        this.cacheSave('state', null)
    }
    parseField(obj, field) {
        if (this.nestedFields) {
            let val = ''
            try { // need this to make sure no error in fields
                val     = obj
                let tmp = String(field).split('.')
                for (let i = 0; i < tmp.length; i++) {
                    val = val[tmp[i]]
                }
            } catch (event) {
                val = ''
            }
            return val
        } else {
            return obj ? obj[field] : ''
        }
    }
    prepareData() {
        let obj = this
        // loops thru records and prepares date and time objects
        for (let r = 0; r < this.records.length; r++) {
            let rec = this.records[r]
            prepareRecord(rec)
        }
        // prepare date and time objects for the 'rec' record and its closed children
        function prepareRecord(rec) {
            for (let c = 0; c < obj.columns.length; c++) {
                let column = obj.columns[c]
                if (rec[column.field] == null || typeof column.render != 'string') continue
                // number
                if (['number', 'int', 'float', 'money', 'currency', 'percent'].indexOf(column.render.split(':')[0]) != -1) {
                    if (typeof rec[column.field] != 'number') rec[column.field] = parseFloat(rec[column.field])
                }
                // date
                if (['date', 'age'].indexOf(column.render.split(':')[0]) != -1) {
                    if (!rec[column.field + '_']) {
                        let dt = rec[column.field]
                        if (w2utils.isInt(dt)) dt = parseInt(dt)
                        rec[column.field + '_'] = new Date(dt)
                    }
                }
                // time
                if (['time'].indexOf(column.render) != -1) {
                    if (w2utils.isTime(rec[column.field])) { // if string
                        let tmp = w2utils.isTime(rec[column.field], true)
                        let dt  = new Date()
                        dt.setHours(tmp.hours, tmp.minutes, (tmp.seconds ? tmp.seconds : 0), 0) // sets hours, min, sec, mills
                        if (!rec[column.field + '_']) rec[column.field + '_'] = dt
                    } else { // if date object
                        let tmp = rec[column.field]
                        if (w2utils.isInt(tmp)) tmp = parseInt(tmp)
                        tmp    = (tmp != null ? new Date(tmp) : new Date())
                        let dt = new Date()
                        dt.setHours(tmp.getHours(), tmp.getMinutes(), tmp.getSeconds(), 0) // sets hours, min, sec, mills
                        if (!rec[column.field + '_']) rec[column.field + '_'] = dt
                    }
                }
            }
            if (rec.w2ui?.children && rec.w2ui?.expanded !== true) {
                // there are closed children, prepare them too.
                for (let r = 0; r < rec.w2ui.children.length; r++) {
                    let subRec = rec.w2ui.children[r]
                    prepareRecord(subRec)
                }
            }
        }
    }
    nextCell(index, col_ind, editable) {
        let check = col_ind + 1
        if (check >= this.columns.length) {
            index = this.nextRow(index)
            return index == null ? index : this.nextCell(index, -1, editable)
        }
        let tmp = this.records[index].w2ui
        let col = this.columns[check]
        let span = (tmp && tmp.colspan && !isNaN(tmp.colspan[col.field]) ? parseInt(tmp.colspan[col.field]) : 1)
        if (col == null) return null
        if (col && col.hidden || span === 0) return this.nextCell(index, check, editable)
        if (editable) {
            let edit = this.getCellEditable(index, check)
            if (edit == null || ['checkbox', 'check'].indexOf(edit.type) != -1) {
                return this.nextCell(index, check, editable)
            }
        }
        return { index, colIndex: check }
    }
    prevCell(index, col_ind, editable) {
        let check = col_ind - 1
        if (check < 0) {
            index = this.prevRow(index)
            return index == null ? index : this.prevCell(index, this.columns.length, editable)
        }
        if (check < 0) return null
        let tmp = this.records[index].w2ui
        let col = this.columns[check]
        let span = (tmp && tmp.colspan && !isNaN(tmp.colspan[col.field]) ? parseInt(tmp.colspan[col.field]) : 1)
        if (col == null) return null
        if (col && col.hidden || span === 0) return this.prevCell(index, check, editable)
        if (editable) {
            let edit = this.getCellEditable(index, check)
            if (edit == null || ['checkbox', 'check'].indexOf(edit.type) != -1) {
                return this.prevCell(index, check, editable)
            }
        }
        return { index, colIndex: check }
    }
    nextRow(ind, col_ind, numRows) {
        let sids = this.last.searchIds
        let ret  = null
        if (numRows == null) numRows = 1
        if (numRows == -1) {
            return this.records.length-1
        }
        if ((ind + numRows < this.records.length && sids.length === 0) // if there are more records
                || (sids.length > 0 && ind < sids[sids.length-numRows])) {
            ind += numRows
            if (sids.length > 0) while (true) {
                if (sids.includes(ind) || ind > this.records.length) break
                ind += numRows
            }
            // colspan
            let tmp  = this.records[ind].w2ui
            let col  = this.columns[col_ind]
            let span = (tmp && tmp.colspan && col != null && !isNaN(tmp.colspan[col.field]) ? parseInt(tmp.colspan[col.field]) : 1)
            if (span === 0) {
                ret = this.nextRow(ind, col_ind, numRows)
            } else {
                ret = ind
            }
        }
        return ret
    }
    prevRow(ind, col_ind, numRows) {
        let sids = this.last.searchIds
        let ret  = null
        if (numRows == null) numRows = 1
        if (numRows == -1) {
            return 0
        }
        if ((ind - numRows >= 0 && sids.length === 0) // if there are more records
                || (sids.length > 0 && ind > sids[0])) {
            ind -= numRows
            if (sids.length > 0) while (true) {
                if (sids.includes(ind) || ind < 0) break
                ind -= numRows
            }
            // colspan
            let tmp  = this.records[ind].w2ui
            let col  = this.columns[col_ind]
            let span = (tmp && tmp.colspan && col != null && !isNaN(tmp.colspan[col.field]) ? parseInt(tmp.colspan[col.field]) : 1)
            if (span === 0) {
                ret = this.prevRow(ind, col_ind, numRows)
            } else {
                ret = ind
            }
        }
        return ret
    }
    selectionSave() {
        this.last.saved_sel = this.getSelection()
        return this.last.saved_sel
    }
    selectionRestore(noRefresh) {
        let time = Date.now()
        this.last.selection = { indexes: [], columns: {} }
        let sel = this.last.selection
        let lst = this.last.saved_sel
        if (lst) for (let i = 0; i < lst.length; i++) {
            if (w2utils.isPlainObject(lst[i])) {
                // selectType: cell
                let tmp = this.get(lst[i].recid, true)
                if (tmp != null) {
                    if (sel.indexes.indexOf(tmp) == -1) sel.indexes.push(tmp)
                    if (!sel.columns[tmp]) sel.columns[tmp] = []
                    sel.columns[tmp].push(lst[i].column)
                }
            } else {
                // selectType: row
                let tmp = this.get(lst[i], true)
                if (tmp != null) sel.indexes.push(tmp)
            }
        }
        delete this.last.saved_sel
        if (noRefresh !== true) this.refresh()
        return Date.now() - time
    }
    message(options) {
        return w2utils.message({
            owner: this,
            box  : this.box,
            after: '.w2ui-grid-header'
        }, options)
    }
    confirm(options) {
        return w2utils.confirm({
            owner: this,
            box  : this.box,
            after: '.w2ui-grid-header'
        }, options)
    }
}

/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tabs, w2toolbar, w2tooltip, w2field
 *
 * == TODO ==
 *  - include delta on save
 *  - tabs below some fields (could already be implemented)
 *  - form with toolbar & tabs
 *  - promise for load, save, etc.
 *
 * == 2.0 changes
 *  - CSP - fixed inline events
 *  - removed jQuery dependency
 *  - better groups support tabs now
 *  - form.confirm - refactored
 *  - form.message - refactored
 *  - observeResize for the box
 *  - removed msgNotJSON, msgAJAXerror
 *  - applyFocus -> setFocus
 *  - getFieldValue(fieldName) = returns { curent, previous, original }
 *  - setFieldVallue(fieldName, value)
 *  - getValue(..., original) -- return original if any
 *  - added .hideErrors()
 *  - reuqest, save, submit - return promises
 *  - this.recid = null if no record needs to be pulled
 *  - remove form.multiplart
 *  - this.method - for saving only
 */

class w2form extends w2base {
    constructor(options) {
        super(options.name)
        this.name         = null
        this.header       = ''
        this.box          = null // HTML element that hold this element
        this.url          = ''
        this.method       = null // if defined, it will be http method when saving
        this.routeData    = {} // data for dynamic routes
        this.formURL      = '' // url where to get form HTML
        this.formHTML     = '' // form HTML (might be loaded from the url)
        this.page         = 0 // current page
        this.pageStyle    = ''
        this.recid        = null // if not null, then load record
        this.fields       = []
        this.actions      = {}
        this.record       = {}
        this.original     = null
        this.dataType     = null // only used when not null, otherwise from w2utils.settings.dataType
        this.postData     = {}
        this.httpHeaders  = {}
        this.toolbar      = {} // if not empty, then it is toolbar
        this.tabs         = {} // if not empty, then it is tabs object
        this.style        = ''
        this.focus        = 0 // focus first or other element
        this.autosize     = true // autosize, if false the container must have a height set
        this.nestedFields = true // use field name containing dots as separator to look into object
        this.tabindexBase = 0 // this will be added to the auto numbering
        this.isGenerated  = false
        this.last         = {
            fetchCtrl: null,    // last fetch AbortController
            fetchOptions: null, // last fetch options
            errors: []
        }
        this.onRequest    = null
        this.onLoad       = null
        this.onValidate   = null
        this.onSubmit     = null
        this.onProgress   = null
        this.onSave       = null
        this.onChange     = null
        this.onInput      = null
        this.onRender     = null
        this.onRefresh    = null
        this.onResize     = null
        this.onDestroy    = null
        this.onAction     = null
        this.onToolbar    = null
        this.onError      = null
        this.msgRefresh   = 'Loading...'
        this.msgSaving    = 'Saving...'
        this.msgServerError = 'Server error'
        this.ALL_TYPES    = [ 'text', 'textarea', 'email', 'pass', 'password', 'int', 'float', 'money', 'currency',
            'percent', 'hex', 'alphanumeric', 'color', 'date', 'time', 'datetime', 'toggle', 'checkbox', 'radio',
            'check', 'checks', 'list', 'combo', 'enum', 'file', 'select', 'map', 'array', 'div', 'custom', 'html',
            'empty']
        this.LIST_TYPES = ['select', 'radio', 'check', 'checks', 'list', 'combo', 'enum']
        this.W2FIELD_TYPES = ['int', 'float', 'money', 'currency', 'percent', 'hex', 'alphanumeric', 'color',
            'date', 'time', 'datetime', 'list', 'combo', 'enum', 'file']
        // mix in options
        w2utils.extend(this, options)
        // remember items
        let record   = options.record
        let original = options.original
        let fields   = options.fields
        let toolbar  = options.toolbar
        let tabs     = options.tabs
        // extend items
        Object.assign(this, { record: {}, original: null, fields: [], tabs: {}, toolbar: {}, handlers: [] })
        // preprocess fields
        if (fields) {
            let sub =_processFields(fields)
            this.fields = sub.fields
            if (!tabs && sub.tabs.length > 0) {
                tabs = sub.tabs
            }
        }
        // prepare tabs
        if (Array.isArray(tabs)) {
            w2utils.extend(this.tabs, { tabs: [] })
            for (let t = 0; t < tabs.length; t++) {
                let tmp = tabs[t]
                if (typeof tmp === 'object') {
                    this.tabs.tabs.push(tmp)
                    if (tmp.active === true) {
                        this.tabs.active = tmp.id
                    }
                } else {
                    this.tabs.tabs.push({ id: tmp, text: tmp })
                }
            }
        } else {
            w2utils.extend(this.tabs, tabs)
        }
        w2utils.extend(this.toolbar, toolbar)
        for (let p in record) { // it is an object
            if (w2utils.isPlainObject(record[p])) {
                this.record[p] = w2utils.clone(record[p])
            } else {
                this.record[p] = record[p]
            }
        }
        for (let p in original) { // it is an object
            if (w2utils.isPlainObject(original[p])) {
                this.original[p] = w2utils.clone(original[p])
            } else {
                this.original[p] = original[p]
            }
        }
        // generate html if necessary
        if (this.formURL !== '') {
            fetch(this.formURL)
                .then(resp => resp.text())
                .then(text => {
                    this.formHTML = text
                    this.isGenerated = true
                    if (this.box) this.render(this.box)
                })
        } else if (!this.formURL && !this.formHTML) {
            this.formHTML    = this.generateHTML()
            this.isGenerated = true
        } else if (this.formHTML) {
            this.isGenerated = true
        }
        // render if box specified
        if (typeof this.box == 'string') this.box = query(this.box).get(0)
        if (this.box) this.render(this.box)
        function _processFields(fields) {
            let newFields = []
            let tabs = []
            // if it is an object
            if (w2utils.isPlainObject(fields)) {
                let tmp = fields
                fields = []
                Object.keys(tmp).forEach((key) => {
                    let fld = tmp[key]
                    if (fld.type == 'group') {
                        fld.text = key
                        if (w2utils.isPlainObject(fld.fields)) {
                            let tmp2 = fld.fields
                            fld.fields = []
                            Object.keys(tmp2).forEach((key2) => {
                                let fld2 = tmp2[key2]
                                fld2.field = key2
                                fld.fields.push(_process(fld2))
                            })
                        }
                        fields.push(fld)
                    } else if (fld.type == 'tab') {
                        // add tab
                        let tab = { id: key, text: key }
                        if (fld.style) {
                            tab.style = fld.style
                        }
                        tabs.push(tab)
                        // add page to fields
                        let sub = _processFields(fld.fields).fields
                        sub.forEach(fld2 => {
                            fld2.html = fld2.html || {}
                            fld2.html.page = tabs.length -1
                            _process2(fld, fld2)
                        })
                        fields.push(...sub)
                    } else {
                        fld.field = key
                        fields.push(_process(fld))
                    }
                })
                function _process(fld) {
                    let ignore = ['html']
                    if (fld.html == null) fld.html = {}
                    Object.keys(fld).forEach((key => {
                        if (ignore.indexOf(key) != -1) return
                        if (['label', 'attr', 'style', 'text', 'span', 'page', 'column', 'anchor',
                            'group', 'groupStyle', 'groupTitleStyle', 'groupCollapsible'].indexOf(key) != -1) {
                            fld.html[key] = fld[key]
                            delete fld[key]
                        }
                    }))
                    return fld
                }
                function _process2(fld, fld2) {
                    let ignore = ['style', 'html']
                    Object.keys(fld).forEach((key => {
                        if (ignore.indexOf(key) != -1) return
                        if (['span', 'column', 'attr', 'text', 'label'].indexOf(key) != -1) {
                            if (fld[key] && !fld2.html[key]) {
                                fld2.html[key] = fld[key]
                            }
                        }
                    }))
                }
            }
            // process groups
            fields.forEach(field => {
                if (field.type == 'group') {
                    // group properties
                    let group = {
                        group: field.text || '',
                        groupStyle: field.style || '',
                        groupTitleStyle: field.titleStyle || '',
                        groupCollapsible: field.collapsible === true ? true : false,
                    }
                    // loop through fields
                    if (Array.isArray(field.fields)) {
                        field.fields.forEach(gfield => {
                            let fld = w2utils.clone(gfield)
                            if (fld.html == null) fld.html = {}
                            w2utils.extend(fld.html, group)
                            Array('span', 'column', 'attr', 'label', 'page').forEach(key => {
                                if (fld.html[key] == null && field[key] != null) {
                                    fld.html[key] = field[key]
                                }
                            })
                            if (fld.field == null && fld.name != null) {
                                console.log('NOTICE: form field.name property is deprecated, please use field.field. Field ->', field)
                                fld.field = fld.name
                            }
                            newFields.push(fld)
                        })
                    }
                } else {
                    let fld = w2utils.clone(field)
                    if (fld.field == null && fld.name != null) {
                        console.log('NOTICE: form field.name property is deprecated, please use field.field. Field ->', field)
                        fld.field = fld.name
                    }
                    newFields.push(fld)
                }
            })
            return { fields: newFields, tabs }
        }
    }
    get(field, returnIndex) {
        if (arguments.length === 0) {
            let all = []
            for (let f1 = 0; f1 < this.fields.length; f1++) {
                if (this.fields[f1].field != null) all.push(this.fields[f1].field)
            }
            return all
        } else {
            for (let f2 = 0; f2 < this.fields.length; f2++) {
                if (this.fields[f2].field == field) {
                    if (returnIndex === true) return f2; else return this.fields[f2]
                }
            }
            return null
        }
    }
    set(field, obj) {
        for (let f = 0; f < this.fields.length; f++) {
            if (this.fields[f].field == field) {
                w2utils.extend(this.fields[f] , obj)
                this.refresh(field)
                return true
            }
        }
        return false
    }
    getValue(field, original) {
        if (this.nestedFields) {
            let val = undefined
            try { // need this to make sure no error in fields
                let rec = original === true ? this.original : this.record
                val = String(field).split('.').reduce((rec, i) => { return rec[i] }, rec)
            } catch (event) {
            }
            return val
        } else {
            return this.record[field]
        }
    }
    setValue(field, value) {
        // will not refresh the form!
        if (value === '' || value == null
                || (Array.isArray(value) && value.length === 0)
                || (w2utils.isPlainObject(value) && Object.keys(value).length == 0)) {
            value = null
        }
        if (this.nestedFields) {
            try { // need this to make sure no error in fields
                let rec = this.record
                String(field).split('.').map((fld, i, arr) => {
                    if (arr.length - 1 !== i) {
                        if (rec[fld]) rec = rec[fld]; else { rec[fld] = {}; rec = rec[fld] }
                    } else {
                        rec[fld] = value
                    }
                })
                return true
            } catch (event) {
                return false
            }
        } else {
            this.record[field] = value
            return true
        }
    }
    getFieldValue(name) {
        let field = this.get(name)
        if (field == null) return
        let el = field.el
        let previous = this.getValue(name)
        let original = this.getValue(name, true)
        // orginary input control
        let current = el.value
        // should not be set to '', incosistent logic
        // if (previous == null) previous = ''
        // clean extra chars
        if (['int', 'float', 'percent', 'money', 'currency'].includes(field.type)) {
            current = field.w2field.clean(current)
        }
        // radio list
        if (['radio'].includes(field.type)) {
            let selected = query(el).closest('div').find('input:checked').get(0)
            if (selected) {
                let item = field.options.items[query(selected).data('index')]
                current = item.id
            } else {
                current = null
            }
        }
        // single checkbox
        if (['toggle', 'checkbox'].includes(field.type)) {
            current = el.checked
        }
        // check list
        if (['check', 'checks'].indexOf(field.type) !== -1) {
            current = []
            let selected = query(el).closest('div').find('input:checked')
            if (selected.length > 0) {
                selected.each(el => {
                    let item = field.options.items[query(el).data('index')]
                    current.push(item.id)
                })
            }
            if (!Array.isArray(previous)) previous = []
        }
        // lists
        let selected = el._w2field?.selected // drop downs and other w2field objects
        if (['list', 'enum', 'file'].includes(field.type) && selected) {
            // TODO: check when w2field is refactored
            let nv = selected
            let cv = previous
            if (Array.isArray(nv)) {
                current = []
                for (let i = 0; i < nv.length; i++) current[i] = w2utils.clone(nv[i]) // clone array
            }
            if (Array.isArray(cv)) {
                previous = []
                for (let i = 0; i < cv.length; i++) previous[i] = w2utils.clone(cv[i]) // clone array
            }
            if (w2utils.isPlainObject(nv)) {
                current = w2utils.clone(nv) // clone object
            }
            if (w2utils.isPlainObject(cv)) {
                previous = w2utils.clone(cv) // clone object
            }
        }
        // map, array
        if (['map', 'array'].includes(field.type)) {
            current = (field.type == 'map' ? {} : [])
            field.$el.parent().find('.w2ui-map-field').each(div => {
                let key = query(div).find('.w2ui-map.key').val()
                let value = query(div).find('.w2ui-map.value').val()
                if (field.type == 'map') {
                    current[key] = value
                } else {
                    current.push(value)
                }
            })
        }
        return { current, previous, original } // current - in input, previous - in form.record, original - before form change
    }
    setFieldValue(name, value) {
        let field = this.get(name)
        if (field == null) return
        let el = field.el
        switch (field.type) {
            case 'toggle':
            case 'checkbox':
                el.checked = value ? true : false
                break
            case 'radio': {
                value = value?.id ?? value
                let inputs = query(el).closest('div').find('input')
                let items  = field.options.items
                items.forEach((it, ind) => {
                    if (it.id === value) { // need exact match so to match empty string and 0
                        inputs.filter(`[data-index="${ind}"]`).prop('checked', true)
                    }
                })
                break
            }
            case 'check':
            case 'checks': {
                if (!Array.isArray(value)) {
                    if (value != null) {
                        value = [value]
                    } else {
                        value = []
                    }
                }
                value = value.map(val => val?.id ?? val) // convert if array of objects
                let inputs = query(el).closest('div').find('input')
                let items  = field.options.items
                items.forEach((it, ind) => {
                    inputs.filter(`[data-index="${ind}"]`).prop('checked', value.includes(it.id) ? true : false)
                })
                break
            }
            case 'list':
            case 'combo':
                let item = value
                // find item in options.items, if any
                if (item?.id == null && Array.isArray(field.options?.items)) {
                    field.options.items.forEach(it => {
                        if (it.id === value) item = it
                    })
                }
                // if item is found in field.options, update it in the this.records
                if (item != value) {
                    this.setValue(field.name, item)
                }
                if (field.type == 'list') {
                    field.w2field.selected = item
                    field.w2field.refresh()
                } else {
                    field.el.value = item?.text ?? value
                }
                break
            case 'enum':
            case 'file': {
                if (!Array.isArray(value)) {
                    value = value != null ? [value] : []
                }
                let items = [...value]
                // find item in options.items, if any
                let updated = false
                items.forEach((item, ind) => {
                    if (item?.id == null && Array.isArray(field.options.items)) {
                        field.options.items.forEach(it => {
                            if (it.id == item) {
                                items[ind] = it
                                updated = true
                            }
                        })
                    }
                })
                if (updated) {
                    this.setValue(field.name, items)
                }
                field.w2field.selected = items
                field.w2field.refresh()
                break
            }
            case 'map':
            case 'array': {
                // init map
                if (field.type == 'map' && (value == null || !w2utils.isPlainObject(value))) {
                    this.setValue(field.field, {})
                    value = this.getValue(field.field)
                }
                if (field.type == 'array' && (value == null || !Array.isArray(value))) {
                    this.setValue(field.field, [])
                    value = this.getValue(field.field)
                }
                let container = query(field.el).parent().find('.w2ui-map-container')
                field.el.mapRefresh(value, container)
                break
            }
            case 'div':
            case 'custom':
                query(el).html(value)
                break
            case 'html':
            case 'empty':
                break
            default:
                // regular text fields
                el.value = value ?? ''
                break
        }
    }
    show() {
        let effected = []
        for (let a = 0; a < arguments.length; a++) {
            let fld = this.get(arguments[a])
            if (fld && fld.hidden) {
                fld.hidden = false
                effected.push(fld.field)
            }
        }
        if (effected.length > 0) this.refresh.apply(this, effected)
        this.updateEmptyGroups()
        return effected
    }
    hide() {
        let effected = []
        for (let a = 0; a < arguments.length; a++) {
            let fld = this.get(arguments[a])
            if (fld && !fld.hidden) {
                fld.hidden = true
                effected.push(fld.field)
            }
        }
        if (effected.length > 0) this.refresh.apply(this, effected)
        this.updateEmptyGroups()
        return effected
    }
    enable() {
        let effected = []
        for (let a = 0; a < arguments.length; a++) {
            let fld = this.get(arguments[a])
            if (fld && fld.disabled) {
                fld.disabled = false
                effected.push(fld.field)
            }
        }
        if (effected.length > 0) this.refresh.apply(this, effected)
        return effected
    }
    disable() {
        let effected = []
        for (let a = 0; a < arguments.length; a++) {
            let fld = this.get(arguments[a])
            if (fld && !fld.disabled) {
                fld.disabled = true
                effected.push(fld.field)
            }
        }
        if (effected.length > 0) this.refresh.apply(this, effected)
        return effected
    }
    updateEmptyGroups() {
        // hide empty groups
        query(this.box).find('.w2ui-group').each((group) =>{
            if (isHidden(query(group).find('.w2ui-field'))) {
                query(group).hide()
            } else {
                query(group).show()
            }
        })
        function isHidden($els) {
            let flag = true
            $els.each((el) => {
                if (el.style.display != 'none') flag = false
            })
            return flag
        }
    }
    change() {
        Array.from(arguments).forEach((field) => {
            let tmp = this.get(field)
            if (tmp.$el) tmp.$el.change()
        })
    }
    reload(callBack) {
        let url = (typeof this.url !== 'object' ? this.url : this.url.get)
        if (url && this.recid != null) {
            // this.clear();
            return this.request(callBack) // returns promise
        } else {
            // this.refresh(); // no need to refresh
            if (typeof callBack === 'function') callBack()
            return new Promise(resolve => { resolve() }) // resolved promise
        }
    }
    clear() {
        if (arguments.length != 0) {
            Array.from(arguments).forEach((field) => {
                let rec = this.record
                String(field).split('.').map((fld, i, arr) => {
                    if (arr.length - 1 !== i) rec = rec[fld]; else delete rec[fld]
                })
                this.refresh(field)
            })
        } else {
            this.recid = null
            this.record = {}
            this.original = null
            this.refresh()
            this.hideErrors()
        }
    }
    error(msg) {
        // let the management of the error outside of the form
        let edata = this.trigger('error', {
            target: this.name,
            message: msg,
            fetchCtrl: this.last.fetchCtrl,
            fetchOptions: this.last.fetchOptions
        })
        if (edata.isCancelled === true) return
        // need a time out because message might be already up)
        setTimeout(() => { this.message(msg) }, 1)
        // event after
        edata.finish()
    }
    message(options) {
        return w2utils.message({
            owner: this,
            box  : this.box,
            after: '.w2ui-form-header'
        }, options)
    }
    confirm(options) {
        return w2utils.confirm({
            owner: this,
            box  : this.box,
            after: '.w2ui-form-header'
        }, options)
    }
    validate(showErrors) {
        if (showErrors == null) showErrors = true
        // validate before saving
        let errors = []
        for (let f = 0; f < this.fields.length; f++) {
            let field = this.fields[f]
            if (this.getValue(field.field) == null) this.setValue(field.field, '')
            if (['int', 'float', 'currency', 'money'].indexOf(field.type) != -1) {
                let val = this.getValue(field.field)
                let min = field.options.min
                let max = field.options.max
                if (min != null && val < min) {
                    errors.push({ field: field, error: w2utils.lang('Should be more than ${min}', { min }) })
                }
                if (max != null && val > max) {
                    errors.push({ field: field, error: w2utils.lang('Should be less than ${max}', { max }) })
                }
            }
            switch (field.type) {
                case 'alphanumeric':
                    if (this.getValue(field.field) && !w2utils.isAlphaNumeric(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not alpha-numeric') })
                    }
                    break
                case 'int':
                    if (this.getValue(field.field) && !w2utils.isInt(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not an integer') })
                    }
                    break
                case 'percent':
                case 'float':
                    if (this.getValue(field.field) && !w2utils.isFloat(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not a float') })
                    }
                    break
                case 'currency':
                case 'money':
                    if (this.getValue(field.field) && !w2utils.isMoney(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not in money format') })
                    }
                    break
                case 'color':
                case 'hex':
                    if (this.getValue(field.field) && !w2utils.isHex(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not a hex number') })
                    }
                    break
                case 'email':
                    if (this.getValue(field.field) && !w2utils.isEmail(this.getValue(field.field))) {
                        errors.push({ field: field, error: w2utils.lang('Not a valid email') })
                    }
                    break
                case 'checkbox':
                    // convert true/false
                    if (this.getValue(field.field) == true) {
                        this.setValue(field.field, true)
                    } else {
                        this.setValue(field.field, false)
                    }
                    break
                case 'date':
                    // format date before submit
                    if (!field.options.format) field.options.format = w2utils.settings.dateFormat
                    if (this.getValue(field.field) && !w2utils.isDate(this.getValue(field.field), field.options.format)) {
                        errors.push({ field: field, error: w2utils.lang('Not a valid date') + ': ' + field.options.format })
                    }
                    break
                case 'list':
                case 'combo':
                    break
                case 'enum':
                    break
            }
            // === check required - if field is '0' it should be considered not empty
            let val = this.getValue(field.field)
            if (field.hidden !== true && field.required
                    && !['div', 'custom', 'html', 'empty'].includes(field.type)
                    && (val == null || val === '' || (Array.isArray(val) && val.length === 0)
                        || (w2utils.isPlainObject(val) && Object.keys(val).length == 0))) {
                errors.push({ field: field, error: w2utils.lang('Required field') })
            }
            if (field.hidden !== true && field.options?.minLength > 0
                    && !['enum', 'list', 'combo'].includes(field.type) // since minLength is used there for other purpose
                    && (val == null || val.length < field.options.minLength)) {
                errors.push({ field: field, error: w2utils.lang('Field should be at least ${count} characters.',
                    { count: field.options.minLength })})
            }
        }
        // event before
        let edata = this.trigger('validate', { target: this.name, errors: errors })
        if (edata.isCancelled === true) return
        // show error
        this.last.errors = errors
        if (showErrors) this.showErrors()
        // event after
        edata.finish()
        return errors
    }
    showErrors() {
        // TODO: check edge cases
        // -- scroll
        // -- invisible pages
        // -- form refresh
        let errors = this.last.errors
        if (errors.length <= 0) return
        // show errors
        this.goto(errors[0].field.page)
        query(errors[0].field.$el).parents('.w2ui-field')[0].scrollIntoView({ block: 'nearest', inline: 'nearest' })
        // show errors
        // show only for visible controls
        errors.forEach(error => {
            let opt = w2utils.extend({
                anchorClass: 'w2ui-error',
                class: 'w2ui-light',
                position: 'right|left',
                hideOn: ['input']
            }, error.options)
            if (error.field == null) return
            let anchor = error.field.el
            if (error.field.type === 'radio') { // for radio and checkboxes
                anchor = query(error.field.el).closest('div').get(0)
            } else if (['enum', 'file'].includes(error.field.type)) {
                // TODO: check
                // anchor = (error.field.el).data('w2field').helpers.multi
                // $(fld).addClass('w2ui-error')
            }
            w2tooltip.show(w2utils.extend({
                anchor,
                name: `${this.name}-${error.field.field}-error`,
                html: error.error
            }, opt))
        })
        // hide errors on scroll
        query(errors[0].field.$el).parents('.w2ui-page')
            .off('.hideErrors')
            .on('scroll.hideErrors', (evt) => { this.hideErrors() })
    }
    hideErrors() {
        this.fields.forEach(field => {
            w2tooltip.hide(`${this.name}-${field.field}-error`)
        })
    }
    getChanges() {
        // TODO: not working on nested structures
        let diff = {}
        if (this.original != null && typeof this.original == 'object' && Object.keys(this.record).length !== 0) {
            diff = doDiff(this.record, this.original, {})
        }
        return diff
        function doDiff(record, original, result) {
            if (Array.isArray(record) && Array.isArray(original)) {
                while (record.length < original.length) {
                    record.push(null)
                }
            }
            for (let i in record) {
                if (record[i] != null && typeof record[i] === 'object') {
                    result[i] = doDiff(record[i], original[i] || {}, {})
                    if (!result[i] || (Object.keys(result[i]).length == 0 && Object.keys(original[i].length == 0))) delete result[i]
                } else if (record[i] != original[i] || (record[i] == null && original[i] != null)) { // also catch field clear
                    result[i] = record[i]
                }
            }
            return Object.keys(result).length != 0 ? result : null
        }
    }
    getCleanRecord(strict) {
        let data = w2utils.clone(this.record)
        this.fields.forEach((fld) => {
            if (['list', 'combo', 'enum'].indexOf(fld.type) != -1) {
                let tmp = { nestedFields: true, record: data }
                let val = this.getValue.call(tmp, fld.field)
                if (w2utils.isPlainObject(val) && val.id != null) { // should be true if val.id === ''
                    this.setValue.call(tmp, fld.field, val.id)
                }
                if (Array.isArray(val)) {
                    val.forEach((item, ind) => {
                        if (w2utils.isPlainObject(item) && item.id) {
                            val[ind] = item.id
                        }
                    })
                }
            }
            if (fld.type == 'map') {
                let tmp = { nestedFields: true, record: data }
                let val = this.getValue.call(tmp, fld.field)
                if (val._order) delete val._order
            }
            if (fld.type == 'file') {
                let tmp = { nestedFields: true, record: data }
                let val = this.getValue.call(tmp, fld.field) ?? []
                val.forEach(v => {
                    delete v.file
                    delete v.modified
                })
                this.setValue.call(tmp, fld.field, val)
            }
        })
        // return only records present in description
        if (strict === true) {
            Object.keys(data).forEach((key) => {
                if (!this.get(key)) delete data[key]
            })
        }
        return data
    }
    request(postData, callBack) { // if (1) param then it is call back if (2) then postData and callBack
        let self = this
        let resolve, reject
        let responseProm = new Promise((res, rej) => { resolve = res; reject = rej })
        // check for multiple params
        if (typeof postData === 'function') {
            callBack = postData
            postData = null
        }
        if (postData == null) postData = {}
        if (!this.url || (typeof this.url === 'object' && !this.url.get)) return
        // build parameters list
        let params = {}
        // add list params
        params.action = 'get'
        params.recid = this.recid
        params.name  = this.name
        // append other params
        w2utils.extend(params, this.postData)
        w2utils.extend(params, postData)
        // event before
        let edata = this.trigger('request', { target: this.name, url: this.url, httpMethod: 'GET',
            postData: params, httpHeaders: this.httpHeaders })
        if (edata.isCancelled === true) return
        // default action
        this.record = {}
        this.original = null
        // call server to get data
        this.lock(w2utils.lang(this.msgRefresh))
        let url = edata.detail.url
        if (typeof url === 'object' && url.get) url = url.get
        if (this.last.fetchCtrl) try { this.last.fetchCtrl.abort() } catch (e) {}
        // process url with routeData
        if (Object.keys(this.routeData).length != 0) {
            let info = w2utils.parseRoute(url)
            if (info.keys.length > 0) {
                for (let k = 0; k < info.keys.length; k++) {
                    if (this.routeData[info.keys[k].name] == null) continue
                    url = url.replace((new RegExp(':'+ info.keys[k].name, 'g')), this.routeData[info.keys[k].name])
                }
            }
        }
        url = new URL(url, location)
        let fetchOptions = w2utils.prepareParams(url, {
            method: edata.detail.httpMethod,
            headers: edata.detail.httpHeaders,
            body: edata.detail.postData
        }, this.dataType)
        this.last.fetchCtrl = new AbortController()
        fetchOptions.signal = this.last.fetchCtrl.signal
        this.last.fetchOptions = fetchOptions
        fetch(url, fetchOptions)
            .catch(processError)
            .then((resp) => {
                if (resp?.status != 200) {
                    // if resp is undefined, it means request was aborted
                    if (resp) processError(resp)
                    return
                }
                resp.json()
                    .catch(processError)
                    .then(data => {
                        // event before
                        let edata = self.trigger('load', {
                            target: self.name,
                            fetchCtrl: this.last.fetchCtrl,
                            fetchOptions: this.last.fetchOptions,
                            data
                        })
                        if (edata.isCancelled === true) return
                        // for backward compatibility
                        if (data.error == null && data.status === 'error') {
                            data.error = true
                        }
                        // if data.record is not present, then assume that entire response is the record
                        if (!data.record) {
                            Object.assign(data, { record: w2utils.clone(data) })
                        }
                        // server response error, not due to network issues
                        if (data.error === true) {
                            self.error(w2utils.lang(data.message ?? this.msgServerError))
                        } else {
                            self.record = w2utils.clone(data.record)
                        }
                        // event after
                        self.unlock()
                        edata.finish()
                        self.refresh()
                        self.setFocus()
                        // call back
                        if (typeof callBack === 'function') callBack(data)
                        resolve(data)
                    })
            })
        // event after
        edata.finish()
        return responseProm
        function processError(response) {
            if (response.name === 'AbortError') {
                // request was aborted by the form
                return
            }
            self.unlock()
            // trigger event
            let edata2 = self.trigger('error', { response, fetchCtrl: self.last.fetchCtrl, fetchOptions: self.last.fetchOptions })
            if (edata2.isCancelled === true) return
            // default behavior
            if (response.status && response.status != 200) {
                self.error(response.status + ': ' + response.statusText)
            } else {
                console.log('ERROR: Server request failed.', response, '. ',
                    'Expected Response:', { error: false, record: { field1: 1, field2: 'item' }},
                    'OR:', { error: true, message: 'Error description' })
                self.error(String(response))
            }
            // event after
            edata2.finish()
            reject(response)
        }
    }
    submit(postData, callBack) {
        return this.save(postData, callBack)
    }
    save(postData, callBack) {
        let self = this
        let resolve, reject
        let saveProm = new Promise((res, rej) => { resolve = res; reject = rej })
        // check for multiple params
        if (typeof postData === 'function') {
            callBack = postData
            postData = null
        }
        // validation
        let errors = self.validate(true)
        if (errors.length !== 0) return
        // submit save
        if (postData == null) postData = {}
        if (!self.url || (typeof self.url === 'object' && !self.url.save)) {
            console.log('ERROR: Form cannot be saved because no url is defined.')
            return
        }
        self.lock(w2utils.lang(self.msgSaving) + ' <span id="'+ self.name +'_progress"></span>')
        // build parameters list
        let params = {}
        // add list params
        params.action = 'save'
        params.recid = self.recid
        params.name = self.name
        // append other params
        w2utils.extend(params, self.postData)
        w2utils.extend(params, postData)
        params.record = w2utils.clone(self.record)
        // event before
        let edata = self.trigger('submit', { target: self.name, url: self.url, httpMethod: this.method ?? 'POST',
            postData: params, httpHeaders: self.httpHeaders })
        if (edata.isCancelled === true) return
        // default action
        let url = edata.detail.url
        if (typeof url === 'object' && url.save) url = url.save
        if (self.last.fetchCtrl) self.last.fetchCtrl.abort()
        // process url with routeData
        if (Object.keys(self.routeData).length > 0) {
            let info = w2utils.parseRoute(url)
            if (info.keys.length > 0) {
                for (let k = 0; k < info.keys.length; k++) {
                    if (self.routeData[info.keys[k].name] == null) continue
                    url = url.replace((new RegExp(':'+ info.keys[k].name, 'g')), self.routeData[info.keys[k].name])
                }
            }
        }
        url = new URL(url, location)
        let fetchOptions = w2utils.prepareParams(url, {
            method: edata.detail.httpMethod,
            headers: edata.detail.httpHeaders,
            body: edata.detail.postData
        }, this.dataType)
        this.last.fetchCtrl = new AbortController()
        fetchOptions.signal = this.last.fetchCtrl.signal
        this.last.fetchOptions = fetchOptions
        fetch(url, fetchOptions)
            .catch(processError)
            .then(resp => {
                self.unlock()
                if (resp?.status != 200) {
                    processError(resp ?? {})
                    return
                }
                // parse server response
                resp.json()
                    .catch(processError)
                    .then(data => {
                        // event before
                        let edata = self.trigger('save', {
                            target: self.name,
                            fetchCtrl: this.last.fetchCtrl,
                            fetchOptions: this.last.fetchOptions,
                            data
                        })
                        if (edata.isCancelled === true) return
                        // server error, not due to network issues
                        if (data.error === true) {
                            self.error(w2utils.lang(data.message ?? this.msgServerError))
                        } else {
                            self.original = null
                        }
                        // event after
                        edata.finish()
                        self.refresh()
                        // call back
                        if (typeof callBack === 'function') callBack(data)
                        resolve(data)
                    })
            })
        // event after
        edata.finish()
        return saveProm
        function processError(response) {
            if (response?.name === 'AbortError') {
                // request was aborted by the form
                return
            }
            self.unlock()
            // trigger event
            let edata2 = self.trigger('error', { response, fetchCtrl: self.last.fetchCtrl, fetchOptions: self.last.fetchOptions })
            if (edata2.isCancelled === true) return
            // default behavior
            if (response.status && response.status != 200) {
                self.error(response.status + ': ' + response.statusText)
            } else {
                console.log('ERROR: Server request failed.', response, '. ',
                    'Expected Response:', { error: false, record: { field1: 1, field2: 'item' }},
                    'OR:', { error: true, message: 'Error description' })
                self.error(String(response))
            }
            // event after
            edata2.finish()
            reject()
        }
    }
    lock(msg, showSpinner) {
        let args = Array.from(arguments)
        args.unshift(this.box)
        w2utils.lock(...args)
    }
    unlock(speed) {
        let box = this.box
        w2utils.unlock(box, speed)
    }
    lockPage(page, msg, spinner) {
        let $page = query(this.box).find('.page-' + page)
        if ($page.length){
            // page found
            w2utils.lock($page, msg, spinner)
            return true
        }
        // page with this id not found!
        return false
    }
    unlockPage(page, speed) {
        let $page = query(this.box).find('.page-' + page)
        if ($page.length) {
            // page found
            w2utils.unlock($page, speed)
            return true
        }
        // page with this id not found!
        return false
    }
    goto(page) {
        if (this.page === page) return // already on this page
        if (page != null) this.page = page
        // if it was auto size, resize it
        if (query(this.box).data('autoSize') === true) {
            query(this.box).get(0).clientHeight = 0
        }
        this.refresh()
    }
    generateHTML() {
        let pages = [] // array for each page
        let group = ''
        let page
        let column
        let html
        let tabindex
        let tabindex_str
        for (let f = 0; f < this.fields.length; f++) {
            html         = ''
            tabindex     = this.tabindexBase + f + 1
            tabindex_str = ' tabindex="'+ tabindex +'"'
            let field    = this.fields[f]
            if (field.html == null) field.html = {}
            if (field.options == null) field.options = {}
            if (field.html.caption != null && field.html.label == null) {
                console.log('NOTICE: form field.html.caption property is deprecated, please use field.html.label. Field ->', field)
                field.html.label = field.html.caption
            }
            if (field.html.label == null) field.html.label = field.field
            field.html = w2utils.extend({ label: '', span: 6, attr: '', text: '', style: '', page: 0, column: 0 }, field.html)
            if (page == null) page = field.html.page
            if (column == null) column = field.html.column
            // input control
            let input = `<input id="${field.field}" name="${field.field}" class="w2ui-input" type="text" ${field.html.attr + tabindex_str}>`
            switch (field.type) {
                case 'pass':
                case 'password':
                    input = input.replace('type="text"', 'type="password"')
                    break
                case 'checkbox': {
                    input = `
                        <label class="w2ui-box-label">
                            <input id="${field.field}" name="${field.field}" class="w2ui-input" type="checkbox" ${field.html.attr + tabindex_str}>
                            <span>${field.html.label}</span>
                        </label>`
                    break
                }
                case 'check':
                case 'checks': {
                    if (field.options.items == null && field.html.items != null) field.options.items = field.html.items
                    let items = field.options.items
                    input = ''
                    // normalized options
                    if (!Array.isArray(items)) items = []
                    if (items.length > 0) {
                        items = w2utils.normMenu.call(this, items, field)
                    }
                    // generate
                    for (let i = 0; i < items.length; i++) {
                        input += `
                            <label class="w2ui-box-label">
                                <input id="${field.field + i}" name="${field.field}" class="w2ui-input" type="checkbox"
                                    ${field.html.attr + tabindex_str} data-value="${items[i].id}" data-index="${i}">
                                <span>&#160;${items[i].text}</span>
                            </label>
                            <br>`
                    }
                    break
                }
                case 'radio': {
                    input = ''
                    // normalized options
                    if (field.options.items == null && field.html.items != null) field.options.items = field.html.items
                    let items = field.options.items
                    if (!Array.isArray(items)) items = []
                    if (items.length > 0) {
                        items = w2utils.normMenu.call(this, items, field)
                    }
                    // generate
                    for (let i = 0; i < items.length; i++) {
                        input += `
                            <label class="w2ui-box-label">
                                <input id="${field.field + i}" name="${field.field}" class="w2ui-input" type="radio"
                                    ${field.html.attr + (i === 0 ? tabindex_str : '')}
                                    data-value="${items[i].id}" data-index="${i}">
                                <span>&#160;${items[i].text}</span>
                            </label>
                            <br>`
                    }
                    break
                }
                case 'select': {
                    input = `<select id="${field.field}" name="${field.field}" class="w2ui-input" ${field.html.attr + tabindex_str}>`
                    // normalized options
                    if (field.options.items == null && field.html.items != null) field.options.items = field.html.items
                    let items = field.options.items
                    if (!Array.isArray(items)) items = []
                    if (items.length > 0) {
                        items = w2utils.normMenu.call(this, items, field)
                    }
                    // generate
                    for (let i = 0; i < items.length; i++) {
                        input += `<option value="${items[i].id}">${items[i].text}</option>`
                    }
                    input += '</select>'
                    break
                }
                case 'textarea':
                    input = `<textarea id="${field.field}" name="${field.field}" class="w2ui-input" ${field.html.attr + tabindex_str}></textarea>`
                    break
                case 'toggle':
                    input = `<input id="${field.field}" name="${field.field}" class="w2ui-input w2ui-toggle" type="checkbox" ${field.html.attr + tabindex_str}>
                            <div><div></div></div>`
                    break
                case 'map':
                case 'array':
                    field.html.key = field.html.key || {}
                    field.html.value = field.html.value || {}
                    field.html.tabindex_str = tabindex_str
                    input = '<span style="float: right">' + (field.html.text || '') + '</span>' +
                            '<input id="'+ field.field +'" name="'+ field.field +'" type="hidden" '+ field.html.attr + tabindex_str + '>'+
                            '<div class="w2ui-map-container"></div>'
                    break
                case 'div':
                case 'custom':
                    input = '<div id="'+ field.field +'" name="'+ field.field +'" '+ field.html.attr + tabindex_str + ' class="w2ui-input">'+
                                (field && field.html && field.html.html ? field.html.html : '') +
                            '</div>'
                    break
                case 'html':
                case 'empty':
                    input = (field && field.html ? (field.html.html || '') + (field.html.text || '') : '')
                    break
            }
            if (group !== '') {
                if (page != field.html.page || column != field.html.column || (field.html.group && (group != field.html.group))) {
                    pages[page][column] += '\n   </div>\n  </div>'
                    group                = ''
                }
            }
            if (field.html.group && (group != field.html.group)) {
                let collapsible = ''
                if (field.html.groupCollapsible) {
                    collapsible = '<span class="w2ui-icon-collapse" style="width: 15px; display: inline-block; position: relative; top: -2px;"></span>'
                }
                html += '\n <div class="w2ui-group">'
                    + '\n   <div class="w2ui-group-title w2ui-eaction" style="'+ (field.html.groupTitleStyle || '') + '; '
                                    + (collapsible != '' ? 'cursor: pointer; user-select: none' : '') + '"'
                    + (collapsible != '' ? 'data-group="' + w2utils.base64encode(field.html.group) + '"' : '')
                    + (collapsible != ''
                        ? 'data-click="toggleGroup|' + field.html.group + '"'
                        : '')
                    + '>'
                    + collapsible + w2utils.lang(field.html.group) + '</div>\n'
                    + '   <div class="w2ui-group-fields" style="'+ (field.html.groupStyle || '') +'">'
                group = field.html.group
            }
            if (field.html.anchor == null) {
                let span = (field.html.span != null ? 'w2ui-span'+ field.html.span : '')
                if (field.html.span == -1) span = 'w2ui-span-none'
                let label = '<label'+ (span == 'none' ? ' style="display: none"' : '') +'>' + w2utils.lang(field.type != 'checkbox' ? field.html.label : field.html.text) +'</label>'
                if (!field.html.label) label = ''
                html += '\n      <div class="w2ui-field '+ span +'" style="'+ (field.hidden ? 'display: none;' : '') + field.html.style +'">'+
                        '\n         '+ label +
                        ((field.type === 'empty') ? input : '\n         <div>'+ input + (field.type != 'array' && field.type != 'map' ? w2utils.lang(field.type != 'checkbox' ? field.html.text : '') : '') + '</div>') +
                        '\n      </div>'
            } else {
                pages[field.html.page].anchors                    = pages[field.html.page].anchors || {}
                pages[field.html.page].anchors[field.html.anchor] = '<div class="w2ui-field w2ui-field-inline" style="'+ (field.hidden ? 'display: none;' : '') + field.html.style +'">'+
                        ((field.type === 'empty') ? input : '<div>'+ w2utils.lang(field.type != 'checkbox' ? field.html.label : field.html.text, true) + input + w2utils.lang(field.type != 'checkbox' ? field.html.text : '') + '</div>') +
                        '</div>'
            }
            if (pages[field.html.page] == null) pages[field.html.page] = {}
            if (pages[field.html.page][field.html.column] == null) pages[field.html.page][field.html.column] = ''
            pages[field.html.page][field.html.column] += html
            page                                       = field.html.page
            column                                     = field.html.column
        }
        if (group !== '') pages[page][column] += '\n   </div>\n  </div>'
        if (this.tabs.tabs) {
            for (let i = 0; i < this.tabs.tabs.length; i++) if (pages[i] == null) pages[i] = []
        }
        // buttons if any
        let buttons = ''
        if (Object.keys(this.actions).length > 0) {
            buttons += '\n<div class="w2ui-buttons">'
            tabindex = this.tabindexBase + this.fields.length + 1
            for (let a in this.actions) { // it is an object
                let act  = this.actions[a]
                let info = { text: '', style: '', 'class': '' }
                if (w2utils.isPlainObject(act)) {
                    if (act.text == null && act.caption != null) {
                        console.log('NOTICE: form action.caption property is deprecated, please use action.text. Action ->', act)
                        act.text = act.caption
                    }
                    if (act.text) info.text = act.text
                    if (act.style) info.style = act.style
                    if (act.class) info.class = act.class
                } else {
                    info.text = a
                    if (['save', 'update', 'create'].indexOf(a.toLowerCase()) !== -1) info.class = 'w2ui-btn-blue'; else info.class = ''
                }
                buttons += '\n    <button name="'+ a +'" class="w2ui-btn '+ info.class +'" style="'+ info.style +'" tabindex="'+ tabindex +'">'+
                                        w2utils.lang(info.text) +'</button>'
                tabindex++
            }
            buttons += '\n</div>'
        }
        html = ''
        for (let p = 0; p < pages.length; p++){
            html += '<div class="w2ui-page page-'+ p +'" style="' + (p !== 0 ? 'display: none;' : '') + this.pageStyle + '">'
            if (!pages[p]) {
                console.log(`ERROR: Page ${p} does not exist`)
                return false
            }
            if (pages[p].before) {
                html += pages[p].before
            }
            html += '<div class="w2ui-column-container">'
            Object.keys(pages[p]).sort().forEach((c, ind) => {
                if (c == parseInt(c)) {
                    html += '<div class="w2ui-column col-'+ c +'">' + (pages[p][c] || '') + '\n</div>'
                }
            })
            html += '\n</div>'
            if (pages[p].after) {
                html += pages[p].after
            }
            html += '\n</div>'
            // process page anchors
            if (pages[p].anchors) {
                Object.keys(pages[p].anchors).forEach((key, ind) => {
                    html = html.replace(key, pages[p].anchors[key])
                })
            }
        }
        html += buttons
        return html
    }
    toggleGroup(groupName, show) {
        let el = query(this.box).find('.w2ui-group-title[data-group="' + w2utils.base64encode(groupName) + '"]')
        if (el.length === 0) return
        let el_next = query(el.prop('nextElementSibling'))
        if (typeof show === 'undefined') {
            show = (el_next.css('display') == 'none')
        }
        if (show) {
            el_next.show()
            el.find('span').addClass('w2ui-icon-collapse').removeClass('w2ui-icon-expand')
        } else {
            el_next.hide()
            el.find('span').addClass('w2ui-icon-expand').removeClass('w2ui-icon-collapse')
        }
    }
    action(action, event) {
        let act   = this.actions[action]
        let click = act
        if (w2utils.isPlainObject(act) && act.onClick) click = act.onClick
        // event before
        let edata = this.trigger('action', { target: action, action: act, originalEvent: event })
        if (edata.isCancelled === true) return
        // default actions
        if (typeof click === 'function') click.call(this, event)
        // event after
        edata.finish()
    }
    resize() {
        let self = this
        // event before
        let edata = this.trigger('resize', { target: this.name })
        if (edata.isCancelled === true) return
        // default behaviour
        let header  = query(this.box).find(':scope > div .w2ui-form-header')
        let toolbar = query(this.box).find(':scope > div .w2ui-form-toolbar')
        let tabs    = query(this.box).find(':scope > div .w2ui-form-tabs')
        let page    = query(this.box).find(':scope > div .w2ui-page')
        let dpage   = query(this.box).find(':scope > div .w2ui-page.page-'+ this.page + ' > div')
        let buttons = query(this.box).find(':scope > div .w2ui-buttons')
        // if no height, calculate it
        let { headerHeight, tbHeight, tabsHeight } = resizeElements()
        if (this.autosize) { // we don't need autosize every time
            let cHeight = query(this.box).get(0).clientHeight
            if (cHeight === 0 || query(this.box).data('autosize') == 'yes') {
                query(this.box).css({
                    height: headerHeight + tbHeight + tabsHeight + 15 // 15 is extra height
                        + (page.length > 0 ? w2utils.getSize(dpage, 'height') : 0)
                        + (buttons.length > 0 ? w2utils.getSize(buttons, 'height') : 0)
                        + 'px'
                })
                query(this.box).data('autosize', 'yes')
            }
            resizeElements()
        }
        // event after
        edata.finish()
        function resizeElements() {
            let headerHeight = (self.header !== '' ? w2utils.getSize(header, 'height') : 0)
            let tbHeight = (Array.isArray(self.toolbar?.items) && self.toolbar?.items?.length > 0)
                ? w2utils.getSize(toolbar, 'height')
                : 0
            let tabsHeight = (Array.isArray(self.tabs?.tabs) && self.tabs?.tabs?.length > 0)
                ? w2utils.getSize(tabs, 'height')
                : 0
            // resize elements
            toolbar.css({ top: headerHeight + 'px' })
            tabs.css({ top: headerHeight + tbHeight + 'px' })
            page.css({
                top: headerHeight + tbHeight + tabsHeight + 'px',
                bottom: (buttons.length > 0 ? w2utils.getSize(buttons, 'height') : 0) + 'px'
            })
            // return some params
            return { headerHeight, tbHeight, tabsHeight }
        }
    }
    refresh() {
        let time = Date.now()
        let self = this
        if (!this.box) return
        if (!this.isGenerated || !query(this.box).html()) return
        // event before
        let edata = this.trigger('refresh', { target: this.name, page: this.page, field: arguments[0], fields: arguments })
        if (edata.isCancelled === true) return
        let fields = Array.from(this.fields.keys())
        if (arguments.length > 0) {
            fields = Array.from(arguments)
                .map((fld, ind) => {
                    if (typeof fld != 'string') console.log('ERROR: Arguments in refresh functions should be field names')
                    return this.get(fld, true) // get index of field
                })
                .filter((fld, ind) => {
                    if (fld != null) return true; else return false
                })
        } else {
            // update field.page with page it belongs too
            query(this.box).find('input, textarea, select').each(el => {
                let name = (query(el).attr('name') != null ? query(el).attr('name') : query(el).attr('id'))
                let field = this.get(name)
                if (field) {
                    // find page
                    let div = query(el).closest('.w2ui-page')
                    if (div.length > 0) {
                        for (let i = 0; i < 100; i++) {
                            if (div.hasClass('page-'+i)) { field.page = i; break }
                        }
                    }
                }
            })
            // default action
            query(this.box).find('.w2ui-page').hide()
            query(this.box).find('.w2ui-page.page-' + this.page).show()
            query(this.box).find('.w2ui-form-header').html(w2utils.lang(this.header))
            // refresh tabs if needed
            if (typeof this.tabs === 'object' && Array.isArray(this.tabs.tabs) && this.tabs.tabs.length > 0) {
                query(this.box).find('#form_'+ this.name +'_tabs').show()
                this.tabs.active = this.tabs.tabs[this.page].id
                this.tabs.refresh()
            } else {
                query(this.box).find('#form_'+ this.name +'_tabs').hide()
            }
            // refresh tabs if needed
            if (typeof this.toolbar === 'object' && Array.isArray(this.toolbar.items) && this.toolbar.items.length > 0) {
                query(this.box).find('#form_'+ this.name +'_toolbar').show()
                this.toolbar.refresh()
            } else {
                query(this.box).find('#form_'+ this.name +'_toolbar').hide()
            }
        }
        // refresh values of fields
        for (let f = 0; f < fields.length; f++) {
            let field = this.fields[fields[f]]
            if (field.name == null && field.field != null) field.name = field.field
            if (field.field == null && field.name != null) field.field = field.name
            field.$el = query(this.box).find(`[name='${String(field.name).replace(/\\/g, '\\\\')}']`)
            field.el  = field.$el.get(0)
            if (field.el) field.el.id = field.name
            // TODO: check
            if (field.w2field) {
                field.w2field.reset()
            }
            field.$el
                .off('.w2form')
                .on('change.w2form', function(event) {
                    let value = self.getFieldValue(field.field)
                    // clear error class
                    if (['enum', 'file'].includes(field.type)) {
                        let helper = field.el._w2field?.helpers?.multi
                        query(helper).removeClass('w2ui-error')
                    }
                    if (this._previous != null) {
                        value.previous = this._previous
                        delete this._previous
                    }
                    // event before
                    let edata2 = self.trigger('change', { target: this.name, field: this.name, value, originalEvent: event })
                    if (edata2.isCancelled === true) return
                    // default behavior
                    self.setValue(this.name, value.current)
                    // event after
                    edata2.finish()
                })
                .on('input.w2form', function(event) {
                    // remember original
                    if (self.original == null) {
                        if (Object.keys(self.record).length > 0) {
                            self.original = w2utils.clone(self.record)
                        } else {
                            self.original = {}
                        }
                    }
                    let value = self.getFieldValue(field.field)
                    // save previous for change event
                    if (this._previous == null) {
                        this._previous = value.previous
                    }
                    // event before
                    let edata2 = self.trigger('input', { target: self.name, value, originalEvent: event })
                    if (edata2.isCancelled === true) return
                    // default action
                    self.setValue(this.name, value.current)
                    // event after
                    edata2.finish()
                })
            // required
            if (field.required) {
                field.$el.closest('.w2ui-field').addClass('w2ui-required')
            } else {
                field.$el.closest('.w2ui-field').removeClass('w2ui-required')
            }
            // disabled
            if (field.disabled != null) {
                if (field.disabled) {
                    if (field.$el.data('tabIndex') == null) {
                        field.$el.data('tabIndex', field.$el.prop('tabIndex'))
                    }
                    field.$el
                        .prop('readOnly', true)
                        .prop('disabled', true)
                        .prop('tabIndex', -1)
                        .closest('.w2ui-field')
                        .addClass('w2ui-disabled')
                } else {
                    field.$el
                        .prop('readOnly', false)
                        .prop('disabled', false)
                        .prop('tabIndex', field.$el.data('tabIndex') ?? field.$el.prop('tabIndex') ?? 0)
                        .closest('.w2ui-field')
                        .removeClass('w2ui-disabled')
                }
            }
            // hidden
            let tmp = field.el
            if (!tmp) tmp = query(this.box).find('#' + field.field)
            if (field.hidden) {
                query(tmp).closest('.w2ui-field').hide()
            } else {
                query(tmp).closest('.w2ui-field').show()
            }
        }
        // attach actions on buttons
        query(this.box).find('button, input[type=button]').each(el => {
            query(el).off('click').on('click', function(event) {
                let action = this.value
                if (this.id) action = this.id
                if (this.name) action = this.name
                self.action(action, event)
            })
        })
        // init controls with record
        for (let f = 0; f < fields.length; f++) {
            let field = this.fields[fields[f]]
            if (!field.el) continue
            if (!field.$el.hasClass('w2ui-input')) field.$el.addClass('w2ui-input')
            field.type = String(field.type).toLowerCase()
            if (!field.options) field.options = {}
            // list type
            if (this.LIST_TYPES.includes(field.type)) {
                let items = field.options.items
                if (items == null) field.options.items = []
                field.options.items = w2utils.normMenu.call(this, items, field)
            }
            // HTML select
            if (field.type == 'select') {
                // generate options
                let items = field.options.items
                let options = ''
                items.forEach(item => {
                    options += `<option value="${item.id}">${item.text}</option>`
                })
                field.$el.html(options)
            }
            // w2fields
            if (this.W2FIELD_TYPES.includes(field.type)) {
                field.w2field = field.w2field
                    ?? new w2field(w2utils.extend({}, field.options, { type: field.type }))
                field.w2field.render(field.el)
            }
            // map and arrays
            if (['map', 'array'].includes(field.type)) {
                // need closure
                (function (obj, field) {
                    let keepFocus
                    field.el.mapAdd = function(field, div, cnt) {
                        let attr = (field.disabled ? ' readOnly ' : '') + (field.html.tabindex_str || '')
                        let html = `
                            <div class="w2ui-map-field" style="margin-bottom: 5px" data-index="${cnt}">
                            ${field.type == 'map'
                                ? `<input type="text" ${field.html.key.attr + attr} class="w2ui-input w2ui-map key">
                                    ${field.html.key.text || ''}
                                `
                                : ''
                            }
                            <input type="text" ${field.html.value.attr + attr} class="w2ui-input w2ui-map value">
                                ${field.html.value.text || ''}
                            </div>`
                        div.append(html)
                    }
                    field.el.mapRefresh = function(map, div) {
                        // generate options
                        let keys, $k, $v
                        if (field.type == 'map') {
                            if (!w2utils.isPlainObject(map)) map = {}
                            if (map._order == null) map._order = Object.keys(map)
                            keys = map._order
                        }
                        if (field.type == 'array') {
                            if (!Array.isArray(map)) map = []
                            keys = map.map((item, ind) => { return ind })
                        }
                        // delete extra fields (including empty one)
                        let all = div.find('.w2ui-map-field')
                        for (let i = all.length-1; i >= keys.length; i--) {
                            div.find(`div[data-index='${i}']`).remove()
                        }
                        for (let ind = 0; ind < keys.length; ind++) {
                            let key = keys[ind]
                            let fld = div.find(`div[data-index='${ind}']`)
                            // add if does not exists
                            if (fld.length == 0) {
                                field.el.mapAdd(field, div, ind)
                                fld = div.find(`div[data-index='${ind}']`)
                            }
                            fld.attr('data-key', key)
                            $k = fld.find('.w2ui-map.key')
                            $v = fld.find('.w2ui-map.value')
                            let val = map[key]
                            if (field.type == 'array') {
                                let tmp = map.filter((it) => { return it.key == key ? true : false})
                                if (tmp.length > 0) val = tmp[0].value
                            }
                            $k.val(key)
                            $v.val(val)
                            if (field.disabled === true || field.disabled === false) {
                                $k.prop('readOnly', field.disabled ? true : false)
                                $v.prop('readOnly', field.disabled ? true : false)
                            }
                        }
                        let cnt = keys.length
                        let curr = div.find(`div[data-index='${cnt}']`)
                        // if not disabled - add next if needed
                        if (curr.length === 0 && (!$k || $k.val() != '' || $v.val() != '')
                            && !($k && ($k.prop('readOnly') === true || $k.prop('disabled') === true))
                        ) {
                            field.el.mapAdd(field, div, cnt)
                        }
                        if (field.disabled === true || field.disabled === false) {
                            curr.find('.key').prop('readOnly', field.disabled ? true : false)
                            curr.find('.value').prop('readOnly', field.disabled ? true : false)
                        }
                        // attach events
                        let container = query(field.el).get(0)?.nextSibling // should be div
                        query(container).find('input.w2ui-map')
                            .off('.mapChange')
                            .on('keyup.mapChange', function(event) {
                                let $div = query(event.target).closest('.w2ui-map-field')
                                let next = $div.get(0).nextElementSibling
                                let prev = $div.get(0).previousElementSibling
                                if (event.keyCode == 13) {
                                    let el = keepFocus ?? next
                                    if (el instanceof HTMLElement) {
                                        let inp = query(el).find('input')
                                        if (inp.length > 0) {
                                            inp.get(0).focus()
                                        }
                                    }
                                    keepFocus = undefined
                                }
                                let className = query(event.target).hasClass('key') ? 'key' : 'value'
                                if (event.keyCode == 38 && prev) { // up key
                                    query(prev).find(`input.${className}`).get(0).select()
                                    event.preventDefault()
                                }
                                if (event.keyCode == 40 && next) { // down key
                                    query(next).find(`input.${className}`).get(0).select()
                                    event.preventDefault()
                                }
                            })
                            .on('keydown.mapChange', function(event) {
                                if (event.keyCode == 38 || event.keyCode == 40) {
                                    event.preventDefault()
                                }
                            })
                            .on('input.mapChange', function(event) {
                                let fld = query(event.target).closest('div')
                                let cnt = fld.data('index')
                                let next = fld.get(0).nextElementSibling
                                // if last one, add new empty
                                if (fld.find('input').val() != '' && !next) {
                                    field.el.mapAdd(field, div, parseInt(cnt) + 1)
                                } else if (fld.find('input').val() == '' && next) {
                                    let isEmpty = true
                                    query(next).find('input').each(el => {
                                        if (el.value != '') isEmpty = false
                                    })
                                    if (isEmpty) {
                                        query(next).remove()
                                    }
                                }
                            })
                            .on('change.mapChange', function(event) {
                                // remember original
                                if (self.original == null) {
                                    if (Object.keys(self.record).length > 0) {
                                        self.original = w2utils.clone(self.record)
                                    } else {
                                        self.original = {}
                                    }
                                }
                                // event before
                                let { current, previous, original } = self.getFieldValue(field.field)
                                let $cnt = query(event.target).closest('.w2ui-map-container')
                                if (field.type == 'map') current._order = []
                                $cnt.find('.w2ui-map.key').each(el => { current._order.push(el.value) })
                                let edata = self.trigger('change', { target: field.field, field: field.field, originalEvent: event,
                                    value: { current, previous, original }
                                })
                                if (edata.isCancelled === true) {
                                    return
                                }
                                // delete empty
                                if (field.type == 'map') {
                                    current._order = current._order.filter(k => k !== '')
                                    delete current['']
                                }
                                if (field.type == 'array') {
                                    current = current.filter(k => k !== '')
                                }
                                if (query(event.target).parent().find('input').val() == '') {
                                    keepFocus = event.target
                                }
                                self.setValue(field.field, current)
                                field.el.mapRefresh(current, div)
                                // event after
                                edata.finish()
                            })
                    }
                })(this, field)
            }
            // set value to HTML input field
            this.setFieldValue(field.field, this.getValue(field.name))
        }
        // event after
        edata.finish()
        this.resize()
        return Date.now() - time
    }
    render(box) {
        let time = Date.now()
        let self = this
        if (typeof box == 'string') box = query(box).get(0)
        // event before
        let edata = this.trigger('render', { target: this.name, box: box ?? this.box })
        if (edata.isCancelled === true) return
        // default action
        if (box != null) {
            // clean previous box
            if (query(this.box).find('#form_'+ this.name +'_form').length > 0) {
                query(this.box).removeAttr('name')
                    .removeClass('w2ui-reset w2ui-form')
                    .html('')
            }
            this.box = box
        }
        if (!this.isGenerated && !this.formHTML) return
        if (!this.box) return
        // render form
        let html = '<div class="w2ui-form-box">' +
                    (this.header !== '' ? '<div class="w2ui-form-header">' + w2utils.lang(this.header) + '</div>' : '') +
                    '    <div id="form_'+ this.name +'_toolbar" class="w2ui-form-toolbar" style="display: none"></div>' +
                    '    <div id="form_'+ this.name +'_tabs" class="w2ui-form-tabs" style="display: none"></div>' +
                        this.formHTML +
                    '</div>'
        query(this.box).attr('name', this.name)
            .addClass('w2ui-reset w2ui-form')
            .html(html)
        if (query(this.box).length > 0) query(this.box)[0].style.cssText += this.style
        w2utils.bindEvents(query(this.box).find('.w2ui-eaction'), this)
        // init toolbar regardless it is defined or not
        if (typeof this.toolbar.render !== 'function') {
            this.toolbar = new w2toolbar(w2utils.extend({}, this.toolbar, { name: this.name +'_toolbar', owner: this }))
            this.toolbar.on('click', function(event) {
                let edata = self.trigger('toolbar', { target: event.target, originalEvent: event })
                if (edata.isCancelled === true) return
                // no default action
                edata.finish()
            })
        }
        if (typeof this.toolbar === 'object' && typeof this.toolbar.render === 'function') {
            this.toolbar.render(query(this.box).find('#form_'+ this.name +'_toolbar')[0])
        }
        // init tabs regardless it is defined or not
        if (typeof this.tabs.render !== 'function') {
            this.tabs = new w2tabs(w2utils.extend({}, this.tabs, { name: this.name +'_tabs', owner: this, active: this.tabs.active }))
            this.tabs.on('click', function(event) {
                self.goto(this.get(event.target, true))
            })
        }
        if (typeof this.tabs === 'object' && typeof this.tabs.render === 'function') {
            this.tabs.render(query(this.box).find('#form_'+ this.name +'_tabs')[0])
            if (this.tabs.active) this.tabs.click(this.tabs.active)
        }
        // event after
        edata.finish()
        // after render actions
        this.resize()
        let url = (typeof this.url !== 'object' ? this.url : this.url.get)
        if (url && this.recid != null) {
            this.request().catch(error => this.refresh()) // even if there was error, still need refresh
        } else {
            this.refresh()
        }
        // observe div resize
        this.last.observeResize = new ResizeObserver(() => { this.resize() })
        this.last.observeResize.observe(this.box)
        // focus on load
        if (this.focus != -1) {
            let setCount = 0
            let setFocus = () => {
                if (query(self.box).find('input, select, textarea').length > 0) {
                    self.setFocus()
                } else {
                    setCount++
                    if (setCount < 20) setTimeout(setFocus, 50) // 1 sec max
                }
            }
            setFocus()
        }
        return Date.now() - time
    }
    setFocus(focus) {
        if (typeof focus === 'undefined'){
            // no argument - use form's focus property
            focus = this.focus
        }
        let $input
        // focus field by index
        if (w2utils.isInt(focus)){
            if (focus < 0) {
                return
            }
            let inputs = query(this.box)
                .find('div:not(.w2ui-field-helper) > input, select, textarea, div > label:nth-child(1) > [type=radio]')
                .filter(':not(.file-input)')
            // find visible (offsetParent == null for any element is not visible)
            while (inputs[focus].offsetParent == null && inputs.length >= focus) {
                focus++
            }
            if (inputs[focus]) {
                $input = query(inputs[focus])
            }
        } else if (typeof focus === 'string') {
            // focus field by name
            $input = query(this.box).find(`[name='${focus}']`)
        }
        if ($input.length > 0){
            $input.get(0).focus()
        }
        return $input
    }
    destroy() {
        // event before
        let edata = this.trigger('destroy', { target: this.name })
        if (edata.isCancelled === true) return
        // clean up
        if (typeof this.toolbar === 'object' && this.toolbar.destroy) this.toolbar.destroy()
        if (typeof this.tabs === 'object' && this.tabs.destroy) this.tabs.destroy()
        if (query(this.box).find('#form_'+ this.name +'_tabs').length > 0) {
            query(this.box)
                .removeAttr('name')
                .removeClass('w2ui-reset w2ui-form')
                .html('')
        }
        this.last.observeResize?.disconnect()
        delete w2ui[this.name]
        // event after
        edata.finish()
    }
}
/**
 * Part of w2ui 2.0 library
 *  - Dependencies: mQuery, w2utils, w2base, w2tooltip, w2color, w2menu, w2date
 *
 * == TODO ==
 *  - upload (regular files)
 *  - BUG with prefix/postfix and arrows (test in different contexts)
 *  - multiple date selection
 *  - month selection, year selections
 *  - MultiSelect - Allow Copy/Paste for single and multi values
 *  - add routeData to list/enum
 *  - ENUM, LIST: should have same as grid (limit, offset, search, sort)
 *  - ENUM, LIST: should support wild chars
 *  - add selection of predefined times (used for appointments)
 *  - options.items - can be an array
 *  - options.msgNoItems - can be a function
 *  - REMOTE fields
 *
 * == 2.0 changes
 *  - removed jQuery dependency
 *  - enum options.autoAdd
 *  - [numeric, date] - options.autoCorrect to enforce range and validity
 *  - silent only left for files, removed form the rest
 *  - remote source response items => records or just an array
 *  - deprecated "success" field for remote source response
 *  - CSP - fixed inline events
 *  - remove clear, use reset instead
 *  - options.msgSearch
 *  - options.msgNoItems
 */

class w2field extends w2base {
    constructor(type, options) {
        super()
        // sanitization
        if (typeof type == 'string' && options == null) {
            options = { type: type }
        }
        if (typeof type == 'object' && options == null) {
            options = w2utils.clone(type)
        }
        if (typeof type == 'string' && typeof options == 'object') {
            options.type = type
        }
        options.type = String(options.type).toLowerCase()
        this.el          = options.el ?? null
        this.selected    = null
        this.helpers     = {} // object or helper elements
        this.type        = options.type ?? 'text'
        this.options     = w2utils.clone(options)
        this.onClick     = options.onClick ?? null
        this.onAdd       = options.onAdd ?? null
        this.onNew       = options.onNew ?? null
        this.onRemove    = options.onRemove ?? null
        this.onMouseEnter= options.onMouseEnter ?? null
        this.onMouseLeave= options.onMouseLeave ?? null
        this.onScroll    = options.onScroll ?? null
        this.tmp         = {} // temp object
        // clean up some options
        delete this.options.type
        delete this.options.onClick
        delete this.options.onMouseEnter
        delete this.options.onMouseLeave
        delete this.options.onScroll
        if (this.el) {
            this.render(this.el)
        }
    }
    render(el) {
        if (!(el instanceof HTMLElement)) {
            console.log('ERROR: Cannot init w2field on empty subject')
            return
        }
        if (el._w2field) {
            el._w2field.reset()
        } else {
            el._w2field = this
        }
        this.el = el
        this.init()
    }
    init() {
        let options = this.options
        let defaults
        // only for INPUT or TEXTAREA
        if (!['INPUT', 'TEXTAREA'].includes(this.el.tagName.toUpperCase())) {
            console.log('ERROR: w2field could only be applied to INPUT or TEXTAREA.', this.el)
            return
        }
        switch (this.type) {
            case 'text':
            case 'int':
            case 'float':
            case 'money':
            case 'currency':
            case 'percent':
            case 'alphanumeric':
            case 'bin':
            case 'hex':
                defaults = {
                    min: null,
                    max: null,
                    step: 1,
                    autoFormat: true,
                    autoCorrect: true,
                    currencyPrefix: w2utils.settings.currencyPrefix,
                    currencySuffix: w2utils.settings.currencySuffix,
                    currencyPrecision: w2utils.settings.currencyPrecision,
                    decimalSymbol: w2utils.settings.decimalSymbol,
                    groupSymbol: w2utils.settings.groupSymbol,
                    arrow: false,
                    keyboard: true,
                    precision: null,
                    prefix: '',
                    suffix: ''
                }
                this.options = w2utils.extend({}, defaults, options)
                options = this.options // since object is re-created, need to re-assign
                options.numberRE  = new RegExp('['+ options.groupSymbol + ']', 'g')
                options.moneyRE   = new RegExp('['+ options.currencyPrefix + options.currencySuffix + options.groupSymbol +']', 'g')
                options.percentRE = new RegExp('['+ options.groupSymbol + '%]', 'g')
                // no keyboard support needed
                if (['text', 'alphanumeric', 'hex', 'bin'].includes(this.type)) {
                    options.arrow   = false
                    options.keyboard = false
                }
                break
            case 'color':
                defaults     = {
                    prefix      : '#',
                    suffix      : `<div style="width: ${(parseInt(getComputedStyle(this.el)['font-size'])) || 12}px">&#160;</div>`,
                    arrow       : false,
                    advanced    : null, // open advanced by default
                    transparent : true
                }
                this.options = w2utils.extend({}, defaults, options)
                options = this.options // since object is re-created, need to re-assign
                break
            case 'date':
                defaults = {
                    format        : w2utils.settings.dateFormat, // date format
                    keyboard      : true,
                    autoCorrect   : true,
                    start         : null,
                    end           : null,
                    blockDates    : [], // array of blocked dates
                    blockWeekdays : [], // blocked weekdays 0 - sunday, 1 - monday, etc
                    colored       : {}, // ex: { '3/13/2022': 'bg-color|text-color' }
                    btnNow        : true
                }
                this.options = w2utils.extend({ type: 'date' }, defaults, options)
                options = this.options // since object is re-created, need to re-assign
                if (query(this.el).attr('placeholder') == null) {
                    query(this.el).attr('placeholder', options.format)
                }
                break
            case 'time':
                defaults     = {
                    format      : w2utils.settings.timeFormat,
                    keyboard    : true,
                    autoCorrect : true,
                    start       : null,
                    end         : null,
                    btnNow      : true,
                    noMinutes   : false
                }
                this.options = w2utils.extend({ type: 'time' }, defaults, options)
                options = this.options // since object is re-created, need to re-assign
                if (query(this.el).attr('placeholder') == null) {
                    query(this.el).attr('placeholder', options.format)
                }
                break
            case 'datetime':
                defaults     = {
                    format        : w2utils.settings.dateFormat + '|' + w2utils.settings.timeFormat,
                    keyboard      : true,
                    autoCorrect   : true,
                    start         : null,
                    end           : null,
                    startTime     : null,
                    endTime       : null,
                    blockDates    : [], // array of blocked dates
                    blockWeekdays : [], // blocked weekdays 0 - sunday, 1 - monday, etc
                    colored       : {}, // ex: { '3/13/2022': 'bg-color|text-color' }
                    btnNow        : true,
                    noMinutes     : false
                }
                this.options = w2utils.extend({ type: 'datetime' }, defaults, options)
                options = this.options // since object is re-created, need to re-assign
                if (query(this.el).attr('placeholder') == null) {
                    query(this.el).attr('placeholder', options.placeholder || options.format)
                }
                break
            case 'list':
            case 'combo':
                defaults = {
                    items           : [],
                    selected        : {},
                    url             : null,   // url to pull data from // TODO: implement
                    recId           : null,   // map retrieved data from url to id, can be string or function
                    recText         : null,   // map retrieved data from url to text, can be string or function
                    method          : null,   // default httpMethod
                    debounce        : 250,    // number of ms to wait before sending server call on search
                    postData        : {},
                    minLength       : 1,      // min number of chars when trigger search
                    cacheMax        : 250,
                    maxDropHeight   : 350,    // max height for drop down menu
                    maxDropWidth    : null,   // if null then auto set
                    minDropWidth    : null,   // if null then auto set
                    match           : 'begins', // ['contains', 'is', 'begins', 'ends']
                    icon            : null,
                    iconStyle       : '',
                    align           : 'both', // same width as control
                    altRows         : true,   // alternate row color
                    renderDrop      : null,   // render function for drop down item
                    compare         : null,   // compare function for filtering
                    filter          : true,   // weather to filter at all
                    hideSelected    : false,  // hide selected item from drop down
                    prefix          : '',
                    suffix          : '',
                    msgNoItems      : 'No matches',
                    msgSearch       : 'Type to search...',
                    openOnFocus     : false,  // if to show overlay onclick or when typing
                    markSearch      : false,
                    onSearch        : null,   // when search needs to be performed
                    onRequest       : null,   // when request is submitted
                    onLoad          : null,   // when data is received
                    onError         : null    // when data fails to load due to server error or other failure modes
                }
                if (typeof options.items == 'function') {
                    options._items_fun = options.items
                }
                // need to be first
                options.items = w2utils.normMenu.call(this, options.items)
                if (this.type === 'list') {
                    // defaults.search = (options.items && options.items.length >= 10 ? true : false);
                    query(this.el).addClass('w2ui-select')
                    // if simple value - look it up
                    if (!w2utils.isPlainObject(options.selected) && Array.isArray(options.items)) {
                        options.items.forEach(item => {
                            if (item && item.id === options.selected) {
                                options.selected = w2utils.clone(item)
                            }
                        })
                    }
                }
                options = w2utils.extend({}, defaults, options)
                this.options = options
                if (!w2utils.isPlainObject(options.selected)) options.selected = {}
                this.selected = options.selected
                query(this.el)
                    .attr('autocapitalize', 'off')
                    .attr('autocomplete', 'off')
                    .attr('autocorrect', 'off')
                    .attr('spellcheck', 'false')
                if (options.selected.text != null) {
                    query(this.el).val(options.selected.text)
                }
                break
            case 'enum':
                defaults = {
                    items           : [],    // id, text, tooltip, icon
                    selected        : [],
                    max             : 0,     // max number of selected items, 0 - unlimited
                    url             : null,  // not implemented
                    recId           : null,  // map retrieved data from url to id, can be string or function
                    recText         : null,  // map retrieved data from url to text, can be string or function
                    debounce        : 250,   // number of ms to wait before sending server call on search
                    method          : null,  // default httpMethod
                    postData        : {},
                    minLength       : 1,     // min number of chars when trigger search
                    cacheMax        : 250,
                    maxItemWidth    : 250,   // max width for a single item
                    maxDropHeight   : 350,   // max height for drop down menu
                    maxDropWidth    : null,  // if null then auto set
                    match           : 'contains', // ['contains', 'is', 'begins', 'ends']
                    align           : '',    // align drop down related to search field
                    altRows         : true,  // alternate row color
                    openOnFocus     : false, // if to show overlay onclick or when typing
                    markSearch      : false,
                    renderDrop      : null,  // render function for drop down item
                    renderItem      : null,  // render selected item
                    compare         : null,  // compare function for filtering
                    filter          : true,  // alias for compare
                    hideSelected    : true,  // hide selected item from drop down
                    style           : '',    // style for container div
                    msgNoItems      : 'No matches',
                    msgSearch       : 'Type to search...',
                    onSearch        : null,  // when search needs to be performed
                    onRequest       : null,  // when request is submitted
                    onLoad          : null,  // when data is received
                    onError         : null,  // when data fails to load due to server error or other failure modes
                    onClick         : null,  // when an item is clicked
                    onAdd           : null,  // when an item is added
                    onNew           : null,  // when new item should be added
                    onRemove        : null,  // when an item is removed
                    onMouseEnter    : null,  // when an item is mouse over
                    onMouseLeave    : null,  // when an item is mouse out
                    onScroll        : null   // when div with selected items is scrolled
                }
                options  = w2utils.extend({}, defaults, options, { suffix: '' })
                if (typeof options.items == 'function') {
                    options._items_fun = options.items
                }
                options.items    = w2utils.normMenu.call(this, options.items)
                options.selected = w2utils.normMenu.call(this, options.selected)
                this.options     = options
                if (!Array.isArray(options.selected)) options.selected = []
                this.selected = options.selected
                break
            case 'file':
                defaults     = {
                    selected      : [],
                    max           : 0,
                    maxSize       : 0,    // max size of all files, 0 - unlimited
                    maxFileSize   : 0,    // max size of a single file, 0 -unlimited
                    maxItemWidth  : 250,  // max width for a single item
                    maxDropHeight : 350,  // max height for drop down menu
                    maxDropWidth  : null, // if null then auto set
                    readContent   : true, // if true, it will readAsDataURL content of the file
                    silent        : true,
                    align         : 'both', // same width as control
                    altRows       : true, // alternate row color
                    renderItem    : null, // render selected item
                    style         : '',   // style for container div
                    onClick       : null, // when an item is clicked
                    onAdd         : null, // when an item is added
                    onRemove      : null, // when an item is removed
                    onMouseEnter  : null, // when an item is mouse over
                    onMouseLeave  : null  // when an item is mouse out
                }
                options = w2utils.extend({}, defaults, options)
                this.options = options
                if (!Array.isArray(options.selected)) options.selected = []
                this.selected = options.selected
                if (query(this.el).attr('placeholder') == null) {
                    query(this.el).attr('placeholder', w2utils.lang('Attach files by dragging and dropping or Click to Select'))
                }
                break
        }
        // attach events
        query(this.el)
            .css('box-sizing', 'border-box')
            .addClass('w2field w2ui-input')
            .off('.w2field')
            .on('change.w2field', (event) => { this.change(event) })
            .on('click.w2field', (event) => { this.click(event) })
            .on('focus.w2field', (event) => { this.focus(event) })
            .on('blur.w2field', (event) => { if (this.type !== 'list') this.blur(event) })
            .on('keydown.w2field', (event) => { this.keyDown(event) })
            .on('keyup.w2field', (event) => { this.keyUp(event) })
        // suffix and prefix need to be after styles
        this.addPrefix() // only will add if needed
        this.addSuffix() // only will add if needed
        this.addSearch()
        this.addMultiSearch()
        // this.refresh() // do not call refresh, on change will trigger refresh (for list at list)
        // format initial value
        this.change(new Event('change'))
    }
    get() {
        let ret
        if (['list', 'enum', 'file'].indexOf(this.type) !== -1) {
            ret = this.selected
        } else {
            ret = query(this.el).val()
        }
        return ret
    }
    set(val, append) {
        if (['list', 'enum', 'file'].indexOf(this.type) !== -1) {
            if (this.type !== 'list' && append) {
                if (!Array.isArray(this.selected)) this.selected = []
                this.selected.push(val)
                // update selected array in overlay
                let overlay = w2menu.get(this.el.id + '_menu')
                if (overlay) overlay.options.selected = this.selected
                query(this.el).trigger('input').trigger('change')
            } else {
                if (val == null) val = []
                let it = (this.type === 'enum' && !Array.isArray(val) ? [val] : val)
                this.selected = it
                query(this.el).trigger('input').trigger('change')
            }
            this.refresh()
        } else {
            query(this.el).val(val)
        }
    }
    setIndex(ind, append) {
        if (['list', 'enum'].indexOf(this.type) !== -1) {
            let items = this.options.items
            if (items && items[ind]) {
                if (this.type == 'list') {
                    this.selected = items[ind]
                }
                if (this.type == 'enum') {
                    if (!append) this.selected = []
                    this.selected.push(items[ind])
                }
                let overlay = w2menu.get(this.el.id + '_menu')
                if (overlay) overlay.options.selected = this.selected
                query(this.el).trigger('input').trigger('change')
                this.refresh()
                return true
            }
        }
        return false
    }
    refresh() {
        let options = this.options
        let time    = Date.now()
        let styles  = getComputedStyle(this.el)
        // enum
        if (this.type == 'list') {
            query(this.el).parent().css('white-space', 'nowrap') // needs this for arrow always to appear on the right side
            // hide focus and show text
            if (this.helpers.prefix) this.helpers.prefix.hide()
            if (!this.helpers.search) return
            // if empty show no icon
            if (this.selected == null && options.icon) {
                options.prefix = `
                    <span class="w2ui-icon ${options.icon} "style="cursor: pointer; font-size: 14px;
                        display: inline-block; margin-top: -1px; color: #7F98AD; ${options.iconStyle}">
                    </span>`
                this.addPrefix()
            } else {
                options.prefix = ''
                this.addPrefix()
            }
            // focus helper
            let focus = query(this.helpers.search_focus)
            let icon = query(focus[0].previousElementSibling)
            focus.css({ outline: 'none' })
            if (focus.val() === '') {
                focus.css('opacity', 0)
                icon.css('opacity', 0)
                if (this.selected?.id) {
                    let text = this.selected.text
                    let ind = this.findItemIndex(options.items, this.selected.id)
                    if (text != null) {
                        query(this.el)
                            .val(w2utils.lang(text))
                            .data({
                                selected: text,
                                selectedIndex: ind[0]
                            })
                    }
                } else {
                    this.el.value = ''
                    query(this.el).removeData('selected selectedIndex')
                }
            } else {
                focus.css('opacity', 1)
                icon.css('opacity', 1)
                query(this.el).val('')
                setTimeout(() => {
                    if (this.helpers.prefix) this.helpers.prefix.hide()
                    if (options.icon) {
                        focus.css('margin-left', '17px')
                        query(this.helpers.search).find('.w2ui-icon-search')
                            .addClass('show-search')
                    } else {
                        focus.css('margin-left', '0px')
                        query(this.helpers.search).find('.w2ui-icon-search')
                            .removeClass('show-search')
                    }
                }, 1)
            }
            // if readonly or disabled
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) {
                setTimeout(() => {
                    if (this.helpers.prefix) query(this.helpers.prefix).css('opacity', '0.6')
                    if (this.helpers.suffix) query(this.helpers.suffix).css('opacity', '0.6')
                }, 1)
            } else {
                setTimeout(() => {
                    if (this.helpers.prefix) query(this.helpers.prefix).css('opacity', '1')
                    if (this.helpers.suffix) query(this.helpers.suffix).css('opacity', '1')
                }, 1)
            }
        }
        let div = this.helpers.multi
        if (['enum', 'file'].includes(this.type) && div) {
            let html = ''
            if (Array.isArray(this.selected)) {
                this.selected.forEach((it, ind) => {
                    if (it == null) return
                    html += `
                        <div class="li-item" index="${ind}" style="max-width: ${parseInt(options.maxItemWidth)}px; ${it.style ? it.style : ''}">
                        ${
                            typeof options.renderItem === 'function'
                            ? options.renderItem(it, ind, `<div class="w2ui-list-remove" index="${ind}">&#160;&#160;</div>`)
                            : `
                               ${it.icon ? `<span class="w2ui-icon ${it.icon}"></span>` : ''}
                               <div class="w2ui-list-remove" index="${ind}">&#160;&#160;</div>
                               ${(this.type === 'enum' ? it.text : it.name) ?? it.id ?? it }
                               ${it.size ? `<span class="file-size"> - ${w2utils.formatSize(it.size)}</span>` : ''}
                            `
                        }
                        </div>`
                })
            }
            let ul  = div.find('.w2ui-multi-items')
            if (options.style) {
                div.attr('style', div.attr('style') + ';' + options.style)
            }
            query(this.el).css('z-index', '-1')
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) {
                setTimeout(() => {
                    div[0].scrollTop = 0 // scroll to the top
                    div.addClass('w2ui-readonly')
                        .find('.li-item').css('opacity', '0.9')
                        .parent().find('.li-search').hide()
                        .find('input').prop('readOnly', true)
                        .closest('.w2ui-multi-items')
                        .find('.w2ui-list-remove').hide()
                }, 1)
            } else {
                setTimeout(() => {
                    div.removeClass('w2ui-readonly')
                        .find('.li-item').css('opacity', '1')
                        .parent().find('.li-search').show()
                        .find('input').prop('readOnly', false)
                        .closest('.w2ui-multi-items')
                        .find('.w2ui-list-remove').show()
                }, 1)
            }
            // clean
            if (this.selected?.length > 0) {
                query(this.el).attr('placeholder', '')
            }
            div.find('.w2ui-enum-placeholder').remove()
            ul.find('.li-item').remove()
            // add new list
            if (html !== '') {
                ul.prepend(html)
            } else if (query(this.el).attr('placeholder') != null && div.find('input').val() === '') {
                let style = w2utils.stripSpaces(`
                    padding-top: ${styles['padding-top']};
                    padding-left: ${styles['padding-left']};
                    box-sizing: ${styles['box-sizing']};
                    line-height: ${styles['line-height']};
                    font-size: ${styles['font-size']};
                    font-family: ${styles['font-family']};
                `)
                div.prepend(`<div class="w2ui-enum-placeholder" style="${style}">${query(this.el).attr('placeholder')}</div>`)
            }
            // ITEMS events
            div.off('.w2item')
                .on('scroll.w2item', (event) => {
                    let edata = this.trigger('scroll', { target: this.el, originalEvent: event })
                    if (edata.isCancelled === true) return
                    // hide tooltip if any
                    w2tooltip.hide(this.el.id + '_preview')
                    // event after
                    edata.finish()
                })
                .find('.li-item')
                .on('click.w2item', (event) => {
                    let target = query(event.target).closest('.li-item')
                    let index  = target.attr('index')
                    let item   = this.selected[index]
                    if (query(target).hasClass('li-search')) return
                    event.stopPropagation()
                    let edata
                    // default behavior
                    if (query(event.target).hasClass('w2ui-list-remove')) {
                        if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
                        // trigger event
                        edata = this.trigger('remove', { target: this.el, originalEvent: event, item })
                        if (edata.isCancelled === true) return
                        // default behavior
                        this.selected.splice(index, 1)
                        query(this.el).trigger('input').trigger('change')
                        query(event.target).remove()
                    } else {
                        // trigger event
                        edata = this.trigger('click', { target: this.el, originalEvent: event.originalEvent, item })
                        if (edata.isCancelled === true) return
                        // if file - show image preview
                        let preview = item.tooltip
                        if (this.type === 'file') {
                            if ((/image/i).test(item.type)) { // image
                                preview = `
                                    <div class="w2ui-file-preview">
                                        <img src="${(item.content ? 'data:'+ item.type +';base64,'+ item.content : '')}"
                                            style="max-width: 300px">
                                    </div>`
                            }
                            preview += `
                                <div class="w2ui-file-info">
                                    <div class="file-caption">${w2utils.lang('Name')}:</div>
                                    <div class="file-value">${item.name}</div>
                                    <div class="file-caption">${w2utils.lang('Size')}:</div>
                                    <div class="file-value">${w2utils.formatSize(item.size)}</div>
                                    <div class="file-caption">${w2utils.lang('Type')}:</div>
                                    <div class="file-value file-type">${item.type}</div>
                                    <div class="file-caption">${w2utils.lang('Modified')}:</div>
                                    <div class="file-value">${w2utils.date(item.modified)}</div>
                                </div>`
                        }
                        if (preview) {
                            let name = this.el.id + '_preview'
                            w2tooltip.show({
                                name,
                                anchor: target.get(0),
                                html: preview,
                                hideOn: ['doc-click'],
                                class: ''
                            })
                            .show((event) => {
                                let $img = query(`#w2overlay-${name} img`)
                                $img.on('load', function (event) {
                                    let w = this.clientWidth
                                    let h = this.clientHeight
                                    if (w < 300 & h < 300) return
                                    if (w >= h && w > 300) query(this).css('width', '300px')
                                    if (w < h && h > 300) query(this).css('height', '300px')
                                })
                                .on('error', function (event) {
                                    this.style.display = 'none'
                                })
                            })
                        }
                        edata.finish()
                    }
                })
                .on('mouseenter.w2item', (event) => {
                    let target = query(event.target).closest('.li-item')
                    if (query(target).hasClass('li-search')) return
                    let item = this.selected[query(event.target).attr('index')]
                    // trigger event
                    let edata = this.trigger('mouseEnter', { target: this.el, originalEvent: event, item })
                    if (edata.isCancelled === true) return
                    // event after
                    edata.finish()
                })
                .on('mouseleave.w2item', (event) => {
                    let target = query(event.target).closest('.li-item')
                    if (query(target).hasClass('li-search')) return
                    let item = this.selected[query(event.target).attr('index')]
                    // trigger event
                    let edata = this.trigger('mouseLeave', { target: this.el, originalEvent: event, item })
                    if (edata.isCancelled === true) return
                    // event after
                    edata.finish()
                })
            // update size for enum, hide for file
            if (this.type === 'enum') {
                let search = this.helpers.multi.find('input')
                search.css({ width: '15px' })
            } else {
                this.helpers.multi.find('.li-search').hide()
            }
            this.resize()
        }
        return Date.now() - time
    }
    // resizing width of list, enum, file controls
    resize() {
        let width = this.el.clientWidth
        // let height = this.el.clientHeight
        // if (this.tmp.current_width == width && height > 0) return
        let styles = getComputedStyle(this.el)
        let focus  = this.helpers.search
        let multi  = this.helpers.multi
        let suffix = this.helpers.suffix
        let prefix = this.helpers.prefix
        // resize helpers
        if (focus) {
            query(focus).css('width', width)
        }
        if (multi) {
            query(multi).css('width', width - parseInt(styles['margin-left'], 10) - parseInt(styles['margin-right'], 10))
        }
        if (suffix) {
            this.addSuffix()
        }
        if (prefix) {
            this.addPrefix()
        }
        // enum or file
        let div = this.helpers.multi
        if (['enum', 'file'].includes(this.type) && div) {
            // adjust height
            query(this.el).css('height', 'auto')
            let cntHeight = query(div).find(':scope div.w2ui-multi-items').get(0).clientHeight + 5
            if (cntHeight < 20) cntHeight = 20
            // max height
            if (cntHeight > this.tmp['max-height']) {
                cntHeight = this.tmp['max-height']
            }
            // min height
            if (cntHeight < this.tmp['min-height']) {
                cntHeight = this.tmp['min-height']
            }
            let inpHeight = w2utils.getSize(this.el, 'height') - 2
            if (inpHeight > cntHeight) cntHeight = inpHeight
            query(div).css({
                'height': cntHeight + 'px',
                overflow: (cntHeight == this.tmp['max-height'] ? 'auto' : 'hidden')
            })
            query(div).css('height', cntHeight + 'px')
            query(this.el).css({ 'height': cntHeight + 'px' })
        }
        // remember width
        this.tmp.current_width = width
    }
    reset() {
        // restore paddings
        if (this.tmp != null) {
            query(this.el).css('height', 'auto')
            Array('padding-left', 'padding-right', 'background-color', 'border-color').forEach(prop => {
                if (this.tmp && this.tmp['old-'+ prop] != null) {
                    query(this.el).css(prop, this.tmp['old-' + prop])
                    delete this.tmp['old-' + prop]
                }
            })
            // remove resize watcher
            clearInterval(this.tmp.sizeTimer)
        }
        // remove events and (data)
        query(this.el)
            .val(this.clean(query(this.el).val()))
            .removeClass('w2field')
            .removeData('selected selectedIndex')
            .off('.w2field') // remove only events added by w2field
        // remove helpers
        Object.keys(this.helpers).forEach(key => {
            query(this.helpers[key]).remove()
        })
        this.helpers = {}
    }
    clean(val) {
        // issue #499
        if (typeof val === 'number'){
            return val
        }
        let options = this.options
        val = String(val).trim()
        // clean
        if (['int', 'float', 'money', 'currency', 'percent'].includes(this.type)) {
            if (typeof val === 'string') {
                if (options.autoFormat) {
                    if (['money', 'currency'].includes(this.type)) {
                        val = String(val).replace(options.moneyRE, '')
                    }
                    if (this.type === 'percent') {
                        val = String(val).replace(options.percentRE, '')
                    }
                    if (['int', 'float'].includes(this.type)) {
                        val = String(val).replace(options.numberRE, '')
                    }
                }
                val = val.replace(/\s+/g, '')
                         .replace(new RegExp(options.groupSymbol, 'g'), '')
                         .replace(options.decimalSymbol, '.')
            }
            if (val !== '' && w2utils.isFloat(val)) val = Number(val); else val = ''
        }
        return val
    }
    format(val) {
        let options = this.options
        // auto format numbers or money
        if (options.autoFormat && val !== '') {
            switch (this.type) {
                case 'money':
                case 'currency':
                    val = w2utils.formatNumber(val, options.currencyPrecision, true)
                    if (val !== '') val = options.currencyPrefix + val + options.currencySuffix
                    break
                case 'percent':
                    val = w2utils.formatNumber(val, options.precision, true)
                    if (val !== '') val += '%'
                    break
                case 'float':
                    val = w2utils.formatNumber(val, options.precision, true)
                    break
                case 'int':
                    val = w2utils.formatNumber(val, 0, true)
                    break
            }
            // if default group symbol does not match - replase it
            let group = parseInt(1000).toLocaleString(w2utils.settings.locale, { useGrouping: true }).slice(1, 2)
            if (group !== this.options.groupSymbol) {
                val = val.replaceAll(group, this.options.groupSymbol)
            }
        }
        return val
    }
    change(event) {
        // numeric
        if (['int', 'float', 'money', 'currency', 'percent'].indexOf(this.type) !== -1) {
            // check max/min
            let val = query(this.el).val()
            let new_val = this.format(this.clean(query(this.el).val()))
            // if was modified
            if (val !== '' && val != new_val) {
                query(this.el).val(new_val)
                // cancel event
                event.stopPropagation()
                event.preventDefault()
                return false
            }
        }
        // color
        if (this.type === 'color') {
            let color = query(this.el).val()
            if (color.substr(0, 3).toLowerCase() !== 'rgb') {
                color   = '#' + color
                let len = query(this.el).val().length
                if (len !== 8 && len !== 6 && len !== 3) color = ''
            }
            let next = query(this.el).get(0).nextElementSibling
            query(next).find('div').css('background-color', color)
            if (query(this.el).hasClass('has-focus')) {
                this.updateOverlay()
            }
        }
        // list, enum
        if (['list', 'enum', 'file'].indexOf(this.type) !== -1) {
            this.refresh()
        }
        // date, time
        if (['date', 'time', 'datetime'].indexOf(this.type) !== -1) {
            // convert linux timestamps
            let tmp = parseInt(this.el.value)
            if (w2utils.isInt(this.el.value) && tmp > 3000) {
                if (this.type === 'time') tmp = w2utils.formatTime(new Date(tmp), this.options.format)
                if (this.type === 'date') tmp = w2utils.formatDate(new Date(tmp), this.options.format)
                if (this.type === 'datetime') tmp = w2utils.formatDateTime(new Date(tmp), this.options.format)
                query(this.el).val(tmp).trigger('input').trigger('change')
            }
        }
    }
    click(event) {
        // lists
        if (['list', 'combo', 'enum'].includes(this.type)) {
            if (!query(this.el).hasClass('has-focus')) {
                this.focus(event)
            }
            if (this.type == 'combo') {
                this.updateOverlay()
            }
            // since list has separate search input, in order to keep the overlay open, need to stop
            if (this.type == 'list') {
                this.updateOverlay()
                event.stopPropagation()
            }
        }
        // other fields with drops
        if (['date', 'time', 'datetime', 'color'].includes(this.type)) {
            this.updateOverlay()
        }
    }
    focus(event) {
        if (this.type == 'list' && document.activeElement == this.el) {
            this.helpers.search_focus.focus()
            return
        }
        // color, date, time
        if (['color', 'date', 'time', 'datetime'].indexOf(this.type) !== -1) {
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            this.updateOverlay()
        }
        // menu
        if (['list', 'combo', 'enum'].indexOf(this.type) !== -1) {
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) {
                // still add focus
                query(this.el).addClass('has-focus')
                return
            }
            // regenerate items
            if (typeof this.options._items_fun == 'function') {
                this.options.items = w2utils.normMenu.call(this, this.options._items_fun)
            }
            if (this.helpers.search) {
                let search = this.helpers.search_focus
                search.value = ''
                search.select()
            }
            if (this.type == 'enum') {
                // file control in particular need to receive focus after file select
                let search = query(this.el.previousElementSibling).find('.li-search input').get(0)
                if (document.activeElement !== search) {
                    search.focus()
                }
            }
            this.resize()
            // update overlay if needed
            if (event.showMenu !== false && (this.options.openOnFocus !== false || query(this.el).hasClass('has-focus'))) {
                setTimeout(() => { this.updateOverlay() }, 100) // execute at the end of event loop
            }
        }
        if (this.type == 'file') {
            let prev = query(this.el).get(0).previousElementSibling
            query(prev).addClass('has-focus')
        }
        query(this.el).addClass('has-focus')
    }
    blur(event) {
        let val = query(this.el).val().trim()
        query(this.el).removeClass('has-focus')
        if (['int', 'float', 'money', 'currency', 'percent'].includes(this.type)) {
            if (val !== '') {
                let newVal = val
                let error = ''
                if (!this.isStrValid(val)) { // validity is also checked in blur
                    newVal = ''
                } else {
                    let rVal = this.clean(val)
                    if (this.options.min != null && rVal < this.options.min) {
                        newVal = this.options.min
                        error = `Should be >= ${this.options.min}`
                    }
                    if (this.options.max != null && rVal > this.options.max) {
                        newVal = this.options.max
                        error = `Should be <= ${this.options.max}`
                    }
                }
                if (this.options.autoCorrect) {
                    query(this.el).val(newVal).trigger('input').trigger('change')
                    if (error) {
                        w2tooltip.show({
                            name: this.el.id + '_error',
                            anchor: this.el,
                            html: error
                        })
                        setTimeout(() => { w2tooltip.hide(this.el.id + '_error') }, 3000)
                    }
                }
            }
        }
        // date or time
        if (['date', 'time', 'datetime'].includes(this.type) && this.options.autoCorrect) {
            if (val !== '') {
                let check = this.type == 'date' ? w2utils.isDate :
                    (this.type == 'time' ? w2utils.isTime : w2utils.isDateTime)
                if (!w2date.inRange(this.el.value, this.options)
                        || !check.bind(w2utils)(this.el.value, this.options.format)) {
                    // if not in range or wrong value - clear it
                    query(this.el).val('').trigger('input').trigger('change')
                }
            }
        }
        // clear search input
        if (this.type === 'enum') {
            query(this.helpers.multi).find('input').val('').css('width', '15px')
        }
        if (this.type == 'file') {
            let prev = this.el.previousElementSibling
            query(prev).removeClass('has-focus')
        }
        if (this.type === 'list') {
            this.el.value = this.selected?.text ?? ''
        }
    }
    keyDown(event, extra) {
        let options = this.options
        let key     = event.keyCode || (extra && extra.keyCode)
        let cancel  = false
        let val, inc, daymil, dt, newValue, newDT
        // ignore wrong pressed key
        if (['int', 'float', 'money', 'currency', 'percent', 'hex', 'bin', 'color', 'alphanumeric'].includes(this.type)) {
            if (!event.metaKey && !event.ctrlKey && !event.altKey) {
                if (!this.isStrValid(event.key ?? '1', true) && // valid & is not arrows, dot, comma, etc keys
                        ![9, 8, 13, 27, 37, 38, 39, 40, 46].includes(event.keyCode)) {
                    event.preventDefault()
                    if (event.stopPropagation) event.stopPropagation(); else event.cancelBubble = true
                    return false
                }
            }
        }
        // numeric
        if (['int', 'float', 'money', 'currency', 'percent'].includes(this.type)) {
            if (!options.keyboard || query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            val = parseFloat(query(this.el).val().replace(options.moneyRE, '')) || 0
            inc = options.step
            if (event.ctrlKey || event.metaKey) inc = options.step * 10
            switch (key) {
                case 38: // up
                    if (event.shiftKey) break // no action if shift key is pressed
                    newValue = (val + inc <= options.max || options.max == null ? Number((val + inc).toFixed(12)) : options.max)
                    query(this.el).val(newValue).trigger('input').trigger('change')
                    cancel = true
                    break
                case 40: // down
                    if (event.shiftKey) break // no action if shift key is pressed
                    newValue = (val - inc >= options.min || options.min == null ? Number((val - inc).toFixed(12)) : options.min)
                    query(this.el).val(newValue).trigger('input').trigger('change')
                    cancel = true
                    break
            }
            if (cancel) {
                event.preventDefault()
                this.moveCaret2end()
            }
        }
        // date/datetime
        if (['date', 'datetime'].includes(this.type)) {
            if (!options.keyboard || query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            let is = (this.type == 'date' ? w2utils.isDate : w2utils.isDateTime).bind(w2utils)
            let format = (this.type == 'date' ? w2utils.formatDate : w2utils.formatDateTime).bind(w2utils)
            daymil = 24*60*60*1000
            inc = 1
            if (event.ctrlKey || event.metaKey) inc = 10 // by month
            dt = is(query(this.el).val(), options.format, true)
            if (!dt) { dt = new Date(); daymil = 0 }
            switch (key) {
                case 38: // up
                    if (event.shiftKey) break // no action if shift key is pressed
                    if (inc == 10) {
                        dt.setMonth(dt.getMonth() + 1)
                    } else {
                        dt.setTime(dt.getTime() + daymil)
                    }
                    newDT = format(dt.getTime(), options.format)
                    query(this.el).val(newDT).trigger('input').trigger('change')
                    cancel = true
                    break
                case 40: // down
                    if (event.shiftKey) break // no action if shift key is pressed
                    if (inc == 10) {
                        dt.setMonth(dt.getMonth() - 1)
                    } else {
                        dt.setTime(dt.getTime() - daymil)
                    }
                    newDT = format(dt.getTime(), options.format)
                    query(this.el).val(newDT).trigger('input').trigger('change')
                    cancel = true
                    break
            }
            if (cancel) {
                event.preventDefault()
                this.moveCaret2end()
                this.updateOverlay()
            }
        }
        // time
        if (this.type === 'time') {
            if (!options.keyboard || query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            inc = (event.ctrlKey || event.metaKey ? 60 : 1)
            val = query(this.el).val()
            let time = w2date.str2min(val) || w2date.str2min((new Date()).getHours() + ':' + ((new Date()).getMinutes() - 1))
            switch (key) {
                case 38: // up
                    if (event.shiftKey) break // no action if shift key is pressed
                    time  += inc
                    cancel = true
                    break
                case 40: // down
                    if (event.shiftKey) break // no action if shift key is pressed
                    time  -= inc
                    cancel = true
                    break
            }
            if (cancel) {
                event.preventDefault()
                query(this.el).val(w2date.min2str(time)).trigger('input').trigger('change')
                this.moveCaret2end()
            }
        }
        // list/enum
        if (['list', 'enum'].includes(this.type)) {
            switch (key) {
                case 8: // delete
                case 46: // backspace
                    if (this.type == 'list') {
                        let search = query(this.helpers.search_focus)
                        if (search.val() == '') {
                            this.selected = null
                            w2menu.hide(this.el.id + '_menu')
                            query(this.el).val('').trigger('input').trigger('change')
                        }
                    } else {
                        let search = query(this.helpers.multi).find('input')
                        if (search.val() == '') {
                            w2menu.hide(this.el.id + '_menu')
                            this.selected.pop()
                            // update selected array in overlay
                            let overlay = w2menu.get(this.el.id + '_menu')
                            if (overlay) overlay.options.selected = this.selected
                            this.refresh()
                        }
                    }
                    break
                case 9: // tab key
                case 16: // shift key (when shift+tab)
                    break
                case 27: // escape
                    w2menu.hide(this.el.id + '_menu')
                    this.refresh()
                    break
                default: {
                    // intentionally blank
                }
            }
        }
    }
    keyUp(event) {
        if (this.type == 'list') {
            let search = query(this.helpers.search_focus)
            if (search.val() !== '') {
                query(this.el).attr('placeholder', '')
            } else {
                query(this.el).attr('placeholder', this.tmp.pholder)
            }
            if (event.keyCode == 13) {
                setTimeout(() => {
                    search.val('')
                    w2menu.hide(this.el.id + '_menu')
                    this.refresh()
                }, 1)
            }
            // if arrows are clicked, it will show overlay
            if ([38, 40].includes(event.keyCode) && !this.tmp.overlay.overlay.displayed) {
                this.updateOverlay()
            }
            this.refresh()
        }
        if (this.type == 'combo') {
            this.updateOverlay()
        }
        if (this.type == 'enum') {
            let search = this.helpers.multi.find('input')
            let styles = getComputedStyle(search.get(0))
            let width = w2utils.getStrWidth(search.val(),
                `font-family: ${styles['font-family']}; font-size: ${styles['font-size']};`)
            search.css({ width: (width + 15) + 'px' })
            this.resize()
            // if arrows are clicked, it will show overlay
            if ([38, 40].includes(event.keyCode) && !this.tmp.overlay?.overlay?.displayed) {
                this.updateOverlay()
            }
        }
    }
    findItemIndex(items, id, parents) {
        let inds = []
        if (!parents) parents = []
        if (['list', 'combo', 'enum'].includes(this.type) && this.options.url) {
            // remove source, so get it from overlay
            let overlay = w2menu.get(this.el.id + '_menu')
            if (overlay) {
                items = overlay.options.items
                this.options.items = items
            }
        }
        items.forEach((item, ind) => {
            if (item.id === id) {
                inds = parents.concat([ind])
                this.options.index = [ind]
            }
            if (inds.length == 0 && item.items && item.items.length > 0) {
                parents.push(ind)
                inds = this.findItemIndex(item.items, id, parents)
                parents.pop()
            }
        })
        return inds
    }
    updateOverlay(indexOnly) {
        let options = this.options
        let params
        // color
        if (this.type === 'color') {
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            w2color.show(w2utils.extend({
                name: this.el.id + '_color',
                anchor: this.el,
                transparent: options.transparent,
                advanced: options.advanced,
                color: this.el.value,
                liveUpdate: true
            }, this.options))
            .select(event => {
                let color = event.detail.color
                query(this.el).val(color).trigger('input').trigger('change')
            })
            .liveUpdate(event => {
                let color = event.detail.color
                query(this.helpers.suffix).find(':scope > div').css('background-color', '#' + color)
            })
        }
        // list
        if (['list', 'combo', 'enum'].includes(this.type)) {
            let el = this.el
            let input = this.el
            if (this.type === 'enum') {
                el = this.helpers.multi.get(0)
                input = query(el).find('input').get(0)
            }
            if (this.type === 'list') {
                let sel = this.selected
                if (w2utils.isPlainObject(sel) && Object.keys(sel).length > 0) {
                    let ind = this.findItemIndex(options.items, sel.id)
                    if (ind.length > 0) {
                        options.index = ind
                    }
                }
                input = this.helpers.search_focus
            }
            if (query(this.el).hasClass('has-focus') && !this.el.readOnly && !this.el.disabled) {
                params = w2utils.extend({}, options, {
                    name: this.el.id + '_menu',
                    anchor: input,
                    selected: this.selected,
                    search: false,
                    render: options.renderDrop,
                    anchorClass: '',
                    offsetY: 5,
                    maxHeight: options.maxDropHeight, // TODO: check
                    maxWidth: options.maxDropWidth,  // TODO: check
                    minWidth: options.minDropWidth   // TODO: check
                })
                this.tmp.overlay = w2menu.show(params)
                    .select(event => {
                        if (['list', 'combo'].includes(this.type)) {
                            this.selected = event.detail.item
                            query(input).val('')
                            query(this.el).val(this.selected.text).trigger('input').trigger('change')
                            this.focus({ showMenu: false })
                        } else {
                            let selected = this.selected
                            let newItem = event.detail?.item
                            if (newItem) {
                                // trigger event
                                let edata = this.trigger('add', { target: this.el, item: newItem, originalEvent: event })
                                if (edata.isCancelled === true) return
                                // default behavior
                                if (selected.length >= options.max && options.max > 0) selected.pop()
                                delete newItem.hidden
                                selected.push(newItem)
                                query(this.el).trigger('input').trigger('change')
                                query(this.helpers.multi).find('input').val('')
                                // updaet selected array in overlays
                                let overlay = w2menu.get(this.el.id + '_menu')
                                if (overlay) overlay.options.selected = this.selected
                                // event after
                                edata.finish()
                            }
                        }
                    })
            }
        }
        // date
        if (['date', 'time', 'datetime'].includes(this.type)) {
            if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
            w2date.show(w2utils.extend({
                name: this.el.id + '_date',
                anchor: this.el,
                value: this.el.value,
            }, this.options))
            .select(event => {
                let date = event.detail.date
                if (date != null) {
                    query(this.el).val(date).trigger('input').trigger('change')
                }
            })
        }
    }
    /*
    *  INTERNAL FUNCTIONS
    */
    isStrValid(ch, loose) {
        let isValid = true
        switch (this.type) {
            case 'int':
                if (loose && ['-', this.options.groupSymbol].includes(ch)) {
                    isValid = true
                } else {
                    isValid = w2utils.isInt(ch.replace(this.options.numberRE, ''))
                }
                break
            case 'percent':
                ch = ch.replace(/%/g, '')
            case 'float':
                if (loose && ['-', '', this.options.decimalSymbol, this.options.groupSymbol].includes(ch)) {
                    isValid = true
                } else {
                    isValid = w2utils.isFloat(ch.replace(this.options.numberRE, ''))
                }
                break
            case 'money':
            case 'currency':
                if (loose && ['-', this.options.decimalSymbol, this.options.groupSymbol, this.options.currencyPrefix,
                    this.options.currencySuffix].includes(ch)) {
                    isValid = true
                } else {
                    isValid = w2utils.isFloat(ch.replace(this.options.moneyRE, ''))
                }
                break
            case 'bin':
                isValid = w2utils.isBin(ch)
                break
            case 'color':
            case 'hex':
                isValid = w2utils.isHex(ch)
                break
            case 'alphanumeric':
                isValid = w2utils.isAlphaNumeric(ch)
                break
        }
        return isValid
    }
    addPrefix() {
        if (!this.options.prefix) {
            return
        }
        let helper
        let styles = getComputedStyle(this.el)
        if (this.tmp['old-padding-left'] == null) {
            this.tmp['old-padding-left'] = styles['padding-left']
        }
        // remove if already displayed
        if (this.helpers.prefix) query(this.helpers.prefix).remove()
        query(this.el).before(`<div class="w2ui-field-helper">${this.options.prefix}</div>`)
        helper = query(this.el).get(0).previousElementSibling
        query(helper)
            .css({
                'color'          : styles.color,
                'font-family'    : styles['font-family'],
                'font-size'      : styles['font-size'],
                'height'         : this.el.clientHeight + 'px',
                'padding-top'    : styles['padding-top'],
                'padding-bottom' : styles['padding-bottom'],
                'padding-left'   : this.tmp['old-padding-left'],
                'padding-right'  : 0,
                'margin-top'     : (parseInt(styles['margin-top'], 10) + 2) + 'px',
                'margin-bottom'  : (parseInt(styles['margin-bottom'], 10) + 1) + 'px',
                'margin-left'    : styles['margin-left'],
                'margin-right'   : 0,
                'z-index'        : 1,
            })
        // only if visible
        query(this.el).css('padding-left', helper.clientWidth + 'px !important')
        // remember helper
        this.helpers.prefix = helper
    }
    addSuffix() {
        if (!this.options.suffix && !this.options.arrow) {
            return
        }
        let helper
        let self = this
        let styles = getComputedStyle(this.el)
        if (this.tmp['old-padding-right'] == null) {
            this.tmp['old-padding-right'] = styles['padding-right']
        }
        let pr = parseInt(styles['padding-right'] || 0)
        if (this.options.arrow) {
            // remove if already displayed
            if (this.helpers.arrow) query(this.helpers.arrow).remove()
            // add fresh
            query(this.el).after(
                '<div class="w2ui-field-helper" style="border: 1px solid transparent">&#160;'+
                '    <div class="w2ui-field-up" type="up">'+
                '        <div class="arrow-up" type="up"></div>'+
                '    </div>'+
                '    <div class="w2ui-field-down" type="down">'+
                '        <div class="arrow-down" type="down"></div>'+
                '    </div>'+
                '</div>')
            helper = query(this.el).get(0).nextElementSibling
            query(helper).css({
                'color'         : styles.color,
                'font-family'   : styles['font-family'],
                'font-size'     : styles['font-size'],
                'height'        : this.el.clientHeight + 'px',
                'padding'       : 0,
                'margin-top'    : (parseInt(styles['margin-top'], 10) + 1) + 'px',
                'margin-bottom' : 0,
                'border-left'   : '1px solid silver',
                'width'         : '16px',
                'transform'     : 'translateX(-100%)'
            })
                .on('mousedown', function(event) {
                    if (query(event.target).hasClass('arrow-up')) {
                        self.keyDown(event, { keyCode: 38 })
                    }
                    if (query(event.target).hasClass('arrow-down')) {
                        self.keyDown(event, { keyCode: 40 })
                    }
                })
            pr += helper.clientWidth // width of the control
            query(this.el).css('padding-right', pr + 'px !important')
            this.helpers.arrow = helper
        }
        if (this.options.suffix !== '') {
            // remove if already displayed
            if (this.helpers.suffix) query(this.helpers.suffix).remove()
            // add fresh
            query(this.el).after(`<div class="w2ui-field-helper">${this.options.suffix}</div>`)
            helper = query(this.el).get(0).nextElementSibling
            query(helper)
                .css({
                    'color'          : styles.color,
                    'font-family'    : styles['font-family'],
                    'font-size'      : styles['font-size'],
                    'height'        : this.el.clientHeight + 'px',
                    'padding-top'    : styles['padding-top'],
                    'padding-bottom' : styles['padding-bottom'],
                    'padding-left'   : 0,
                    'padding-right'  : styles['padding-right'],
                    'margin-top'     : (parseInt(styles['margin-top'], 10) + 2) + 'px',
                    'margin-bottom'  : (parseInt(styles['margin-bottom'], 10) + 1) + 'px',
                    'transform'      : 'translateX(-100%)'
                })
            query(this.el).css('padding-right', helper.clientWidth + 'px !important')
            this.helpers.suffix = helper
        }
    }
    // Only used for list
    addSearch() {
        if (this.type !== 'list') return
        // clean up & init
        if (this.helpers.search) query(this.helpers.search).remove()
        // remember original tabindex
        let tabIndex = parseInt(query(this.el).attr('tabIndex'))
        if (!isNaN(tabIndex) && tabIndex !== -1) this.tmp['old-tabIndex'] = tabIndex
        if (this.tmp['old-tabIndex']) tabIndex = this.tmp['old-tabIndex']
        if (tabIndex == null || isNaN(tabIndex)) tabIndex = 0
        // if there is id, add to search with "_search"
        let searchId = ''
        if (query(this.el).attr('id') != null) {
            searchId = 'id="' + query(this.el).attr('id') + '_search"'
        }
        // build helper
        let html = `
            <div class="w2ui-field-helper">
                <span class="w2ui-icon w2ui-icon-search"></span>
                <input ${searchId} type="text" tabIndex="${tabIndex}" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"/>
            </div>`
        query(this.el).attr('tabindex', -1).before(html)
        let helper = query(this.el).get(0).previousElementSibling
        this.helpers.search = helper
        this.helpers.search_focus = query(helper).find('input').get(0)
        let styles = getComputedStyle(this.el)
        query(helper).css({
            width           : this.el.clientWidth + 'px',
            'margin-top'    : styles['margin-top'],
            'margin-left'   : styles['margin-left'],
            'margin-bottom' : styles['margin-bottom'],
            'margin-right'  : styles['margin-right']
        })
            .find('input')
            .css({
                cursor   : 'default',
                width    : '100%',
                opacity  : 1,
                padding  : styles.padding,
                margin   : styles.margin,
                border   : '1px solid transparent',
                'background-color' : 'transparent'
            })
        // INPUT events
        query(helper).find('input')
            .off('.helper')
            .on('focus.helper', event => {
                query(event.target).val('')
                this.tmp.pholder = query(this.el).attr('placeholder') ?? ''
                this.focus(event)
                event.stopPropagation()
            })
            .on('blur.helper', event => {
                query(event.target).val('')
                if (this.tmp.pholder != null) query(this.el).attr('placeholder', this.tmp.pholder)
                this.blur(event)
                event.stopPropagation()
            })
            .on('keydown.helper', event => { this.keyDown(event) })
            .on('keyup.helper', event => { this.keyUp(event) })
        // MAIN div
        query(helper).on('click', event => {
            query(event.target).find('input').focus()
        })
    }
    // Used in enum/file
    addMultiSearch() {
        if (!['enum', 'file'].includes(this.type)) {
            return
        }
        // clean up & init
        query(this.helpers.multi).remove()
        // build helper
        let html   = ''
        let styles = getComputedStyle(this.el)
        let margin = w2utils.stripSpaces(`
            margin-top: 0px;
            margin-bottom: 0px;
            margin-left: ${styles['margin-left']};
            margin-right: ${styles['margin-right']};
            width: ${(w2utils.getSize(this.el, 'width') - parseInt(styles['margin-left'], 10)
                                - parseInt(styles['margin-right'], 10))}px;
        `)
        if (this.tmp['min-height'] == null) {
            let min = this.tmp['min-height'] = parseInt((styles['min-height'] != 'none' ? styles['min-height'] : 0) || 0)
            let current = parseInt(styles.height)
            this.tmp['min-height'] = Math.max(min, current)
        }
        if (this.tmp['max-height'] == null && styles['max-height'] != 'none') {
            this.tmp['max-height'] = parseInt(styles['max-height'])
        }
        // if there is id, add to search with "_search"
        let searchId = ''
        if (query(this.el).attr('id') != null) {
            searchId = `id="${query(this.el).attr('id')}_search"`
        }
        // remember original tabindex
        let tabIndex = parseInt(query(this.el).attr('tabIndex'))
        if (!isNaN(tabIndex) && tabIndex !== -1) this.tmp['old-tabIndex'] = tabIndex
        if (this.tmp['old-tabIndex']) tabIndex = this.tmp['old-tabIndex']
        if (tabIndex == null || isNaN(tabIndex)) tabIndex = 0
        if (this.type === 'enum') {
            html = `
            <div class="w2ui-field-helper w2ui-list" style="${margin}">
                <div class="w2ui-multi-items">
                    <div class="li-search">
                        <input ${searchId} type="text" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"
                            tabindex="${tabIndex}"
                            ${query(this.el).prop('readOnly') ? 'readonly': '' }
                            ${query(this.el).prop('disabled') ? 'disabled': '' }>
                    </div>
                </div>
            </div>`
        }
        if (this.type === 'file') {
            html = `
            <div class="w2ui-field-helper w2ui-list" style="${margin}">
                <div class="w2ui-multi-file">
                    <input name="attachment" class="file-input" type="file" tabindex="-1"'
                        style="width: 100%; height: 100%; opacity: 0" title=""
                        ${this.options.max !== 1 ? 'multiple' : ''}
                        ${query(this.el).prop('readOnly') || query(this.el).prop('disabled') ? 'disabled': ''}
                        ${query(this.el).attr('accept') ? ' accept="'+ query(this.el).attr('accept') +'"': ''}>
                </div>
                <div class="w2ui-multi-items">
                    <div class="li-search" style="display: none">
                        <input ${searchId} type="text" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"
                            tabindex="${tabIndex}"
                            ${query(this.el).prop('readOnly') ? 'readonly': '' }
                            ${query(this.el).prop('disabled') ? 'disabled': '' }>
                    </div>
                </div>
            </div>`
        }
        // old bg and border
        this.tmp['old-background-color'] = styles['background-color']
        this.tmp['old-border-color']     = styles['border-color']
        query(this.el)
            .before(html)
            .css({
                'border-color': 'transparent',
                'background-color': 'transparent'
            })
        let div = query(this.el.previousElementSibling)
        this.helpers.multi = div
        query(this.el).attr('tabindex', -1)
        // click anywhere on the field
        div.on('click', event => { this.focus(event) })
        // search field
        div.find('input:not(.file-input)')
            .on('click', event => { this.click(event) })
            .on('focus', event => { this.focus(event) })
            .on('blur', event => { this.blur(event) })
            .on('keydown', event => { this.keyDown(event) })
            .on('keyup', event => { this.keyUp(event) })
        // file input
        if (this.type === 'file') {
            div.find('input.file-input')
                .off('.drag')
                .on('click.drag', (event) => {
                    event.stopPropagation()
                    if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
                    this.focus(event)
                })
                .on('dragenter.drag', (event) => {
                    if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
                    div.addClass('w2ui-file-dragover')
                })
                .on('dragleave.drag', (event) => {
                    if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
                    div.removeClass('w2ui-file-dragover')
                })
                .on('drop.drag', (event) => {
                    if (query(this.el).prop('readOnly') || query(this.el).prop('disabled')) return
                    div.removeClass('w2ui-file-dragover')
                    let files = Array.from(event.dataTransfer.files)
                    files.forEach(file => { this.addFile(file) })
                    this.focus(event)
                    // cancel to stop browser behaviour
                    event.preventDefault()
                    event.stopPropagation()
                })
                .on('dragover.drag', (event) => {
                    // cancel to stop browser behaviour
                    event.preventDefault()
                    event.stopPropagation()
                })
                .on('change.drag', (event) => {
                    if (typeof event.target.files !== 'undefined') {
                        Array.from(event.target.files).forEach(file => { this.addFile(file) })
                    }
                    this.focus(event)
                })
        }
        this.refresh()
    }
    addFile(file) {
        let options = this.options
        let selected = this.selected
        let newItem = {
            name     : file.name,
            type     : file.type,
            modified : file.lastModifiedDate,
            size     : file.size,
            content  : null,
            file     : file
        }
        let size = 0
        let cnt = 0
        let errors = []
        if (Array.isArray(selected)) {
            selected.forEach(item => {
                if (item.name == file.name && item.size == file.size) {
                    errors.push(w2utils.lang('The file "${name}" (${size}) is already added.', {
                        name: file.name, size: w2utils.formatSize(file.size) }))
                }
                size += item.size
                cnt++
            })
        }
        if (options.maxFileSize !== 0 && newItem.size > options.maxFileSize) {
            errors.push(w2utils.lang('Maximum file size is ${size}', { size: w2utils.formatSize(options.maxFileSize) }))
        }
        if (options.maxSize !== 0 && size + newItem.size > options.maxSize) {
            errors.push(w2utils.lang('Maximum total size is ${size}', { size: w2utils.formatSize(options.maxSize) }))
        }
        if (options.max !== 0 && cnt >= options.max) {
            errors.push(w2utils.lang('Maximum number of files is ${count}', { count: options.max }))
        }
        // trigger event
        let edata = this.trigger('add', { target: this.el, file: newItem, total: cnt, totalSize: size, errors })
        if (edata.isCancelled === true) return
        // if errors and not silent
        if (options.silent !== true && errors.length > 0) {
            w2tooltip.show({
                anchor: this.el,
                html: 'Errors: ' + errors.join('<br>')
            })
            console.log('ERRORS (while adding files): ', errors)
            return
        }
        // check params
        selected.push(newItem)
        // read file as base64
        if (typeof FileReader !== 'undefined' && options.readContent === true) {
            let reader = new FileReader()
            let self = this
            // need a closure
            reader.onload = (function onload() {
                return function closure(event) {
                    let fl = event.target.result
                    let ind = fl.indexOf(',')
                    newItem.content = fl.substr(ind + 1)
                    self.refresh()
                    query(self.el).trigger('input').trigger('change')
                    // event after
                    edata.finish()
                }
            })()
            reader.readAsDataURL(file)
        } else {
            this.refresh()
            query(this.el).trigger('input').trigger('change')
            edata.finish()
        }
    }
    // move cursror to end
    moveCaret2end() {
        setTimeout(() => {
            this.el.setSelectionRange(this.el.value.length, this.el.value.length)
        }, 0)
    }
}
export {
    w2ui, w2utils, query, w2locale, w2event, w2base,
    w2popup, w2alert, w2confirm, w2prompt, Dialog,
    w2tooltip, w2menu, w2color, w2date, Tooltip,
    w2toolbar, w2sidebar, w2tabs, w2layout, w2grid, w2form, w2field
}