Source

client/ui/containers/panel.js

/**
 * 
 * The Panel derives from [Container](kiss.ui.Container.html).
 * 
 * It's a container with a header and other properties that allow to create standard draggable windows and modal windows.
 * 
 * Don't forget you can use the Container's methods like **update, addItem, insertItem, deleteItem, getFields, getData...**
 * 
 * @param {object} config
 * @param {object[]} config.items - The array of contained items
 * @param {boolean} [config.multiview] - If true, the container only displays one item at a time. Useful for Tab layouts.
 * @param {boolean} [config.header]
 * @param {boolean} [config.headerColor]
 * @param {string} [config.headerBackgroundColor]
 * @param {string} [config.headerBorderRadius]
 * @param {string} [config.headerBorderColor]
 * @param {object[]} [config.headerButtons] - Buttons injected in the header. See example below.
 * @param {object[]} [config.headerIcons] - Icons injected in the header. See example below.
 * @param {string} [config.title]
 * @param {string} [config.icon]
 * @param {string} [config.iconColor]
 * @param {string} [config.iconSize]
 * @param {boolean} [config.modal] - Makes the panel modal (clicking out of the panel will close it)
 * @param {boolean} [config.expandable] - Adds a header icon to expand the panel in fullscreen
 * @param {boolean} [config.closable] - Adds a header icon to close the panel
 * @param {string} [config.closeMethod] - Use "hide" or "remove" (default, destroys the DOM node)
 * @param {boolean} [config.draggable] - Makes the panel draggable.
 * @param {boolean} [config.collapsible] - Allows the panel content to be collapsed. Note that This property is disabled if the panel is also draggable.
 * @param {boolean} [config.collapsed] - Default collapse state
 * @param {string} [config.position]
 * @param {string|number} [config.top]
 * @param {string|number} [config.left]
 * @param {string|number} [config.right]
 * @param {string} [config.align] - "center" to center the panel horizontally on the screen
 * @param {string} [config.verticalAlign] - "center" to center the panel vertically on the screen
 * @param {string} [config.layout]
 * @param {string} [config.display]
 * @param {string} [config.flex]
 * @param {string} [config.flexFlow]
 * @param {string} [config.flexWrap]
 * @param {string} [config.alignItems]
 * @param {string} [config.alignContent]
 * @param {string} [config.justifyContent]
 * @param {string|number} [config.width]
 * @param {string|number} [config.minWidth]
 * @param {string|number} [config.maxWidth]
 * @param {string|number} [config.height] - A calculation involving the header's height and panel border-width
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.background]
 * @param {string} [config.backgroundColor]
 * @param {string} [config.backgroundImage]
 * @param {string} [config.backgroundSize]
 * @param {string} [config.border]
 * @param {string} [config.borderStyle]
 * @param {string} [config.borderWidth]
 * @param {string} [config.borderColor]
 * @param {string} [config.borderRadius]
 * @param {string} [config.boxShadow]
 * @param {string} [config.overflow]
 * @param {string} [config.overflowX]
 * @param {string} [config.overflowY]
 * @param {number} [config.zIndex]
 * @param {number} [config.opacity]
 * @param {number} [config.transform] 
 * @param {string} [maskBackgroundColor] - Allows to adjust the opacity of the mask for modal windows. Example: rgba(0, 0, 0, 0.5)
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-panel class="a-panel">
 *  <div class="panel-header">
 *      <span class="panel-icon"></span>
 *      <span class="panel-title"></span>
 *      <span class="panel-custom-buttons"></span>
 *      <span class="panel-button-expand-collapse"></span>
 *      <span class="panel-button-maximize"></span>
 *      <span class="panel-button-close"></span>
 *  </div>
 *  <div class="panel-body">
 *      <!-- Panel items are inserted here -->
 *  </div>
 * </a-panel>
 * ```
 * 
 * ## Todo
 * - add a panel footer?
 * - add a panel toolbar?
 * 
 */
kiss.ui.Panel = class Panel extends kiss.ui.Container {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 3 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myPanel = document.createElement("a-panel").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myPanel = createPanel({
     *   title: "Setup"
     *   icon: "fas fa-wrench",
     *   headerBackgroundColor: "#00aaee",
     *   closable: true,
     *   draggable: true,
     *   modal: true,
     *   display: "flex"
     *   flexFlow: "column",
     *   padding: "10px",
     *   items: [
     *       // Panel items...
     *   ]
     * })
     * 
     * myPanel.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myBlock = createBlock({
     *   items: [
     *       {
     *           type: "panel",
     *           title: "Foo",
     *           items: [
     *               // Panel items...
     *           ]
     *       }
     *   ]
     * })
     * myBlock.render()
     * ```
     * 
     * To add buttons or icons in the header:
     * ```
     * createPanel({
     *  headerButtons: [
     *      {
     *          icon: "fas fa-bolt",
     *          text: "Do something"
     *          action: () => this.doSomething()
     *      }
     *  ],
     *  headerIcons: [
     *      {
     *          icon: "fas fa-bolt",
     *          action: () => this.doSomething()
     *      }
     *  ],
     *  items: [
     *      // ...
     *  ]
     * })
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Panel from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        super.init(config)

        // Template
        const id = this.id
        this.innerHTML =
            `${((config.collapsible == true) && (config.draggable != true))
                ? `<div id="panel-header-${id}" class="panel-header panel-header-collapsible">`
                : `<div id="panel-header-${id}" class="panel-header ${(config.draggable == true) ? "panel-header-draggable" : ""}">`
            }
                <span id="panel-icon-${id}" class="panel-icon ${(config.icon) ? config.icon : ""}"></span>
                <span id="panel-title-${id}" class="panel-title">${config.title || ""}</span>
                <span style="flex:1"></span>
                <span class="panel-custom-buttons"></span>
                <span class="panel-custom-icons"></span>
                ${(config.collapsible) ? `<span id="panel-button-expand-collapse-${id}" class="fas fa-chevron-down panel-buttons panel-button-expand-collapse"></span>` : ""}
                ${(config.expandable) ? `<span id="panel-button-maximize-${id}" class="fas fa-window-maximize panel-buttons panel-button-expand"></span>` : ""}
                ${(config.closable) ? `<span id="panel-button-close-${id}" class="fas fa-times panel-buttons panel-button-close"></span>` : ""}
            </div>
            
            <div tabindex=1 id="panel-body-${id}" class="panel-body ${(config.header == false) ? "panel-body-no-header" : ""}">
            </div>`.removeExtraSpaces()

        // Mask (for modal windows)
        if (config.modal == true) {
            this.mask = document.createElement("div")
            this.mask.setAttribute("id", "panel-mask-" + id)
            this.mask.classList.add("panel-mask")
            this.mask.onmousedown = () => $(id).close()
            if (config.zIndex) this.mask.style = `z-index: ${config.zIndex}`
            document.body.appendChild(this.mask)
        }

        // Set properties
        this.panelHeader = this.querySelector(".panel-header")
        this.panelTitle = this.querySelector(".panel-title")
        this.panelIcon = this.querySelector(".panel-icon")
        this.panelButtonExpandCollapse = this.querySelector(".panel-button-expand-collapse")
        this.panelButtons = this.querySelectorAll(".panel-buttons")
        this.panelCustomButtons = this.querySelector(".panel-custom-buttons")
        this.panelCustomIcons = this.querySelector(".panel-custom-icons")

        // Define component's items container (which can vary depending on the component)
        this.panelBody = this.container = this.querySelector(".panel-body")

        // Draggable panels need to have a fixed position
        config.position = (config.draggable) ? "fixed" : ((config.modal) ? "absolute" : (config.position || "relative"))

        // Restrict header's border radius to upper corners
        if ((config.borderRadius) && (config.borderRadius.split(" ").length == 4)) {
            const borderRadiusConfig = config.borderRadius.split(" ")
            const topLeftBorderRadius = borderRadiusConfig[0]
            const topRightBorderRadius = borderRadiusConfig[1]
            config.headerBorderRadius = topLeftBorderRadius + " " + topRightBorderRadius + " 0px 0px"
        }

        this._setProperties(config, [
            [
                ["position", "top", "left", "right", "flex", "margin", "border", "borderColor", "borderRadius", "boxShadow", "transform", "zIndex", "opacity"],
                [this.style]
            ],
            [
                ["headerHeight=height", "headerBackgroundColor=background", "headerBorderColor=borderColor", "headerBorderRadius=borderRadius"],
                [this.panelHeader.style]
            ],
            [
                ["headerColor=color"],
                [this.panelTitle.style]
            ],            
            [
                ["headerColor=color", "iconColor=color", "iconSize=fontSize"],
                [this.panelIcon.style]
            ],
            [
                ["headerColor=color"],
                Array.from(this.panelButtons).map(panelButton => panelButton.style)
            ],                 
            [
                ["display", "flexFlow", "flexWrap", "alignItems", "alignContent", "justifyContent", "padding", "overflow", "overflowX", "overflowY", "background", "backgroundColor", "backgroundImage", "backgroundSize"],
                [this.panelBody.style]
            ],
            [
                ["maskBackgroundColor=backgroundColor"],
                [this.mask?.style]
            ]
        ])

        // Header visibility
        if (config.header == false) this.panelHeader.style.display = "none"

        // Close action (hide or remove)
        this.closeMethod = config.closeMethod || "remove"

        // Draggable
        if (config.draggable == true) this._enableDrag()

        // Collapsible
        if (config.collapsible) this.isCollapsible = true

        // Default state
        this.expanded = true

        // If it's a draggable (floating) windows or auto-centered window, we update the layout when window is resized
        if (config.draggable || config.align == "center" || config.verticalAlign == "center") {
            this.subscriptions.push(subscribe("EVT_WINDOW_RESIZED", () => this.updateLayout()))
        }

        // Add custom header buttons
        if (config.headerButtons) {
            config.headerButtons.forEach(button => this.addHeaderButton(button))
        }

        // Add custom header icons
        if (config.headerIcons) {
            config.headerIcons.forEach(icon => this.addHeaderIcon(icon))
        }

        // Collapse panel if requested
        if (config.collapsed) {
            setTimeout(() => this.collapse(), 0)
        }

        this._initHeaderClickEvent()
        return this
    }

    /**
     * Manage click event in the panel's header to perform various actions like "close", "expand", "collapse"...
     * 
     * @private
     * @ignore
     */
    _initHeaderClickEvent() {
        this.panelHeader.onclick = function(event) {
            const element = event.target
            const panel = element.closest("a-panel")

            if (element.classList.contains("panel-button-close")) {
                panel.close()
            }
            else if (element.classList.contains("panel-button-expand")) {
                panel.maximize(20)
            }
            else if (element.classList.contains("panel-button-expand-collapse") || element.classList.contains("panel-header-collapsible")) {
                panel.expandCollapse()
            }
            else if ((element.classList.contains("panel-title") || element.classList.contains("panel-icon")) && panel.config.collapsible === true && panel.config.draggable !== true) {
                panel.expandCollapse()
            }
        }
    }

    /**
     * Set or update the panel icon
     * 
     * @param {string} iconClass
     * @returns this
     */
    setIcon(iconClass) {
        this.config.icon = iconClass
        this.panelIcon.className = "panel-icon " + iconClass
        return this
    }

    /**
     * Set or update the panel header text color
     * 
     * @param {string} color - Hexa color code. Ex: #00aaee
     * @returns this
     */
    setHeaderColor(color) {
        this.config.headerColor = color
        this.panelIcon.style.color = color
        this.panelTitle.style.color = color
        Array.from(this.panelButtons).forEach(panelButton => panelButton.style.color = color)
        return this
    }

    /**
     * Set or update the panel header background color
     * 
     * @param {string} color - Hexa color code. Ex: #00aaee
     * @returns this
     */
    setHeaderBackgroundColor(color) {
        this.config.headerBackgroundColor = color
        this.panelHeader.style.backgroundColor = color
        return this
    }

    /**
     * Add a custom button inside the panel's header
     * 
     * @param {object} config
     * @param {string} config.icon - Font Awesome icon class. Ex: "fas fa-check"
     * @param {string} config.tip - Help text
     * @param {function} config.action - Action performed when the button is clicked
     * @returns this
     * 
     * @example
     * myPanel.addHeaderButton({
     *  icon: "fas fa-check",
     *  text: "Save and exit",
     *  action: async () => {
     *      await myRecord.save()
     *      myPanel.close()
     *  }
     * })
     */
    addHeaderButton(config) {
        const button = createButton(config)
        button.classList.add("panel-button")
        this.panelCustomButtons.appendChild(button)
        return this
    }

    /**
     * Add a custom icon inside the panel's header
     * 
     * @param {object} config
     * @param {string} config.icon - Font Awesome icon class. Ex: "fas fa-cog"
     * @param {string} config.tip - Help text
     * @param {function} config.action - Action performed when the icon is clicked
     * @returns this
     * 
     * @example
     * myPanel.addHeaderIcon({
     *  icon: "fas fa-cog",
     *  tip: "Opens the model properties",
     *  action: () => kiss.views.show("model-properties")
     * })
     */    
    addHeaderIcon(config) {
        if (!config.icon) return

        const icon = document.createElement("span")
        icon.setAttribute("id", kiss.tools.shortUid())
        icon.classList.add("panel-buttons", ...config.icon.split(" "))
        if (config.action) icon.onclick = config.action
        if (config.tip) icon.attachTip(config.tip)
        
        this.panelCustomIcons.appendChild(icon)
        return this
    }

    /**
     * Set the panel's title
     * 
     * @param {string} newTitle
     * @returns this
     */
    setTitle(newTitle) {
        this.panelTitle.innerHTML = newTitle
        return this
    }

    /**
     * Set the Html content of the panel component
     * 
     * @param {string} html
     * @returns this
     */
    setInnerHtml(html) {
        this.panelBody.innerHTML = html
        return this
    }

    /**
     * Get the Html content of the panel component
     * 
     * @returns {string} The html content
     */
    getInnerHtml() {
        return this.panelBody.innerHTML
    }

    /**
     * Close the panel using one of 2 possible behaviors:
     * - hide: just hide the panel, without removing it from the DOM
     * - remove: (default) remove the panel from the DOM + all its children + all listeners + all subscriptions
     * 
     * The close method also checks if an event "close|onclose|onClose" has been defined:
     * - if it has been defined, the method is executed
     * - if it returns false, the closing is interrupted
     * 
     * @param {string} [closeMethod] - "hide" or "remove"
     * @param {boolean} [forceClose] - true to force closing, even if the close event returns false
     * @returns {boolean} false if the panel couldn't be closed
     */
    close(closeMethod, forceClose = false) {
        // Trigger onclose event if required
        const closeEvent = (this.config?.events?.onclose) || (this.config?.events?.onClose) || (this.config?.events?.close)
        if (closeEvent) {
            const doClose = closeEvent(forceClose)

            // If the closeEvent returns false, we prevent from closing
            if (doClose === false) return false
        }

        let method = closeMethod || this.closeMethod
        if (method == "hide") {
            this.hide()
            if (this.mask) this.mask.hide()
        } else {
            kiss.views.remove(this.id)
            if (this.mask) kiss.views.remove("panel-mask-" + this.id)
        }
        return true
    }

    /**
     * Set the new panel width
     * 
     * The width can be:
     * - a number, which will be converted in pixels
     * - a valid CSS value: 50px, 10vw
     * - a function that returns a number or a valid CSS value
     * 
     * @param {number|string|function} width 
     * @returns this
     * 
     * @example
     * myPanel.setWidth(500)
     * myPanel.setWidth("500px")
     * myPanel.setWidth("40%")
     * myPanel.setWidth(() => kiss.screen.current.width / 2) // Half the current screen size
     */
    setWidth(width) {
        this.config.width = width
        this.updateLayout()
        return this
    }

    /**
     * Set the new panel height
     * 
     * The height can be:
     * - a number, which will be converted in pixels
     * - a valid CSS value: 50px, 10vw
     * - a function that returns a number or a valid CSS value
     * 
     * @param {number|string|function} height 
     * @returns this
     * 
     * @example
     * myPanel.setHeight(500)
     * myPanel.setHeight("500px")
     * myPanel.setHeight("40%")
     * myPanel.setHeight(() => kiss.screen.current.height / 2) // Half the current screen size
     */    
    setHeight(height) {
        this.config.height = height
        this.updateLayout()
        return this
    }

    /**
     * Collapse the panel
     * 
     * @returns this
     */
    collapse() {
        if (this.expanded) {
            let panelBorderWidth = Number(getComputedStyle(this, "")["border-width"].replace("px", ""))
            this.style.height = (this.panelHeader.offsetHeight + 2 * panelBorderWidth).toString() + "px"
            this.panelBody.style.height = "0px"
            this.panelBody.style.padding = "0px"
            this.panelButtonExpandCollapse.classList.remove("fa-chevron-down")
            this.panelButtonExpandCollapse.classList.add("fa-chevron-right")
            this.expanded = false
        }
        return this
    }

    /**
     * Expand the panel
     * 
     * @returns this
     */
    expand() {
        if (!this.expanded) {
            if (this.config.height) {
                this._setHeight()
            } else {
                this.style.height = ""
                this.panelBody.style.height = ""
                this.panelBody.style.padding = ""
            }

            this.panelButtonExpandCollapse.classList.remove("fa-chevron-right")
            this.panelButtonExpandCollapse.classList.add("fa-chevron-down")
            this.expanded = true
        }
        return this
    }

    /**
     * Expand / Collapse the panel alternatively
     * 
     * @returns this
     */
    expandCollapse() {
        if (!this.isCollapsible) return

        if (this.expanded) {
            this.collapse()
        } else {
            this.expand()
        }
        return this
    }

    /**
     * Enable / Disable the collapsible property
     * 
     * @param {boolean} status
     * @returns this
     */
    setCollapsible(status) {
        this.isCollapsible = status
        return this
    }

    /**
     * Set the panel to the max size
     * 
     * @param {boolean} [delta] - Optional values, in pixels, to make the panel a bit smaller than fullscreen
     * @param {boolean} [state] - true to force fullscreen mode / false to exit fullscreen mode / leave undefined to alternate
     * @returns this
     */
    maximize(delta = 0, state) {
        // Exit for non changing states
        if (this.isFullscreen && state == true) return
        if (!this.isFullscreen && state === false) return

        if (this.isFullscreen != true || state == true) {
            // Set full screen
            // Keep actual values so we can restore them when fullscreen is unset
            this.isFullscreen = true
            this.fullscreenDelta = delta

            this.currentWidth = this.config.width || this.clientWidth
            this.currentHeight = this.config.height || this.clientHeight
            this.currentTop = this.config.top
            this.currentLeft = this.config.left

            // Update config values
            this.config.width = () => kiss.screen.current.width - delta
            this.config.height = () => kiss.screen.current.height - delta
            this.config.top = delta / 2
            this.config.left = delta / 2
        }
        else if (this.isFullscreen == true || state === false) {
            // Unset full screen
            this.isFullscreen = false
            this.config.width = this.currentWidth
            this.config.height = this.currentHeight
            this.config.top = this.currentTop
            this.config.left = this.currentLeft
        }

        this.updateLayout()
        return this
    }

    /**
     * Set the panel to its original size, if it was maximized
     * 
     * @returns this
     */
    minimize() {
        this.maximize(this.fullscreenDelta, false)
        return this
    }

    /**
     * Show the panel's header
     * 
     * @returns this
     */
    showHeader() {
        this.panelHeader.show()
        return this
    }

    /**
     * Hide the panel's header
     * 
     * @returns this
     */
    hideHeader() {
        this.panelHeader.hide()
        return this
    }

    /**
     * Enable a draggable behavior on the Panel
     * 
     * @private
     * @ignore
     */
    _enableDrag() {
        let _this = this
        let deltaX = 0
        let deltaY = 0
        let posX = 0
        let posY = 0

        // Enable the element's header
        let header = _this.querySelector(".panel-header")
        if (header.style.display != "none") {
            header.onmousedown = dragStart
        } else {
            _this.onmousedown = dragStart
        }

        // Enable dragging
        function dragStart(e) {
            e = e || window.event
            e.stop()

            posX = e.clientX
            posY = e.clientY
            document.onmouseup = dragStop
            document.onmousemove = dragMove
        }

        // Move
        function dragMove(e) {
            e = e || window.event

            // Prevent drag behavior when the cursor is inside a field, to allow text selection
            if (e.target.nodeName == "INPUT") {
                dragStop(e)
                return e
            }
            e.stop()

            deltaX = posX - e.clientX
            deltaY = posY - e.clientY
            posX = e.clientX
            posY = e.clientY

            _this.style.opacity = "0.8"
            _this.style.top = (_this.offsetTop - deltaY) + "px"
            _this.style.left = (_this.offsetLeft - deltaX) + "px"
        }

        // Disable dragging
        function dragStop(e) {
            e = e || window.event
            e.stop()

            _this.style.opacity = "1"
            document.onmouseup = null
            document.onmousemove = null
        }
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-panel", kiss.ui.Panel)

/**
 * Shorthand to create a new Panel. See [kiss.ui.Panel](kiss.ui.Panel.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createPanel = (config) => document.createElement("a-panel").init(config)

;